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 others: BTreeSet<String> = sent
2041 .iter()
2042 .enumerate()
2043 .filter(|(j, _)| j != wi)
2044 .map(|(_, j)| j.spec.id.clone())
2045 .collect();
2046 let Some(job) = sent.get_mut(*wi) else {
2047 continue;
2048 };
2049 let start = self
2054 .roles
2055 .implementer_roster
2056 .iter()
2057 .position(|s| s.id == job.spec.id)
2058 .unwrap_or(0);
2059 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2060 let mut fallback_attempt = 0usize;
2061 let mut prev: Option<FailClass> = None;
2062 while let Some(cur) = FailClass::of(&*out) {
2063 if !should_hand_over(prev.as_ref(), &cur) {
2064 break;
2065 }
2066 let Some(next) =
2067 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2068 .cloned()
2069 else {
2070 break;
2071 };
2072 tried.insert(next.id.clone());
2073 fallback_attempt += 1;
2074
2075 if let Ok(r) = git::rescue_commit(
2076 &job.cwd,
2077 &format!(
2078 "magi: candidate {} (uncommitted work before {} fallback)",
2079 seat.key,
2080 if cur == FailClass::Quota {
2081 "quota"
2082 } else {
2083 "handover"
2084 }
2085 ),
2086 )
2087 .await
2088 {
2089 self.state.note_withheld("implement", &r.withheld);
2090 }
2091
2092 record_handover(
2093 &mut self.state,
2094 "implement",
2095 &seat.key,
2096 &seat.agent,
2097 &next.id,
2098 &cur,
2099 &fail_reason(&*out),
2100 );
2101 prev = Some(cur.clone());
2102
2103 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2104 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2105 job.spec = next.clone();
2113 let mut retry = job.clone();
2114 retry.seat = new_seat;
2115 retry.prompt = prompt::implement(
2116 &instruction,
2117 &job.cwd.to_string_lossy(),
2118 &language,
2119 brief.as_deref(),
2120 &attachments,
2121 );
2122 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2123 let cache = self.state.config.cache_dir();
2124 let ctx = WaveCtx {
2125 carry_seats: false,
2126 run: run_id,
2127 node: "implement",
2128 prompts,
2129 cache: cache.as_deref(),
2130 round: None,
2131 };
2132 let (fallback_seat, fallback_out) = run_one(
2133 retry,
2134 Arc::clone(&self.sem),
2135 &ctx,
2136 &mut self.state,
2137 fallback_attempt,
2138 )
2139 .await;
2140 *seat = fallback_seat;
2141 *out = fallback_out;
2142 }
2143 }
2144 }
2145
2146 async fn resume_unconfirmed_commands(
2170 &mut self,
2171 results: &mut [(usize, SeatState, AgentOutcome)],
2172 sent: &[SeatJob],
2173 prompts: &Prompts,
2174 run_id: &str,
2175 ) {
2176 for (wi, seat, out) in results.iter_mut() {
2177 let AgentOutcome::Ok(o) = &*out else {
2178 continue;
2179 };
2180 if !has_unconfirmed_command(&o.commands) {
2181 continue;
2182 }
2183 let Some(job) = sent.get(*wi) else { continue };
2184 if !has_context(&job.spec, seat, job.sessions) {
2185 self.state.event(
2186 "implement",
2187 format!(
2188 "{}: the reply named a command whose own CLI never confirmed the exit \
2189 status of, but there is no session left to resume",
2190 seat.key
2191 ),
2192 );
2193 continue;
2194 }
2195 self.state.event(
2196 "implement",
2197 format!(
2198 "{}: the reply named a command whose own CLI never confirmed the exit \
2199 status of; resuming the conversation",
2200 seat.key
2201 ),
2202 );
2203 let mut retry = job.clone();
2204 retry.seat = seat.clone();
2205 retry.prompt = prompt::resume_incomplete(
2206 "a command in your last reply had no confirmed exit status",
2207 );
2208 retry.timeout = retry_budget(job.timeout, true);
2209 retry.stem = format!("{}-confirm", job.stem);
2210 let cache = self.state.config.cache_dir();
2211 let ctx = WaveCtx {
2212 carry_seats: false,
2213 run: run_id,
2214 node: "implement",
2215 prompts,
2216 cache: cache.as_deref(),
2217 round: None,
2218 };
2219 let (resumed_seat, resumed) =
2220 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2221 *seat = resumed_seat;
2222 *out = resumed;
2223 }
2224 }
2225
2226 async fn continue_fix_report(
2247 &mut self,
2248 mut seat: SeatState,
2249 parse_err: String,
2250 job: &SeatJob,
2251 prompts: &Prompts,
2252 run_id: &str,
2253 round: usize,
2254 ) -> (
2255 SeatState,
2256 Option<FixReport>,
2257 Option<String>,
2258 ContinuationRecord,
2259 ) {
2260 let mut last_err = parse_err;
2261 let mut cumulative_wait_ms = 0u64;
2262 let mut attempts = 0usize;
2263 loop {
2264 if !has_context(&job.spec, &seat, job.sessions) {
2265 self.state.event(
2266 "fix",
2267 format!(
2268 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2269 session left to resume into"
2270 ),
2271 );
2272 let outcome = if attempts == 0 {
2273 ContinuationOutcome::NoSession
2274 } else {
2275 ContinuationOutcome::Exhausted
2276 };
2277 return (
2278 seat,
2279 None,
2280 Some(format!("unparsable fix report: {last_err}")),
2281 ContinuationRecord {
2282 attempts,
2283 cumulative_wait_ms,
2284 outcome,
2285 },
2286 );
2287 }
2288 if attempts >= MAX_FIX_CONTINUATIONS {
2289 self.state.event(
2290 "fix",
2291 format!(
2292 "round {round}: fixer's reply still had no adoption report after \
2293 {attempts} continuation(s) ({last_err}); giving up"
2294 ),
2295 );
2296 return (
2297 seat,
2298 None,
2299 Some(format!(
2300 "unparsable fix report after {attempts} continuation(s): {last_err}"
2301 )),
2302 ContinuationRecord {
2303 attempts,
2304 cumulative_wait_ms,
2305 outcome: ContinuationOutcome::Exhausted,
2306 },
2307 );
2308 }
2309 attempts += 1;
2310 self.state.event(
2311 "fix",
2312 format!(
2313 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2314 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2315 ),
2316 );
2317 let mut retry = job.clone();
2318 retry.seat = seat.clone();
2319 retry.prompt = prompt::resume_incomplete(&last_err);
2320 retry.timeout = retry_budget(job.timeout, true);
2321 retry.stem = format!("{}-continue{attempts}", job.stem);
2322 let cache = self.state.config.cache_dir();
2323 let ctx = WaveCtx {
2324 carry_seats: false,
2325 run: run_id,
2326 node: "fix",
2327 prompts,
2328 cache: cache.as_deref(),
2329 round: Some(round),
2330 };
2331 let (resumed_seat, resumed_out) = run_one(
2332 retry,
2333 Arc::clone(&self.sem),
2334 &ctx,
2335 &mut self.state,
2336 attempts,
2337 )
2338 .await;
2339 seat = resumed_seat;
2340 match resumed_out {
2341 AgentOutcome::Ok(o) => {
2342 cumulative_wait_ms += o.duration_ms;
2343 match verdict::extract_json::<FixReport>(&o.text) {
2344 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2345 self.state.event(
2346 "fix",
2347 format!(
2348 "round {round}: fixer's adoption report recovered after \
2349 {attempts} continuation(s)"
2350 ),
2351 );
2352 return (
2353 seat,
2354 Some(report),
2355 None,
2356 ContinuationRecord {
2357 attempts,
2358 cumulative_wait_ms,
2359 outcome: ContinuationOutcome::Resumed,
2360 },
2361 );
2362 }
2363 Ok(_) => {
2371 last_err = "the reply parsed, but it reported a command whose own CLI \
2372 never confirmed an exit status"
2373 .to_owned();
2374 }
2375 Err(e) => last_err = e.to_string(),
2376 }
2377 }
2378 AgentOutcome::Quota(o) => {
2379 cumulative_wait_ms += o.duration_ms;
2380 self.state.quota.push(QuotaLoss {
2381 seat: seat.key.clone(),
2382 node: "fix".to_owned(),
2383 at: Timestamp::now(),
2384 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2385 });
2386 self.state.event(
2387 "fix",
2388 format!(
2389 "round {round}: continuation rate limited (quota); not retrying now"
2390 ),
2391 );
2392 return (
2393 seat,
2394 None,
2395 Some("rate limited (quota) while recovering the fix report".to_owned()),
2396 ContinuationRecord {
2397 attempts,
2398 cumulative_wait_ms,
2399 outcome: ContinuationOutcome::QuotaLost,
2400 },
2401 );
2402 }
2403 AgentOutcome::Dropped(o) => {
2404 cumulative_wait_ms += o.duration_ms;
2405 let why = o
2406 .dropped
2407 .as_ref()
2408 .map(|d| d.why.as_str())
2409 .unwrap_or("the CLI ended the stream without delivering its answer");
2410 last_err = format!("the CLI dropped the stream ({why})");
2411 }
2412 AgentOutcome::Failed(e) => last_err = e,
2413 }
2414 }
2415 }
2416
2417 fn after_implement(&mut self) -> Result<()> {
2418 if self.state.leaks.is_empty() {
2420 let cfg = self.state.config.blind.clone();
2421 let mut leaks = Vec::new();
2422 for c in &self.state.candidates {
2423 let Some(patch) =
2424 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2425 else {
2426 continue;
2427 };
2428 leaks.extend(blind::scan(
2429 &format!("candidate {} patch", c.label),
2430 &patch,
2431 &cfg.vendor_tokens,
2432 ));
2433 }
2434 if !leaks.is_empty() {
2435 let summary = leaks
2436 .iter()
2437 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2438 .collect::<Vec<_>>()
2439 .join(", ");
2440 match cfg.on_leak {
2441 LeakPolicy::Fail => {
2442 self.state.status = RunStatus::Failed;
2443 self.state
2444 .event("blind", format!("vendor text in a patch: {summary}"));
2445 self.state.leaks = leaks;
2446 self.state.save()?;
2447 self.settle_questions();
2448 bail!(
2449 "blind.on_leak = \"fail\" and vendor text reached a \
2450 judged patch: {summary}"
2451 );
2452 }
2453 LeakPolicy::Redact => self.state.event(
2454 "blind",
2455 format!("redacting vendor text for judging: {summary}"),
2456 ),
2457 LeakPolicy::Warn => self.state.event(
2458 "blind",
2459 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2460 ),
2461 }
2462 self.state.leaks = leaks;
2463 }
2464 }
2465
2466 if self.state.viable().is_empty() {
2467 if self.state.all_candidates_verified_noop() {
2468 self.state.status = RunStatus::VerifiedNoop;
2479 self.state.save()?;
2480 self.settle_questions();
2481 return Ok(());
2482 }
2483 self.state.status = RunStatus::Failed;
2484 self.state.save()?;
2485 self.settle_questions();
2486 bail!("no candidate produced a change; nothing to judge");
2487 }
2488 self.state.status = RunStatus::Judging;
2489 self.state.save()?;
2490 Ok(())
2491 }
2492
2493 async fn judge(&mut self) -> Result<()> {
2496 let run_id = self.state.id.clone();
2501 let prompts = self.state.config.prompts.clone();
2502 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2503 return Ok(());
2504 }
2505 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2506 if viable.len() == 1 {
2507 self.state.judge_skipped = true;
2514 self.state.event(
2515 "judge",
2516 format!(
2517 "only candidate {} produced a change; judging skipped",
2518 viable[0].label
2519 ),
2520 );
2521 self.state.save()?;
2522 return Ok(());
2523 }
2524 self.state.status = RunStatus::Judging;
2525
2526 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2527 let language = self.state.config.graph.language.clone();
2528 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2529 let sessions = self.state.config.graph.sessions;
2530 let artifacts = agent::artifacts_dir(&self.state.dir());
2531 let root = self.state.worktree_root();
2532 let base_short = short(&self.state.base_commit);
2533
2534 let mut jobs = Vec::new();
2535 let mut orders = Vec::new();
2536 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2537 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2538 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2539 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2540 let seat_key = format!("judge-{}", j + 1);
2541 let seat = self.seat(&seat_key, &spec.id);
2542 jobs.push(SeatJob {
2543 prompt: prompt::judge(
2544 &self.state.instruction,
2545 &views,
2546 self.roles.judges.len(),
2547 &base_short,
2548 &language,
2549 ),
2550 spec,
2551 seat,
2552 cwd: root.join(format!("judge-{}", j + 1)),
2553 timeout,
2554 allow_write: false,
2555 sessions,
2556 artifacts: artifacts.clone(),
2557 stem: format!("judge-{}", j + 1),
2558 handover: None,
2559 });
2560 }
2561
2562 self.state.event(
2563 "judge",
2564 format!(
2565 "{} judges ranking {} candidates blind",
2566 jobs.len(),
2567 viable.len()
2568 ),
2569 );
2570 let labels_for_check = labels.clone();
2571 let mut quota_losses = Vec::new();
2572 let cache = self.state.config.cache_dir();
2573 let ctx = WaveCtx {
2574 carry_seats: false,
2575 run: &run_id,
2576 node: "judge",
2577 prompts: &prompts,
2578 cache: cache.as_deref(),
2579 round: None,
2580 };
2581 let results = ask_json_wave::<Ranking>(
2582 jobs,
2583 Arc::clone(&self.sem),
2584 self.state.config.graph.retries,
2585 &self.roles.judge_roster,
2586 &ctx,
2587 &mut quota_losses,
2588 &mut self.state,
2589 &move |r: &Ranking| r.validate(&labels_for_check),
2590 )
2591 .await;
2592 self.state.quota.extend(quota_losses);
2593
2594 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2595 let agent_id = seat.agent.clone();
2596 self.state.seats.insert(seat.key.clone(), seat);
2597 let mut record = Judgement {
2598 judge: j + 1,
2599 seat: format!("judge-{}", j + 1),
2600 agent: agent_id,
2601 ranking: Vec::new(),
2602 reasons: BTreeMap::new(),
2603 confidence: None,
2604 order: orders[j].clone(),
2605 failed: None,
2606 duration_ms: 0,
2607 };
2608 match res {
2609 Ok((ranking, out)) => {
2610 record.ranking = ranking.normalized();
2611 record.reasons = ranking.reasons;
2612 record.confidence = ranking.confidence;
2613 record.duration_ms = out.duration_ms;
2614 self.state.event(
2615 "judge",
2616 format!(
2617 "judge {} ranked {}",
2618 j + 1,
2619 record.ranking.iter().collect::<String>()
2620 ),
2621 );
2622 }
2623 Err(e) => {
2624 record.failed = Some(e.to_string());
2625 self.state
2626 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2627 }
2628 }
2629 self.state.judgements.push(record);
2630 self.state.save()?;
2631 }
2632 Ok(())
2633 }
2634
2635 async fn deliberate(&mut self) -> Result<()> {
2638 let run_id = self.state.id.clone();
2643 let prompts = self.state.config.prompts.clone();
2644 if !self.state.deliberation.is_empty() {
2645 return Ok(());
2646 }
2647 let tops: Vec<char> = self
2648 .state
2649 .judgements
2650 .iter()
2651 .filter_map(|j| j.ranking.first().copied())
2652 .collect();
2653 let rounds = self.state.config.graph.deliberate_rounds;
2654 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2655 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2656 self.state.event(
2657 "deliberate",
2658 format!("judges agreed on {} outright; no deliberation", tops[0]),
2659 );
2660 }
2661 self.state.status = RunStatus::Voting;
2662 self.state.save()?;
2663 return Ok(());
2664 }
2665
2666 self.state.status = RunStatus::Deliberating;
2667 self.state.event(
2668 "deliberate",
2669 format!(
2670 "split: first choices were {} — opening {rounds} round(s)",
2671 tops.iter().collect::<String>()
2672 ),
2673 );
2674
2675 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2676 let language = self.state.config.graph.language.clone();
2677 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2678 let sessions = self.state.config.graph.sessions;
2679 let artifacts = agent::artifacts_dir(&self.state.dir());
2680 let root = self.state.worktree_root();
2681 let base_short = short(&self.state.base_commit);
2682
2683 for round in 1..=rounds {
2687 let mut turns: Vec<DeliberationTurn> = Vec::new();
2688 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2689 if self.state.judgements[j].failed.is_some() {
2690 continue;
2691 }
2692 let seat_key = format!("judge-{}", j + 1);
2693 let spec = self.occupant(&seat_key, spec);
2694 let mut seat = self.seat(&seat_key, &spec.id);
2695 let transcript = self.transcript(&turns, j);
2696 let build = |context: Option<&str>| {
2697 prompt::deliberate(
2698 &self.state.instruction,
2699 context,
2700 &transcript,
2701 round,
2702 rounds,
2703 &language,
2704 )
2705 };
2706 let block = self.candidate_block(&viable, &base_short);
2707 let full = build(Some(&block));
2708 let text = if has_context(&spec, &seat, sessions) {
2709 build(None)
2710 } else {
2711 full.clone()
2712 };
2713 let job = SeatJob {
2714 spec,
2715 seat: seat.clone(),
2716 prompt: text,
2717 cwd: root.join(format!("judge-{}", j + 1)),
2718 timeout,
2719 allow_write: false,
2720 sessions,
2721 artifacts: artifacts.clone(),
2722 stem: format!("delib-{round}-judge-{}", j + 1),
2723 handover: Some(full),
2724 };
2725 let cache = self.state.config.cache_dir();
2726 let ctx = WaveCtx {
2727 carry_seats: false,
2728 run: &run_id,
2729 node: "deliberate",
2730 prompts: &prompts,
2731 cache: cache.as_deref(),
2732 round: None,
2733 };
2734 let mut losses = Vec::new();
2738 let mut results = ask_wave_with::<String>(
2739 vec![job],
2740 Arc::clone(&self.sem),
2741 0,
2742 &self.roles.judge_roster,
2743 &ctx,
2744 &mut losses,
2745 &mut self.state,
2746 &|text: &str| {
2747 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2748 },
2749 )
2750 .await;
2751 self.state.quota.extend(losses);
2752 let (updated, res, _) = results.pop().expect("one job in, one result out");
2753 seat = updated;
2754 let agent_id = seat.agent.clone();
2755 self.state.seats.insert(seat.key.clone(), seat);
2756 let body = match res {
2757 Ok((body, _)) => body,
2758 Err(e) => {
2761 self.state
2762 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2763 continue;
2764 }
2765 };
2766 let tentative = verdict::extract_json::<Position>(&body)
2767 .ok()
2768 .and_then(|p| p.tentative)
2769 .and_then(|s| s.trim().chars().next())
2770 .map(|c| c.to_ascii_uppercase());
2771 self.state.event(
2772 "deliberate",
2773 format!(
2774 "round {round}: judge {} now favours {}",
2775 j + 1,
2776 tentative.map_or("—".to_owned(), |c| c.to_string())
2777 ),
2778 );
2779 turns.push(DeliberationTurn {
2780 judge: j + 1,
2781 agent: agent_id,
2782 body: blind::sanitize_prose(&body, &self.state.config.blind),
2783 tentative,
2784 });
2785 }
2786 self.state
2787 .deliberation
2788 .push(DeliberationRound { round, turns });
2789 self.state.save()?;
2790 }
2791
2792 self.state.status = RunStatus::Voting;
2793 self.state.save()?;
2794 Ok(())
2795 }
2796
2797 async fn vote(&mut self) -> Result<()> {
2800 let run_id = self.state.id.clone();
2805 let prompts = self.state.config.prompts.clone();
2806 if !self.state.votes.is_empty() {
2807 return Ok(());
2808 }
2809 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2810 if viable.len() == 1 {
2811 return Ok(());
2812 }
2813 self.state.status = RunStatus::Voting;
2814
2815 let language = self.state.config.graph.language.clone();
2816 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2817 let sessions = self.state.config.graph.sessions;
2818 let artifacts = agent::artifacts_dir(&self.state.dir());
2819 let root = self.state.worktree_root();
2820 let base_short = short(&self.state.base_commit);
2821 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2822
2823 let mut jobs = Vec::new();
2824 let mut seats_at = Vec::new();
2825 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2826 if self
2827 .state
2828 .judgements
2829 .get(j)
2830 .is_some_and(|r| r.failed.is_some())
2831 {
2832 continue;
2833 }
2834 let seat_key = format!("judge-{}", j + 1);
2835 let spec = self.occupant(&seat_key, spec);
2836 let seat = self.seat(&seat_key, &spec.id);
2837 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2838 let text = if has_context(&spec, &seat, sessions) {
2839 prompt::final_vote(&viable, &language)
2840 } else {
2841 full.clone()
2842 };
2843 jobs.push(SeatJob {
2844 spec,
2845 seat,
2846 prompt: text,
2847 cwd: root.join(format!("judge-{}", j + 1)),
2848 timeout,
2849 allow_write: false,
2850 sessions,
2851 artifacts: artifacts.clone(),
2852 stem: format!("vote-judge-{}", j + 1),
2853 handover: Some(full),
2854 });
2855 seats_at.push(j);
2856 }
2857
2858 self.state.event(
2859 "vote",
2860 format!(
2861 "collecting {} final votes one by one, privately",
2862 jobs.len()
2863 ),
2864 );
2865 let allowed = viable.clone();
2866 let mut quota_losses = Vec::new();
2867 let cache = self.state.config.cache_dir();
2868 let ctx = WaveCtx {
2869 carry_seats: false,
2870 run: &run_id,
2871 node: "vote",
2872 prompts: &prompts,
2873 cache: cache.as_deref(),
2874 round: None,
2875 };
2876 let results = ask_json_wave::<FinalVote>(
2877 jobs,
2878 Arc::clone(&self.sem),
2879 self.state.config.graph.retries,
2880 &self.roles.judge_roster,
2881 &ctx,
2882 &mut quota_losses,
2883 &mut self.state,
2884 &move |v: &FinalVote| match v.label() {
2885 Some(c) if allowed.contains(&c) => Ok(()),
2886 other => bail!("vote {other:?} is not one of {allowed:?}"),
2887 },
2888 )
2889 .await;
2890 self.state.quota.extend(quota_losses);
2891
2892 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2893 let agent_id = seat.agent.clone();
2894 self.state.seats.insert(seat.key.clone(), seat);
2895 let initial = self
2896 .state
2897 .judgements
2898 .get(j)
2899 .and_then(|r| r.ranking.first().copied());
2900 let mut record = VoteRecord {
2901 judge: j + 1,
2902 agent: agent_id,
2903 vote: None,
2904 reason: String::new(),
2905 changed: false,
2906 };
2907 match res {
2908 Ok((v, _)) => {
2909 record.vote = v.label();
2910 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2911 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2912 self.state.event(
2913 "vote",
2914 format!(
2915 "judge {} voted {}{}",
2916 j + 1,
2917 record.vote.unwrap_or('?'),
2918 if record.changed { " (changed)" } else { "" }
2919 ),
2920 );
2921 }
2922 Err(e) => {
2923 self.state
2924 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2925 }
2926 }
2927 self.state.votes.push(record);
2928 self.state.save()?;
2929 }
2930 Ok(())
2931 }
2932
2933 fn tally(&mut self) -> Result<()> {
2936 if self.state.tally.is_some() {
2937 return Ok(());
2938 }
2939 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2940 let tops: Vec<char> = self
2941 .state
2942 .judgements
2943 .iter()
2944 .filter_map(|j| j.ranking.first().copied())
2945 .collect();
2946 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
2947
2948 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2951 let mut cast: Vec<char> = Vec::new();
2952 for (i, j) in self.state.judgements.iter().enumerate() {
2953 let vote = self
2954 .state
2955 .votes
2956 .iter()
2957 .find(|v| v.judge == i + 1)
2958 .and_then(|v| v.vote)
2959 .or_else(|| j.ranking.first().copied());
2960 if let Some(v) = vote {
2961 *first_choice.entry(v).or_insert(0) += 1;
2962 cast.push(v);
2963 }
2964 }
2965
2966 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2967 for j in &self.state.judgements {
2968 let n = j.ranking.len();
2969 for (pos, label) in j.ranking.iter().enumerate() {
2970 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
2971 }
2972 }
2973
2974 let best = first_choice.values().copied().max().unwrap_or(0);
2975 let mut leaders: Vec<char> = first_choice
2976 .iter()
2977 .filter(|(_, v)| **v == best)
2978 .map(|(k, _)| *k)
2979 .collect();
2980 let mut tie_break = None;
2981 if leaders.len() > 1 {
2982 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
2983 let borda_leaders: Vec<char> = leaders
2984 .iter()
2985 .copied()
2986 .filter(|l| borda[l] == top_borda)
2987 .collect();
2988 tie_break = Some(if borda_leaders.len() == 1 {
2989 format!(
2990 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
2991 leaders.len()
2992 )
2993 } else {
2994 format!(
2995 "{} way tie on both first-choice votes and Borda points, broken by label order",
2996 leaders.len()
2997 )
2998 });
2999 leaders = borda_leaders;
3000 leaders.sort_unstable();
3001 }
3002 let winner = *leaders
3003 .first()
3004 .or(viable.first())
3005 .context("no candidate to declare a winner from")?;
3006
3007 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3008 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3009 let deliberated = !self.state.deliberation.is_empty();
3010
3011 let quota_seats: std::collections::BTreeSet<&str> =
3015 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3016 let mut present = 0usize;
3017 for (i, j) in self.state.judgements.iter().enumerate() {
3018 if quota_seats.contains(j.seat.as_str()) {
3019 continue;
3020 }
3021 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3022 let voted = self
3023 .state
3024 .votes
3025 .iter()
3026 .any(|v| v.judge == i + 1 && v.vote.is_some());
3027 if ranked || voted {
3028 present += 1;
3029 }
3030 }
3031 let needs_quorum = viable.len() > 1;
3037 let judges_total = if needs_quorum {
3038 self.roles.judges.len()
3039 } else {
3040 0
3041 };
3042 let quorum = if needs_quorum {
3043 judges_total / 2 + 1
3044 } else {
3045 0
3046 };
3047 let met_quorum = !needs_quorum || present >= quorum;
3048 let uncontested = (!needs_quorum).then(|| {
3049 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3050 });
3051
3052 self.state.event(
3053 "tally",
3054 match &uncontested {
3055 Some(reason) => format!("winner {winner} — {reason}"),
3056 None => format!(
3057 "winner {winner} — votes {} | initial {} | {} changed | \
3058 {present}/{judges_total} judges{}",
3059 first_choice
3060 .iter()
3061 .map(|(k, v)| format!("{k}:{v}"))
3062 .collect::<Vec<_>>()
3063 .join(" "),
3064 if unanimous_initial {
3065 "unanimous"
3066 } else {
3067 "split"
3068 },
3069 changed_votes,
3070 if met_quorum {
3071 String::new()
3072 } else {
3073 format!(" — below quorum ({quorum} required)")
3074 },
3075 ),
3076 },
3077 );
3078 if !met_quorum {
3079 self.state.event(
3080 "stall",
3081 format!(
3082 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3083 the run stops here, resumable"
3084 ),
3085 );
3086 }
3087 self.state.tally = Some(Tally {
3088 first_choice,
3089 borda,
3090 winner,
3091 rankings: tops.len(),
3092 unanimous_initial,
3093 deliberated,
3094 changed_votes,
3095 unanimous_final,
3096 tie_break,
3097 judges: judges_total,
3098 present,
3099 quorum,
3100 met_quorum,
3101 uncontested,
3102 });
3103 self.state.status = if met_quorum {
3104 RunStatus::Reviewing
3105 } else {
3106 RunStatus::Stalled
3107 };
3108 self.state.save()?;
3109 Ok(())
3110 }
3111
3112 #[allow(clippy::too_many_lines)]
3133 async fn recover_stall(&mut self) -> Result<bool> {
3134 let run_id = self.state.id.clone();
3139 let prompts = self.state.config.prompts.clone();
3140 let quota_seats: BTreeSet<&str> =
3145 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3146 let absent: Vec<String> = self
3147 .state
3148 .judgements
3149 .iter()
3150 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3151 .map(|j| j.seat.clone())
3152 .collect();
3153 if absent.is_empty() {
3154 return Ok(false);
3155 }
3156 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3157 if viable.len() <= 1 {
3158 return Ok(false);
3159 }
3160 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3161 let language = self.state.config.graph.language.clone();
3162 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3163 let sessions = self.state.config.graph.sessions;
3164 let artifacts = agent::artifacts_dir(&self.state.dir());
3165 let root = self.state.worktree_root();
3166 let base_short = short(&self.state.base_commit);
3167 let candidates: Vec<Candidate> = viable.clone();
3168
3169 let mut positions: Vec<usize> = absent
3171 .iter()
3172 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3173 .collect();
3174 if positions.is_empty() {
3175 return Ok(false);
3176 }
3177 positions.sort_unstable();
3178 positions.dedup();
3179
3180 let mut judge_jobs = Vec::new();
3182 for &j in &positions {
3183 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3184 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3185 let seat_key = format!("judge-{}", j + 1);
3186 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3187 let seat = self.seat(&seat_key, &spec.id);
3188 judge_jobs.push(SeatJob {
3189 spec,
3190 seat,
3191 prompt: prompt::judge(
3192 &self.state.instruction,
3193 &views,
3194 self.roles.judges.len(),
3195 &base_short,
3196 &language,
3197 ),
3198 cwd: root.join(seat_key),
3199 timeout,
3200 allow_write: false,
3201 sessions,
3202 artifacts: artifacts.clone(),
3203 stem: format!("judge-{}-recover", j + 1),
3204 handover: None,
3205 });
3206 }
3207
3208 let labels_for_check = labels.clone();
3209 let mut judge_losses = Vec::new();
3210 let retries = self.state.config.graph.retries;
3211 let cache = self.state.config.cache_dir();
3212 let ctx = WaveCtx {
3213 carry_seats: false,
3214 run: &run_id,
3215 node: "judge",
3216 prompts: &prompts,
3217 cache: cache.as_deref(),
3218 round: None,
3219 };
3220 let results = ask_json_wave::<Ranking>(
3221 judge_jobs,
3222 Arc::clone(&self.sem),
3223 retries,
3224 &self.roles.judge_roster,
3225 &ctx,
3226 &mut judge_losses,
3227 &mut self.state,
3228 &move |r: &Ranking| r.validate(&labels_for_check),
3229 )
3230 .await;
3231
3232 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3234 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3235 let agent_id = seat.agent.clone();
3236 self.state.seats.insert(seat.key.clone(), seat);
3237 let record = &mut self.state.judgements[j];
3238 match res {
3239 Ok((ranking, out)) => {
3240 record.agent = agent_id;
3241 record.ranking = ranking.normalized();
3242 record.reasons = ranking.reasons;
3243 record.confidence = ranking.confidence;
3244 record.failed = None;
3245 record.duration_ms = out.duration_ms;
3246 recovered.insert(j);
3247 self.state.event(
3248 "recover",
3249 format!("judge {} ranked again after the limit", j + 1),
3250 );
3251 }
3252 Err(e) => {
3253 self.state
3254 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3255 }
3256 }
3257 }
3258
3259 let mut vote_jobs = Vec::new();
3261 let mut vote_pos: Vec<usize> = Vec::new();
3262 for &j in &recovered {
3263 let seat_key = format!("judge-{}", j + 1);
3264 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3265 let seat = self.seat(&seat_key, &spec.id);
3266 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3267 let text = if has_context(&spec, &seat, sessions) {
3268 prompt::final_vote(&labels, &language)
3269 } else {
3270 full.clone()
3271 };
3272 vote_jobs.push(SeatJob {
3273 spec,
3274 seat,
3275 prompt: text,
3276 cwd: root.join(seat_key),
3277 timeout,
3278 allow_write: false,
3279 sessions,
3280 artifacts: artifacts.clone(),
3281 stem: format!("vote-judge-{}-recover", j + 1),
3282 handover: Some(full),
3283 });
3284 vote_pos.push(j);
3285 }
3286 let allowed = labels.clone();
3287 let mut vote_losses = Vec::new();
3288 let vote_retries = self.state.config.graph.retries;
3289 let vote_cache = self.state.config.cache_dir();
3290 let ctx = WaveCtx {
3291 carry_seats: false,
3292 run: &run_id,
3293 node: "vote",
3294 prompts: &prompts,
3295 cache: vote_cache.as_deref(),
3296 round: None,
3297 };
3298 let votes = ask_json_wave::<FinalVote>(
3299 vote_jobs,
3300 Arc::clone(&self.sem),
3301 vote_retries,
3302 &self.roles.judge_roster,
3303 &ctx,
3304 &mut vote_losses,
3305 &mut self.state,
3306 &move |v: &FinalVote| match v.label() {
3307 Some(c) if allowed.contains(&c) => Ok(()),
3308 other => bail!("vote {other:?} is not one of {allowed:?}"),
3309 },
3310 )
3311 .await;
3312 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3313 let agent_id = seat.agent.clone();
3314 self.state.seats.insert(seat.key.clone(), seat);
3315 match res {
3316 Ok((v, _)) => {
3317 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3318 rec.vote = v.label();
3319 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3320 } else {
3321 self.state.votes.push(VoteRecord {
3322 judge: j + 1,
3323 agent: agent_id,
3324 vote: v.label(),
3325 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3326 changed: false,
3327 });
3328 }
3329 self.state.event(
3330 "recover",
3331 format!("judge {} voted again after the limit", j + 1),
3332 );
3333 }
3334 Err(e) => {
3335 self.state
3336 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3337 }
3338 }
3339 }
3340
3341 let recovered_keys: BTreeSet<String> = recovered
3345 .iter()
3346 .map(|&j| format!("judge-{}", j + 1))
3347 .collect();
3348 self.state
3349 .quota
3350 .retain(|q| !recovered_keys.contains(&q.seat));
3351 for loss in judge_losses.into_iter().chain(vote_losses) {
3355 if recovered_keys.contains(&loss.seat) {
3356 continue;
3357 }
3358 self.state.quota.retain(|q| q.seat != loss.seat);
3359 self.state.quota.push(loss);
3360 }
3361
3362 self.state.tally = None;
3364 self.tally()?;
3365 Ok(self
3366 .state
3367 .tally
3368 .as_ref()
3369 .map(|t| t.met_quorum)
3370 .unwrap_or(false))
3371 }
3372
3373 async fn fold_losers(&mut self) -> Result<()> {
3376 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3377 return Ok(());
3378 };
3379 let repo = self.state.repo.clone();
3380 let mut folded = Vec::new();
3381 for i in 0..self.state.candidates.len() {
3382 let c = &self.state.candidates[i];
3383 if c.label == winner || c.folded {
3384 continue;
3385 }
3386 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3387 git::worktree_remove(&repo, &wt).await.ok();
3388 git::branch_delete(&repo, &branch).await.ok();
3389 self.state.candidates[i].folded = true;
3390 folded.push(label.to_string());
3391 }
3392 let root = self.state.worktree_root();
3394 for j in 1..=self.roles.judges.len() {
3395 let wt = root.join(format!("judge-{j}"));
3396 if wt.exists() {
3397 git::worktree_remove(&repo, &wt).await.ok();
3398 }
3399 }
3400 if self.state.config.graph.advise {
3403 for k in 1..=self.state.config.graph.advisors {
3404 let wt = root.join(format!("advisor-{k}"));
3405 if wt.exists() {
3406 git::worktree_remove(&repo, &wt).await.ok();
3407 }
3408 }
3409 }
3410 if !folded.is_empty() {
3411 self.state
3412 .event("fold", format!("folded candidates {}", folded.join(", ")));
3413 self.state.save()?;
3414 }
3415 Ok(())
3416 }
3417
3418 async fn sync_to_base(&mut self) -> Result<()> {
3458 if self.state.status == RunStatus::AlreadyInBase {
3459 return Ok(());
3460 }
3461 let conflicted = self
3462 .state
3463 .base_sync
3464 .as_ref()
3465 .is_some_and(|s| s.conflict.is_some());
3466 let Some(winner) = self.state.winner().cloned() else {
3467 return Ok(());
3468 };
3469
3470 let repo = self.state.repo.clone();
3471 let remote = self.state.config.merge.remote.clone();
3472 let base_branch = self.state.base_branch.clone();
3473 let tracking = format!("{remote}/{base_branch}");
3474
3475 git::fetch(&repo, &remote, &base_branch).await.ok();
3476 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3481 return Ok(());
3482 };
3483
3484 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3485 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3486 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3487
3488 if (behind > 0 || conflicted || head == tip)
3497 && self
3498 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3499 .await?
3500 {
3501 return Ok(());
3502 }
3503 if conflicted {
3504 return Ok(());
3505 }
3506
3507 if behind == 0 {
3508 if let Some(from) = self
3517 .state
3518 .rebase_fixes
3519 .iter()
3520 .rev()
3521 .find_map(|r| r.from.clone())
3522 && from != head
3523 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3524 .await
3525 .is_ok_and(|o| o.ok())
3526 {
3527 git::sync_to_head(&winner.worktree).await?;
3528 }
3529 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3535 if let Some(why) = &conflict {
3536 self.state.status = RunStatus::Blocked;
3537 self.state.event("land", why.clone());
3538 }
3539 self.state.base_sync = Some(BaseSync {
3540 tip,
3541 behind: 0,
3542 attempts,
3543 conflict,
3544 already_in: None,
3545 });
3546 self.state.save()?;
3547 return Ok(());
3548 }
3549
3550 if attempts >= BASE_SYNC_ROUNDS {
3551 let why = format!(
3552 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3553 rebase(s); rebasing again would only race it",
3554 winner.branch
3555 );
3556 self.state.status = RunStatus::Blocked;
3557 self.state.base_sync = Some(BaseSync {
3558 tip,
3559 behind,
3560 attempts,
3561 conflict: Some(why.clone()),
3562 already_in: None,
3563 });
3564 self.state.event("land", why);
3565 self.state.save()?;
3566 return Ok(());
3567 }
3568
3569 self.state.event(
3570 "land",
3571 format!(
3572 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3573 winner.branch
3574 ),
3575 );
3576 self.state.save()?;
3577
3578 let branch_tracking = format!("{remote}/{}", winner.branch);
3584 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3585 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3586 git::rev_parse(&repo, &branch_tracking).await.ok()
3587 } else {
3588 None
3589 };
3590 if let Some(theirs) = &remote_tip
3594 && !git::is_ancestor(&repo, theirs, &head).await
3595 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3596 .await
3597 .is_ok_and(|missing| missing.is_empty())
3598 {
3599 let why = format!(
3600 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3601 them",
3602 short(theirs),
3603 winner.branch
3604 );
3605 self.state.status = RunStatus::Blocked;
3606 self.state.base_sync = Some(BaseSync {
3607 tip,
3608 behind,
3609 attempts,
3610 conflict: Some(why.clone()),
3611 already_in: None,
3612 });
3613 self.state.event("land", why);
3614 self.state.save()?;
3615 return Ok(());
3616 }
3617
3618 let scratch = self.state.dir().join("base-sync");
3619 let rebased = match crate::rebase::rebase_with_fixer(
3620 &mut self.state,
3621 &scratch,
3622 &winner.branch,
3623 &tracking,
3624 )
3625 .await
3626 {
3627 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3628 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3629 Err(e) => Err(e),
3630 };
3631 let attempts = attempts + 1;
3632 match rebased {
3633 Ok(None) => {
3634 git::sync_to_head(&winner.worktree).await?;
3638 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3639 let mut conflict = None;
3640 if let Some(pinned) = &remote_tip {
3641 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3642 match pushed {
3643 Ok(o) if o.ok() => self.state.event(
3644 "land",
3645 format!("pushed rebased {} to {remote}", winner.branch),
3646 ),
3647 Ok(o) => {
3648 conflict = Some(format!(
3649 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
3650 winner.branch,
3651 short(pinned),
3652 o.stderr.chars().take(600).collect::<String>()
3653 ));
3654 }
3655 Err(e) => {
3656 conflict = Some(format!(
3657 "rebased {} locally but could not push it: {e:#}",
3658 winner.branch
3659 ));
3660 }
3661 }
3662 }
3663 if let Some(why) = &conflict {
3664 self.state.status = RunStatus::Blocked;
3665 self.state.event("land", why.clone());
3666 }
3667 self.state.base_sync = Some(BaseSync {
3668 tip: tip.clone(),
3669 behind: 0,
3670 attempts,
3671 conflict,
3672 already_in: None,
3673 });
3674 self.state
3675 .event("land", format!("rebased {} onto {tracking}", winner.branch));
3676 }
3677 Ok(Some(conflict)) => {
3678 let why = format!(
3679 "{} conflicts with {tracking} and did not rebase: {}",
3680 winner.branch,
3681 conflict.chars().take(600).collect::<String>()
3682 );
3683 self.state.status = RunStatus::Blocked;
3684 self.state.base_sync = Some(BaseSync {
3685 tip,
3686 behind,
3687 attempts,
3688 conflict: Some(why.clone()),
3689 already_in: None,
3690 });
3691 self.state.event("land", why);
3692 }
3693 Err(e) => {
3694 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3695 self.state.status = RunStatus::Blocked;
3696 self.state.base_sync = Some(BaseSync {
3697 tip,
3698 behind,
3699 attempts,
3700 conflict: Some(why.clone()),
3701 already_in: None,
3702 });
3703 self.state.event("land", why);
3704 }
3705 }
3706 self.state.save()?;
3707 Ok(())
3708 }
3709
3710 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3716 let repo = self.state.repo.clone();
3717 let remote = self.state.config.merge.remote.clone();
3718 let fetched = git::fetch(&repo, &remote, branch).await;
3719 if !matches!(&fetched, Ok(o) if o.ok()) {
3720 return None;
3721 }
3722 let branch_tracking = format!("{remote}/{branch}");
3723 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3724 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3725 return None;
3726 }
3727 if !crate::reconcile::origin_missing(&repo, head, &theirs)
3728 .await
3729 .is_ok_and(|missing| missing.is_empty())
3730 {
3731 return Some(format!(
3732 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3733 them",
3734 short(&theirs)
3735 ));
3736 }
3737 match git::push_pinned(&repo, &remote, branch, &theirs).await {
3738 Ok(o) if o.ok() => {
3739 self.state
3740 .event("land", format!("pushed rebased {branch} to {remote}"));
3741 None
3742 }
3743 Ok(o) => Some(format!(
3744 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3745 someone may have pushed): {}",
3746 short(&theirs),
3747 o.stderr.chars().take(600).collect::<String>()
3748 )),
3749 Err(e) => Some(format!(
3750 "{branch} is rebased locally but could not be pushed: {e:#}"
3751 )),
3752 }
3753 }
3754
3755 async fn settle_already_in(
3772 &mut self,
3773 branch: &str,
3774 tip: &str,
3775 head: &str,
3776 attempts: usize,
3777 behind: usize,
3778 ) -> Result<bool> {
3779 let repo = self.state.repo.clone();
3780 let remote = self.state.config.merge.remote.clone();
3781 let start = self.state.base_commit.clone();
3782 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3783 Ok(Some(e)) => e,
3784 Ok(None) => return Ok(false),
3785 Err(e) => {
3786 tracing::warn!("already-in-base check for {branch}: {e:#}");
3787 return Ok(false);
3788 }
3789 };
3790 let mut verified = vec![head.to_owned()];
3791 let fetched = git::fetch(&repo, &remote, branch).await;
3792 if matches!(&fetched, Ok(o) if o.ok())
3793 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3794 && theirs != head
3795 {
3796 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3797 Ok(Some(_)) => verified.push(theirs),
3798 _ => return Ok(false),
3799 }
3800 }
3801 let base_branch = self.state.base_branch.clone();
3802 let message = format!(
3803 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3804 land",
3805 evidence.names(),
3806 evidence.proof.as_str()
3807 );
3808 let closed =
3809 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3810 match closed {
3811 Ok(Ok(url)) => self
3812 .state
3813 .event("land", format!("closed {url}: superseded on {base_branch}")),
3814 Ok(Err(why)) => self
3815 .state
3816 .event("land", format!("did not close a pull request: {why}")),
3817 Err(e) => {
3818 let why = format!(
3819 "{branch} is already in {remote}/{base_branch}, but its pull request could \
3820 not be closed ({e:#}); resume to retry"
3821 );
3822 self.state.status = RunStatus::Blocked;
3823 self.state.base_sync = Some(BaseSync {
3824 tip: tip.to_owned(),
3825 behind,
3826 attempts,
3827 conflict: Some(why.clone()),
3828 already_in: None,
3829 });
3830 self.state.event("land", why);
3831 self.state.save()?;
3832 return Ok(true);
3833 }
3834 }
3835 self.state.status = RunStatus::AlreadyInBase;
3836 self.state.base_sync = Some(BaseSync {
3837 tip: tip.to_owned(),
3838 behind,
3839 attempts,
3840 conflict: None,
3841 already_in: Some(evidence),
3842 });
3843 self.state.event("land", message);
3844 self.state.save()?;
3845 self.settle_questions();
3846 Ok(true)
3847 }
3848
3849 fn landing_base(&self) -> String {
3859 self.state
3860 .base_sync
3861 .as_ref()
3862 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3863 }
3864
3865 pub async fn fix_selected(
3898 &mut self,
3899 ids: &[String],
3900 reason: &str,
3901 allow_stale: bool,
3902 ) -> Result<()> {
3903 let reason = reason.trim();
3904 if reason.is_empty() {
3905 bail!("a fix request needs a reason — that is the operator's own record of why");
3906 }
3907 if ids.is_empty() {
3908 bail!("no finding id given");
3909 }
3910 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3911 bail!(
3912 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3913 has already concluded — can be given a targeted fix. A run still \
3914 in progress should simply be resumed; a `merged` run's branch has \
3915 already landed, so its answer is a fresh `magi review <branch>`, \
3916 not reopening this run's own record",
3917 self.state.id,
3918 self.state.status.as_str()
3919 );
3920 }
3921 let Some(winner) = self.state.winner().cloned() else {
3922 bail!("run {} has no winning candidate to fix", self.state.id);
3923 };
3924 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3925 bail!(
3926 "branch `{}` no longer exists; this run cannot be extended",
3927 winner.branch
3928 );
3929 }
3930 let home = crate::run::home();
3931 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
3932 bail!(
3933 "run {} is currently being worked on by another magi process",
3934 self.state.id
3935 );
3936 }
3937 let _claim = FixClaim::acquire(&self.state.dir())?;
3943
3944 let mut seen = BTreeSet::new();
3948 let mut findings = Vec::new();
3949 let mut missing = Vec::new();
3950 for id in ids {
3951 if !seen.insert(id.clone()) {
3952 continue;
3953 }
3954 match self.state.finding(id) {
3955 Some((round, rec, f)) => findings.push(OperatorFixFinding {
3956 id: f.id.clone(),
3957 severity: f.severity,
3958 reviewer_vote: rec.vote,
3959 round: round.round,
3960 round_head: round.head.clone(),
3961 reviewer: rec.reviewer,
3962 agent: rec.agent.clone(),
3963 file: f.file.clone(),
3964 line: f.line,
3965 title: f.title.clone(),
3966 detail: f.detail.clone(),
3967 outcome: OperatorFixOutcome::Pending,
3968 }),
3969 None => missing.push(id.clone()),
3970 }
3971 }
3972 if !missing.is_empty() {
3973 bail!(
3974 "unknown finding id(s): {}; nothing was changed",
3975 missing.join(", ")
3976 );
3977 }
3978
3979 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
3980 let stale_details: Vec<(String, String)> = findings
3981 .iter()
3982 .filter(|f| f.round_head != head_at_request)
3983 .map(|f| (f.id.clone(), f.round_head.clone()))
3984 .collect();
3985 let stale = !stale_details.is_empty();
3986 if stale && !allow_stale {
3987 bail!(
3988 "the branch has moved since some finding(s) were raised — {} — now \
3989 at {}; pass --allow-stale to fix anyway, or re-run review first",
3990 stale_details
3991 .iter()
3992 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
3993 .collect::<Vec<_>>()
3994 .join(", "),
3995 short(&head_at_request)
3996 );
3997 }
3998
3999 let request = OperatorFixRequest {
4000 requested_at: Timestamp::now(),
4001 reason: reason.to_owned(),
4002 findings,
4003 head_at_request: head_at_request.clone(),
4004 allow_stale,
4005 stale,
4006 fix: None,
4007 result_head: None,
4008 follow_up_review_run: None,
4009 };
4010 self.state.event(
4011 "fix",
4012 format!(
4013 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4014 request.findings.len(),
4015 request
4016 .findings
4017 .iter()
4018 .map(|f| f.id.as_str())
4019 .collect::<Vec<_>>()
4020 .join(", "),
4021 ),
4022 );
4023 self.state.operator_fixes.push(request);
4030 self.state.save()?;
4031 let request_index = self.state.operator_fixes.len() - 1;
4032
4033 if winner.worktree.exists() {
4042 let dirty = git::git(
4045 &winner.worktree,
4046 &["status", "--porcelain", "--untracked-files=all"],
4047 )
4048 .await?;
4049 let only_withheld = dirty.lines().all(|l| {
4050 l.strip_prefix("?? ")
4051 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4052 });
4053 if !only_withheld {
4054 bail!(
4055 "`{}` has uncommitted changes; refusing to touch it — commit or \
4056 discard them first",
4057 winner.worktree.display()
4058 );
4059 }
4060 git::worktree_remove(&self.state.repo, &winner.worktree)
4061 .await
4062 .ok();
4063 }
4064 let fix_worktree = self.state.worktree_root().join("operator-fix");
4065 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4066 git::git(
4067 &self.state.repo,
4068 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4069 )
4070 .await
4071 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4072 if !git::is_clean(&fix_worktree).await? {
4073 git::worktree_remove(&self.state.repo, &fix_worktree)
4074 .await
4075 .ok();
4076 bail!(
4077 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4078 winner.branch
4079 );
4080 }
4081
4082 let run_id = self.state.id.clone();
4083 let prompts = self.state.config.prompts.clone();
4084 let language = self.state.config.graph.language.clone();
4085 let sessions = self.state.config.graph.sessions;
4086 let artifacts = agent::artifacts_dir(&self.state.dir());
4087 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
4088 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4089 _ => (
4090 self.state
4091 .config
4092 .agent(&winner.agent)
4093 .cloned()
4094 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4095 format!("impl-{}", winner.label),
4096 ),
4097 };
4098 let seat = self.seat(&fix_seat_key, &fix_spec.id);
4099 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4100 .findings
4101 .iter()
4102 .map(|f| Finding {
4103 id: f.id.clone(),
4104 severity: f.severity,
4105 file: f.file.clone(),
4106 line: f.line,
4107 title: f.title.clone(),
4108 detail: f.detail.clone(),
4109 })
4110 .collect();
4111 let job = SeatJob {
4112 prompt: prompt::operator_fix(
4113 &self.state.instruction,
4114 &finding_list,
4115 reason,
4116 &stale_details,
4117 &head_at_request,
4118 &language,
4119 ),
4120 spec: fix_spec.clone(),
4121 seat,
4122 cwd: fix_worktree.clone(),
4123 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4124 allow_write: true,
4125 sessions,
4126 artifacts: artifacts.clone(),
4127 stem: "operator-fix".to_owned(),
4128 handover: None,
4129 };
4130 let cache = self.state.config.cache_dir();
4131 let ctx = WaveCtx {
4132 carry_seats: false,
4133 run: &run_id,
4134 node: "fix",
4135 prompts: &prompts,
4136 cache: cache.as_deref(),
4137 round: None,
4138 };
4139 let (seat, out) =
4140 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4141 let agent_id = seat.agent.clone();
4142
4143 let mut fix = FixRecord {
4144 agent: agent_id,
4145 addressed: Vec::new(),
4146 rejected: Vec::new(),
4147 notes: String::new(),
4148 committed: false,
4149 failed: None,
4150 duration_ms: 0,
4151 continuation: None,
4152 };
4153 let mut final_seat = seat.clone();
4154 match out {
4155 AgentOutcome::Ok(o) => {
4156 fix.duration_ms = o.duration_ms;
4157 let parsed = verdict::extract_json::<FixReport>(&o.text);
4158 let incomplete_reason = match &parsed {
4159 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4160 "the reply parsed, but it reported a command whose own CLI \
4161 never confirmed an exit status"
4162 .to_owned(),
4163 ),
4164 Ok(_) => None,
4165 Err(e) => Some(e.to_string()),
4166 };
4167 match incomplete_reason {
4168 None => {
4169 let report = parsed.expect("checked Ok above");
4170 fix.addressed = report.addressed;
4171 fix.rejected = report.rejected;
4172 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4173 }
4174 Some(reason) => {
4175 let (resumed_seat, resolved, failure, cont) = self
4176 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4177 .await;
4178 fix.duration_ms += cont.cumulative_wait_ms;
4179 fix.continuation = Some(cont);
4180 final_seat = resumed_seat;
4181 match resolved {
4182 Some(report) => {
4183 fix.addressed = report.addressed;
4184 fix.rejected = report.rejected;
4185 fix.notes =
4186 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4187 }
4188 None => fix.failed = failure,
4189 }
4190 }
4191 }
4192 }
4193 AgentOutcome::Dropped(o) => {
4194 fix.duration_ms = o.duration_ms;
4195 let why = o
4196 .dropped
4197 .as_ref()
4198 .map(|d| d.why.as_str())
4199 .unwrap_or("the CLI ended the stream without delivering its answer");
4200 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4201 }
4202 AgentOutcome::Quota(o) => {
4203 self.state.quota.push(QuotaLoss {
4204 seat: final_seat.key.clone(),
4205 node: "fix".to_owned(),
4206 at: Timestamp::now(),
4207 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4208 });
4209 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4210 }
4211 AgentOutcome::Failed(e) => fix.failed = Some(e),
4212 }
4213 if fix.continuation.is_none() {
4214 fix.continuation = Some(ContinuationRecord::not_needed());
4215 }
4216 self.state.seats.insert(final_seat.key.clone(), final_seat);
4217
4218 let rescue_message = format!(
4219 "magi: operator-selected fix ({}) (uncommitted work)",
4220 self.state.operator_fixes[request_index]
4221 .findings
4222 .iter()
4223 .map(|f| f.id.as_str())
4224 .collect::<Vec<_>>()
4225 .join(", ")
4226 );
4227 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4228 self.state.note_withheld("fix", &r.withheld);
4229 }
4230 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4231 fix.committed = after != head_at_request;
4232 git::worktree_remove(&self.state.repo, &fix_worktree)
4233 .await
4234 .ok();
4235
4236 self.state.event(
4237 "fix",
4238 match &fix.failed {
4239 Some(reason) => format!(
4240 "operator fix: adoption report was lost ({reason}); {}",
4241 if fix.committed {
4242 "committed"
4243 } else {
4244 "NO new commit"
4245 }
4246 ),
4247 None => format!(
4248 "operator fix: {} addressed, {} rejected, {}",
4249 fix.addressed.len(),
4250 fix.rejected.len(),
4251 if fix.committed {
4252 "committed"
4253 } else {
4254 "NO new commit"
4255 }
4256 ),
4257 },
4258 );
4259
4260 for f in &mut self.state.operator_fixes[request_index].findings {
4267 f.outcome = if fix.failed.is_some() {
4268 OperatorFixOutcome::Unreported
4269 } else if fix.addressed.contains(&f.id) {
4270 OperatorFixOutcome::Addressed
4271 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4272 OperatorFixOutcome::Rejected { why: r.why.clone() }
4273 } else {
4274 OperatorFixOutcome::Unreported
4275 };
4276 }
4277
4278 let committed = fix.committed;
4279 if committed {
4280 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4281 }
4282 self.state.operator_fixes[request_index].fix = Some(fix);
4283 self.state.save()?;
4286
4287 if committed {
4288 self.state.event(
4289 "fix",
4290 format!(
4291 "operator fix committed {}; opening a follow-up review-only run",
4292 short(&after)
4293 ),
4294 );
4295 let origin =
4298 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4299 match Self::review(
4300 &self.state.repo,
4301 &winner.branch,
4302 self.state.config.clone(),
4303 origin,
4304 )
4305 .await
4306 {
4307 Ok(mut follow_up) => {
4308 follow_up.state.event(
4309 "start",
4310 format!(
4311 "requested by an operator fix on run {} for finding(s) {}",
4312 self.state.id,
4313 self.state.operator_fixes[request_index]
4314 .findings
4315 .iter()
4316 .map(|f| f.id.as_str())
4317 .collect::<Vec<_>>()
4318 .join(", "),
4319 ),
4320 );
4321 follow_up.state.save()?;
4322 let follow_up_id = follow_up.state.id.clone();
4323 let adopted = match crate::direct::adopt(
4326 &follow_up.state,
4327 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4328 ) {
4329 Ok(a) => a,
4330 Err(e) => {
4331 self.state.event(
4334 "fix",
4335 format!(
4336 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4337 ),
4338 );
4339 self.state.save()?;
4340 return Ok(());
4341 }
4342 };
4343 let executed = follow_up.execute().await;
4344 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4345 if let Some(a) = adopted {
4346 a.finish(&follow_up.state, executed);
4347 }
4348 if let Some(e) = failure {
4349 self.state.event(
4350 "fix",
4351 format!(
4352 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4353 ),
4354 );
4355 }
4356 self.state.operator_fixes[request_index].follow_up_review_run =
4357 Some(follow_up_id);
4358 }
4359 Err(e) => {
4360 self.state.event(
4361 "fix",
4362 format!("committed the fix but could not open a follow-up review: {e:#}"),
4363 );
4364 }
4365 }
4366 self.state.save()?;
4367 }
4368
4369 Ok(())
4370 }
4371
4372 fn fixer_spec(&self, winner: &Candidate) -> (AgentSpec, String) {
4379 match &self.roles.fixer {
4380 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4381 _ => (
4382 self.state
4383 .config
4384 .agent(&winner.agent)
4385 .cloned()
4386 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4387 format!("impl-{}", winner.label),
4388 ),
4389 }
4390 }
4391
4392 async fn review_loop(&mut self) -> Result<()> {
4393 if self
4398 .state
4399 .base_sync
4400 .as_ref()
4401 .is_some_and(|s| s.conflict.is_some())
4402 {
4403 return Ok(());
4404 }
4405 let run_id = self.state.id.clone();
4410 let prompts = self.state.config.prompts.clone();
4411 let Some(winner) = self.state.winner().cloned() else {
4412 return Ok(());
4413 };
4414 let max_rounds = self.state.config.graph.review_rounds;
4415 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4425 if status == RunStatus::Gating {
4428 self.record_contested_handoff();
4429 }
4430 self.state.status = status;
4431 self.state.save()?;
4432 return Ok(());
4433 }
4434 self.state.status = RunStatus::Reviewing;
4435 if self
4445 .state
4446 .reviews
4447 .last()
4448 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4449 {
4450 let shell = self.state.config.shell();
4451 return self
4452 .stop_reviewing(
4453 "the last round's own verification never resolved",
4454 &shell,
4455 &winner.worktree,
4456 )
4457 .await;
4458 }
4459
4460 let repo = self.state.repo.clone();
4461 let root = self.state.worktree_root();
4462 let language = self.state.config.graph.language.clone();
4463 let sessions = self.state.config.graph.sessions;
4464 let artifacts = agent::artifacts_dir(&self.state.dir());
4465 let base = self.landing_base();
4466 let base_short = short(&base);
4467 let reviewers = self.roles.reviewers.clone();
4468 let shell = self.state.config.shell();
4469
4470 for round in (self.state.reviews.len() + 1)..=max_rounds {
4471 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4472 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4473 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4474 let prev_verification = self
4483 .state
4484 .reviews
4485 .last()
4486 .and_then(|r| r.verification_summary(&head));
4487
4488 let mut jobs = Vec::new();
4492 for (r, spec) in reviewers.iter().cloned().enumerate() {
4493 let wt = root.join(format!("review-{}", r + 1));
4494 if wt.exists() {
4495 git::reset_detached(&wt, &head).await?;
4496 } else {
4497 git::worktree_add_detached(&repo, &wt, &head).await?;
4498 }
4499 let seat_key = format!("review-{}", r + 1);
4500 let spec = pick_start_spec(
4503 &self.roles.reviewer_roster,
4504 spec,
4505 self.state.seat_history.get(&seat_key),
4506 );
4507 let seat = self.seat(&seat_key, &spec.id);
4508 jobs.push(SeatJob {
4509 prompt: prompt::review(&prompt::ReviewCtx {
4510 instruction: &self.state.instruction,
4511 branch: &winner.branch,
4512 base_short: &base_short,
4513 stat: &stat,
4514 patch: &patch,
4515 verification: prev_verification.as_ref(),
4516 reviewers: reviewers.len(),
4517 round,
4518 rounds: max_rounds,
4519 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4522 lens: Lens::for_seat(r),
4523 language: &language,
4524 }),
4525 spec,
4526 seat,
4527 cwd: wt,
4528 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4529 allow_write: false,
4530 sessions,
4531 artifacts: artifacts.clone(),
4532 stem: format!("review-{round}-{}", r + 1),
4533 handover: None,
4534 });
4535 }
4536
4537 self.state.event(
4538 "review",
4539 format!(
4540 "round {round}: {} reviewers on {}",
4541 jobs.len(),
4542 short(&head)
4543 ),
4544 );
4545 let mut quota_losses = Vec::new();
4546 let review_retries = self.state.config.graph.retries;
4547 let review_cache = self.state.config.cache_dir();
4548 let ctx = WaveCtx {
4549 carry_seats: true,
4550 run: &run_id,
4551 node: "review",
4552 prompts: &prompts,
4553 cache: review_cache.as_deref(),
4554 round: Some(round),
4555 };
4556 let results = ask_json_wave::<Review>(
4557 jobs,
4558 Arc::clone(&self.sem),
4559 review_retries,
4560 &self.roles.reviewer_roster,
4561 &ctx,
4562 &mut quota_losses,
4563 &mut self.state,
4564 &|_: &Review| Ok(()),
4565 )
4566 .await;
4567 let round_quota_missing = quota_losses.len();
4571 self.state.quota.extend(quota_losses);
4572
4573 let mut records = Vec::new();
4574 let mut all_findings = Vec::new();
4575 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4576 let agent_id = seat.agent.clone();
4577 self.state.seats.insert(seat.key.clone(), seat);
4578 let mut record = ReviewRecord {
4579 reviewer: r + 1,
4580 agent: agent_id,
4581 summary: String::new(),
4582 findings: Vec::new(),
4583 vote: None,
4584 failed: None,
4585 duration_ms: 0,
4586 attempts,
4592 };
4593 match res {
4594 Ok((review, out)) => {
4595 record.summary =
4603 blind::sanitize_prose(&review.summary, &self.state.config.blind);
4604 record.vote = Some(review.vote);
4605 record.duration_ms = out.duration_ms;
4606 for (n, mut f) in review.findings.into_iter().enumerate() {
4607 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4610 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4611 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4612 f.file = f
4618 .file
4619 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4620 all_findings.push(f.clone());
4621 record.findings.push(f);
4622 }
4623 self.state.event(
4624 "review",
4625 format!(
4626 "round {round}: reviewer {} voted {} with {} finding(s)",
4627 r + 1,
4628 review.vote.label(),
4629 record.findings.len()
4630 ),
4631 );
4632 }
4633 Err(e) => {
4634 record.failed = Some(e.to_string());
4635 self.state.event(
4636 "review",
4637 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4638 );
4639 }
4640 }
4641 records.push(record);
4642 }
4643
4644 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4651 let vote_split =
4652 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4653 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4654 if vote_split {
4655 self.state.event(
4656 "review",
4657 format!(
4658 "round {round}: votes split ({}) — one round of reconsideration",
4659 initial_votes
4660 .iter()
4661 .map(|v| v.label())
4662 .collect::<Vec<_>>()
4663 .join(", ")
4664 ),
4665 );
4666 let panel: Vec<ReviewSeatReport<'_>> = records
4669 .iter()
4670 .filter_map(|r| {
4671 r.vote.map(|vote| ReviewSeatReport {
4672 reviewer: r.reviewer,
4673 vote,
4674 summary: &r.summary,
4675 findings: &r.findings,
4676 })
4677 })
4678 .collect();
4679
4680 let mut jobs = Vec::new();
4681 let mut seats_at = Vec::new();
4682 for (r, spec) in reviewers.iter().cloned().enumerate() {
4683 if records[r].vote.is_none() {
4687 continue;
4688 }
4689 let wt = root.join(format!("review-{}", r + 1));
4690 let seat_key = format!("review-{}", r + 1);
4691 let spec = self.occupant(&seat_key, spec);
4692 let seat = self.seat(&seat_key, &spec.id);
4693 let build = |with_patch: bool| {
4701 prompt::review_reconsider(&ReviewReconsiderCtx {
4702 instruction: &self.state.instruction,
4703 reviewer: r + 1,
4704 lens: Lens::for_seat(r),
4705 panel: &panel,
4706 patch: with_patch.then_some(ReviewPatch {
4707 branch: &winner.branch,
4708 base_short: &base_short,
4709 stat: &stat,
4710 patch: &patch,
4711 }),
4712 round,
4713 rounds: max_rounds,
4714 language: &language,
4715 })
4716 };
4717 let full = build(true);
4718 let prompt = if has_context(&spec, &seat, sessions) {
4719 build(false)
4720 } else {
4721 full.clone()
4722 };
4723 jobs.push(SeatJob {
4724 prompt,
4725 spec,
4726 seat,
4727 cwd: wt,
4728 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4729 allow_write: false,
4730 sessions,
4731 artifacts: artifacts.clone(),
4732 stem: format!("review-{round}-reconsider-{}", r + 1),
4733 handover: Some(full),
4734 });
4735 seats_at.push(r);
4736 }
4737
4738 let mut recon_quota_losses = Vec::new();
4739 let recon_cache = self.state.config.cache_dir();
4740 let recon_ctx = WaveCtx {
4741 carry_seats: true,
4742 run: &run_id,
4743 node: "review",
4744 prompts: &prompts,
4745 cache: recon_cache.as_deref(),
4746 round: Some(round),
4747 };
4748 let recon_results = ask_json_wave::<ReviewRevote>(
4749 jobs,
4750 Arc::clone(&self.sem),
4751 review_retries,
4752 &self.roles.reviewer_roster,
4753 &recon_ctx,
4754 &mut recon_quota_losses,
4755 &mut self.state,
4756 &|_: &ReviewRevote| Ok(()),
4757 )
4758 .await;
4759 self.state.quota.extend(recon_quota_losses);
4760
4761 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4762 let agent_id = seat.agent.clone();
4763 self.state.seats.insert(seat.key.clone(), seat);
4764 let mut rec = ReviewRevoteRecord {
4765 reviewer: r + 1,
4766 agent: agent_id,
4767 vote: None,
4768 reason: String::new(),
4769 failed: None,
4770 };
4771 match res {
4772 Ok((rv, _)) => {
4773 rec.vote = Some(rv.vote);
4774 rec.reason =
4775 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4776 self.state.event(
4777 "review",
4778 format!(
4779 "round {round}: reviewer {} revoted {}",
4780 r + 1,
4781 rv.vote.label()
4782 ),
4783 );
4784 }
4785 Err(e) => {
4786 rec.failed = Some(e.to_string());
4787 self.state.event(
4788 "review",
4789 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4790 );
4791 }
4792 }
4793 reconsideration.push(rec);
4794 }
4795 } else if initial_votes.len() > 1 {
4796 self.state.event(
4797 "review",
4798 format!(
4799 "round {round}: votes agreed ({}) — no reconsideration",
4800 initial_votes[0].label()
4801 ),
4802 );
4803 }
4804
4805 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4806 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4807 let defer_e2e =
4818 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4819 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4820 let reason =
4821 format!("{blocking} blocking finding(s) already required a fix this round");
4822 self.state.event(
4823 "verify",
4824 format!(
4825 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4826 {}); it will run once a round has none left",
4827 short(&head)
4828 ),
4829 );
4830 (Vec::new(), false, true, Some(reason))
4831 } else {
4832 let e2e_commands = self.state.config.verify.e2e.clone();
4833 let cache_dir = self.state.config.cache_dir();
4834 let context = format!("round {round}");
4835 let (e2e, verify_retried) = with_cache_lease(
4836 &mut self.state,
4837 cache_dir.as_deref(),
4838 "e2e",
4839 "e2e",
4840 &winner.worktree,
4841 &head,
4842 verify_timeout,
4843 &context,
4844 |state, budget| {
4845 let shell = shell.clone();
4846 let e2e_commands = e2e_commands.clone();
4847 let worktree = winner.worktree.clone();
4848 let context = context.clone();
4849 async move {
4850 run_e2e_with_retry(
4851 state,
4852 &shell,
4853 &e2e_commands,
4854 &worktree,
4855 budget,
4856 &context,
4857 )
4858 .await
4859 }
4860 },
4861 )
4862 .await;
4863 (e2e, verify_retried, false, None)
4864 };
4865
4866 let expected = records.len();
4867 let answered = records.iter().filter(|r| r.failed.is_none()).count();
4868 let incomplete = answered < expected;
4869 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
4870 let policy = self.state.config.graph.incomplete_review;
4871 let clean = round_is_clean(
4872 blocking,
4873 e2e_ok,
4874 answered,
4875 expected,
4876 round_quota_missing,
4877 policy,
4878 );
4879
4880 let mut round_record = ReviewRound {
4881 round,
4882 head: head.clone(),
4883 verified_head: None,
4884 verified_at: None,
4885 reviews: records,
4886 e2e,
4887 verify_retried,
4888 e2e_deferred,
4889 e2e_defer_reason,
4890 fix: None,
4891 blocking,
4892 answered,
4893 expected,
4894 clean,
4895 progressed: false,
4896 vote_split,
4897 reconsideration,
4898 verdict: None,
4899 };
4900 round_record.verdict = ReviewVote::worst(
4904 round_record
4905 .final_votes()
4906 .into_iter()
4907 .map(|(_, _, vote)| vote),
4908 );
4909 if !matches!(
4920 round_record.e2e_status(),
4921 E2eStatus::Deferred | E2eStatus::NotConfigured
4922 ) {
4923 round_record.verified_head = Some(head.clone());
4924 round_record.verified_at = Some(Timestamp::now());
4925 }
4926 let this_round_verification = round_record.verification_summary(&head);
4927
4928 if incomplete {
4929 let missing: Vec<String> = round_record
4930 .reviews
4931 .iter()
4932 .filter(|r| r.failed.is_some())
4933 .map(|r| format!("review-{}", r.reviewer))
4934 .collect();
4935 self.state.event(
4936 "review",
4937 format!(
4938 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
4939 missing.join(", ")
4940 ),
4941 );
4942 }
4943
4944 if clean {
4945 self.state.event(
4946 "review",
4947 if incomplete && policy == IncompleteReviewPolicy::Warn {
4948 format!(
4949 "round {round}: clean (warn policy, incomplete panel) — no \
4950 blocking findings from the seats that answered, verification green"
4951 )
4952 } else if incomplete {
4953 format!(
4954 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
4955 quorum) — no blocking findings from the seats that answered, \
4956 verification green",
4957 expected - answered
4958 )
4959 } else {
4960 format!("round {round}: clean — no blocking findings, verification green")
4961 },
4962 );
4963 self.state.reviews.push(round_record);
4964 self.state.status = RunStatus::Gating;
4965 self.state.save()?;
4966 return Ok(());
4967 }
4968
4969 if incomplete && blocking == 0 && e2e_ok {
4977 self.state.reviews.push(round_record);
4978 self.state.save()?;
4979 if round == max_rounds {
4980 self.state.status = RunStatus::Blocked;
4981 self.state.event(
4982 "review",
4983 format!(
4984 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
4985 refusing to call it clean",
4986 expected - answered
4987 ),
4988 );
4989 return Ok(());
4990 }
4991 continue;
4992 }
4993
4994 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5006 self.state.reviews.push(round_record);
5007 return self
5008 .stop_reviewing(
5009 "the round's own verification could not run",
5010 &shell,
5011 &winner.worktree,
5012 )
5013 .await;
5014 }
5015
5016 if round == max_rounds {
5017 self.state.reviews.push(round_record);
5018 return self
5019 .stop_reviewing(
5020 &format!(
5021 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5022 ),
5023 &shell,
5024 &winner.worktree,
5025 )
5026 .await;
5027 }
5028
5029 let (fix_spec, fix_seat_key) = self.fixer_spec(&winner);
5032 let seat = self.seat(&fix_seat_key, &fix_spec.id);
5033 let blocking_findings: Vec<_> = all_findings
5034 .iter()
5035 .filter(|f| f.severity.blocks())
5036 .cloned()
5037 .collect();
5038 let job = SeatJob {
5039 prompt: prompt::fix(
5040 &self.state.instruction,
5041 &blocking_findings,
5042 this_round_verification.as_ref(),
5043 round,
5044 max_rounds,
5045 &language,
5046 ),
5047 spec: fix_spec.clone(),
5048 seat,
5049 cwd: winner.worktree.clone(),
5050 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5051 allow_write: true,
5052 sessions,
5053 artifacts: artifacts.clone(),
5054 stem: format!("fix-{round}"),
5055 handover: None,
5056 };
5057 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5058 let cache = self.state.config.cache_dir();
5059 let ctx = WaveCtx {
5060 carry_seats: false,
5061 run: &run_id,
5062 node: "fix",
5063 prompts: &prompts,
5064 cache: cache.as_deref(),
5065 round: Some(round),
5066 };
5067 let (seat, out) =
5068 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5069 let agent_id = seat.agent.clone();
5070
5071 let mut fix = FixRecord {
5072 agent: agent_id,
5073 addressed: Vec::new(),
5074 rejected: Vec::new(),
5075 notes: String::new(),
5076 committed: false,
5077 failed: None,
5078 duration_ms: 0,
5079 continuation: None,
5080 };
5081 let mut continuation = ContinuationRecord::not_needed();
5082 let mut final_seat = seat.clone();
5083 match out {
5084 AgentOutcome::Ok(o) => {
5085 fix.duration_ms = o.duration_ms;
5086 let parsed = verdict::extract_json::<FixReport>(&o.text);
5087 let incomplete_reason = match &parsed {
5094 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5095 "the reply parsed, but it reported a command whose own CLI never \
5096 confirmed an exit status"
5097 .to_owned(),
5098 ),
5099 Ok(_) => None,
5100 Err(e) => Some(e.to_string()),
5101 };
5102 match incomplete_reason {
5103 None => {
5104 let report = parsed.expect("checked Ok above");
5105 fix.addressed = report.addressed;
5106 fix.rejected = report.rejected;
5107 fix.notes =
5108 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5109 }
5110 Some(reason) => {
5111 let (resumed_seat, resolved, failure, cont) = self
5112 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5113 .await;
5114 fix.duration_ms += cont.cumulative_wait_ms;
5115 continuation = cont;
5116 final_seat = resumed_seat;
5117 match resolved {
5118 Some(report) => {
5119 fix.addressed = report.addressed;
5120 fix.rejected = report.rejected;
5121 fix.notes = blind::sanitize_prose(
5122 &report.notes,
5123 &self.state.config.blind,
5124 );
5125 }
5126 None => fix.failed = failure,
5127 }
5128 }
5129 }
5130 }
5131 AgentOutcome::Dropped(o) => {
5133 fix.duration_ms = o.duration_ms;
5134 let why = o
5135 .dropped
5136 .as_ref()
5137 .map(|d| d.why.as_str())
5138 .unwrap_or("the CLI ended the stream without delivering its answer");
5139 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5140 }
5141 AgentOutcome::Quota(o) => {
5142 self.state.quota.push(QuotaLoss {
5143 seat: final_seat.key.clone(),
5144 node: "fix".to_owned(),
5145 at: Timestamp::now(),
5146 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5147 });
5148 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5149 }
5150 AgentOutcome::Failed(e) => fix.failed = Some(e),
5151 }
5152 fix.continuation = Some(continuation);
5153 self.state.seats.insert(final_seat.key.clone(), final_seat);
5154 if let Ok(r) = git::rescue_commit(
5155 &winner.worktree,
5156 &format!("magi: review round {round} fixes (uncommitted work)"),
5157 )
5158 .await
5159 {
5160 self.state.note_withheld("fix", &r.withheld);
5161 }
5162 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5163 fix.committed = after != before;
5164 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5172 let progressed = diff_after != patch;
5173 let commit_note = if fix.committed {
5174 "committed"
5175 } else {
5176 "NO new commit"
5177 };
5178 let tree_note = if progressed {
5179 "changed vs base"
5180 } else {
5181 "unchanged vs base"
5182 };
5183 self.state.event(
5184 "fix",
5185 match &fix.failed {
5186 Some(reason) => {
5192 format!(
5193 "round {round}: fixer's adoption report was lost ({reason}); \
5194 {commit_note}, tree {tree_note}"
5195 )
5196 }
5197 None => format!(
5198 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5199 {tree_note}{}",
5200 fix.addressed.len(),
5201 fix.rejected.len(),
5202 if continuation.outcome == ContinuationOutcome::Resumed {
5203 format!(
5204 " (adoption report recovered after {} continuation(s))",
5205 continuation.attempts
5206 )
5207 } else {
5208 String::new()
5209 },
5210 ),
5211 },
5212 );
5213 round_record.fix = Some(fix);
5214 round_record.progressed = progressed;
5215 self.state.reviews.push(round_record);
5216 self.state.save()?;
5217
5218 if matches!(
5231 continuation.outcome,
5232 ContinuationOutcome::Exhausted
5233 | ContinuationOutcome::QuotaLost
5234 | ContinuationOutcome::NoSession
5235 ) {
5236 return self
5237 .stop_reviewing(
5238 "the fixer's adoption report never came back, even after resuming its \
5239 own seat; refusing to start another round against the same worktree \
5240 while that is unresolved",
5241 &shell,
5242 &winner.worktree,
5243 )
5244 .await;
5245 }
5246
5247 let streak = self
5248 .state
5249 .reviews
5250 .iter()
5251 .rev()
5252 .take_while(|r| !r.progressed)
5253 .count();
5254 if streak >= STAGNANT_LIMIT {
5255 return self
5256 .stop_reviewing(
5257 &format!(
5258 "the tree has not moved against base for {streak} round(s) in a row"
5259 ),
5260 &shell,
5261 &winner.worktree,
5262 )
5263 .await;
5264 }
5265 }
5266 Ok(())
5267 }
5268
5269 fn record_contested_handoff(&mut self) {
5303 if self.state.contested_handoff.is_some() {
5304 return;
5305 }
5306 let Some(contested) = self
5307 .state
5308 .reviews
5309 .last()
5310 .and_then(ReviewRound::contested_handoff)
5311 else {
5312 return;
5313 };
5314 self.state.event(
5315 "review",
5316 format!(
5317 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5318 wait for the owner's approval",
5319 contested.findings.len(),
5320 contested.rejecters.len()
5321 ),
5322 );
5323 self.state.contested_handoff = Some(contested);
5324 }
5325
5326 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5327 let round_idx = self.state.reviews.len() - 1;
5328 let needs_catchup_run = matches!(
5336 self.state.reviews[round_idx].e2e_status(),
5337 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5338 );
5339 if needs_catchup_run {
5340 let round = self.state.reviews[round_idx].round;
5341 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5342 let commands = self.state.config.verify.e2e.clone();
5343 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5344 let cache_dir = self.state.config.cache_dir();
5345 let context = format!(
5346 "round {round}: verification unresolved, catching up before the final decision"
5347 );
5348 let (outcomes, verify_retried) = with_cache_lease(
5349 &mut self.state,
5350 cache_dir.as_deref(),
5351 "e2e",
5352 "e2e",
5353 worktree,
5354 &attempted_head,
5355 timeout,
5356 &context,
5357 |state, budget| {
5358 let shell = shell.to_vec();
5359 let commands = commands.clone();
5360 let context = context.clone();
5361 async move {
5362 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5363 .await
5364 }
5365 },
5366 )
5367 .await;
5368 let last = &mut self.state.reviews[round_idx];
5369 last.e2e = outcomes;
5370 last.verify_retried = verify_retried;
5371 last.verified_head = Some(attempted_head);
5378 last.verified_at = Some(Timestamp::now());
5379 if verify_inconclusive(&last.e2e) {
5380 self.state.save()?;
5387 return Ok(());
5388 }
5389 last.e2e_deferred = false;
5390 }
5391 let last = &self.state.reviews[round_idx];
5392 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5393
5394 match last.e2e_status() {
5395 E2eStatus::Failed => {
5396 let red: Vec<String> = last
5397 .e2e
5398 .iter()
5399 .filter(|o| !o.ok())
5400 .map(|o| {
5401 format!(
5402 "`{}` -> {:?}\n{}",
5403 o.command,
5404 o.code,
5405 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5406 )
5407 })
5408 .collect();
5409 self.state
5410 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5411 self.state.status = RunStatus::Blocked;
5412 }
5413 E2eStatus::ResourceBlocked => {
5418 self.state.event(
5419 "review",
5420 format!(
5421 "{why}; e2e could not run (shared build cache unavailable); not \
5422 deciding yet"
5423 ),
5424 );
5425 }
5426 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5427 self.state.event(
5428 "review",
5429 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5430 );
5431 self.record_contested_handoff();
5432 self.state.status = RunStatus::Gating;
5433 }
5434 }
5435 self.state.save()?;
5436 Ok(())
5437 }
5438
5439 async fn gate(&mut self) -> Result<()> {
5442 if self.state.status == RunStatus::Failed
5454 || self
5455 .state
5456 .base_sync
5457 .as_ref()
5458 .is_some_and(|s| s.conflict.is_some())
5459 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5460 != Some(RunStatus::Gating)
5461 {
5462 return Ok(());
5463 }
5464 if self.state.gate_ran {
5465 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5476 self.state.status = RunStatus::Blocked;
5477 self.state.save()?;
5478 }
5479 return Ok(());
5480 }
5481 let Some(winner) = self.state.winner().cloned() else {
5482 return Ok(());
5483 };
5484 self.state.status = RunStatus::Gating;
5485 let mut outcomes = self.run_gate(&winner).await?;
5486 loop {
5487 if verify_inconclusive(&outcomes) {
5498 self.state.save()?;
5499 return Ok(());
5500 }
5501 if outcomes.iter().all(CommandOutcome::ok) {
5502 break;
5503 }
5504 match self.gate_fix_round(&winner, &outcomes).await? {
5505 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5506 GateFix::Stop => break,
5507 GateFix::Defer => {
5508 self.state.save()?;
5509 return Ok(());
5510 }
5511 }
5512 }
5513 let passed = outcomes.iter().all(CommandOutcome::ok);
5514 self.state.gate = outcomes;
5515 self.state.gate_ran = true;
5516 if !passed {
5517 self.state.status = RunStatus::Blocked;
5518 let spent = self.state.gate_fixes.len();
5519 self.state.event(
5520 "gate",
5521 if spent == 0 {
5522 "gate failed; not merging".to_owned()
5523 } else {
5524 format!("gate failed after {spent} gate-fix round(s); not merging")
5525 },
5526 );
5527 }
5528 self.state.save()?;
5529 Ok(())
5530 }
5531
5532 async fn run_pre_gate(&mut self, winner: &Candidate) {
5542 let commands = self.state.config.verify.pre_gate.clone();
5543 if commands.is_empty() {
5544 return;
5545 }
5546 let shell = self.state.config.shell();
5547 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5548 let (outcomes, _) = run_commands(
5549 &mut self.state,
5550 "pre_gate",
5551 "pre_gate",
5552 0,
5553 &shell,
5554 &commands,
5555 &winner.worktree,
5556 timeout,
5557 )
5558 .await;
5559 for o in &outcomes {
5560 if !o.ok() {
5561 tracing::warn!(
5562 "pre_gate `{}` failed ({:?}); the gate decides",
5563 o.command,
5564 o.code
5565 );
5566 }
5567 self.state.event(
5568 "pre_gate",
5569 format!(
5570 "`{}` -> {}",
5571 o.command,
5572 if o.ok() {
5573 "pass".to_owned()
5574 } else {
5575 format!(
5576 "FAIL ({:?})\n{}",
5577 o.code,
5578 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5579 )
5580 }
5581 ),
5582 );
5583 }
5584 self.state.pre_gate = outcomes;
5585 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5586 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5587 Ok(head) => {
5588 self.state
5589 .event("pre_gate", format!("committed mechanical fixes ({head})"));
5590 self.state.pre_gate_commit = Some(head);
5591 }
5592 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5593 },
5594 Ok(false) => {}
5595 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5596 }
5597 if let Err(e) = self.state.save() {
5598 tracing::warn!("could not persist the pre_gate record: {e:#}");
5599 }
5600 }
5601
5602 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5605 self.run_pre_gate(winner).await;
5606 let shell = self.state.config.shell();
5607 let gate_commands = self.state.config.verify.gate.clone();
5608 let outcomes = if gate_commands.is_empty() {
5617 Vec::new()
5618 } else {
5619 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5620 let cache_dir = self.state.config.cache_dir();
5621 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5622 let (outcomes, _) = with_cache_lease(
5623 &mut self.state,
5624 cache_dir.as_deref(),
5625 "gate",
5626 "gate",
5627 &winner.worktree,
5628 &head,
5629 timeout,
5630 "final gate",
5631 |state, budget| {
5632 let shell = shell.clone();
5633 let gate_commands = gate_commands.clone();
5634 let worktree = winner.worktree.clone();
5635 async move {
5636 let (outcomes, timed_out_pids) = run_commands(
5637 state,
5638 "gate",
5639 "gate",
5640 0,
5641 &shell,
5642 &gate_commands,
5643 &worktree,
5644 budget,
5645 )
5646 .await;
5647 (outcomes, false, timed_out_pids)
5648 }
5649 },
5650 )
5651 .await;
5652 outcomes
5653 };
5654 if outcomes.is_empty() {
5655 self.state.event(
5660 "gate",
5661 "no gate commands configured; nothing to check, passing",
5662 );
5663 }
5664 for o in &outcomes {
5665 self.state.event(
5666 "gate",
5667 format!(
5668 "`{}` -> {}",
5669 o.command,
5670 if o.ok() {
5671 "pass".to_owned()
5672 } else {
5673 format!(
5674 "FAIL ({:?})\n{}",
5675 o.code,
5676 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5677 )
5678 }
5679 ),
5680 );
5681 }
5682 Ok(outcomes)
5683 }
5684
5685 async fn gate_fix_round(
5698 &mut self,
5699 winner: &Candidate,
5700 outcomes: &[CommandOutcome],
5701 ) -> Result<GateFix> {
5702 let cap = self.state.config.graph.gate_fix_rounds;
5703 let spent = self.state.gate_fixes.len();
5704 if spent >= cap {
5705 if cap > 0 {
5706 self.state.event(
5707 "gate",
5708 format!("{spent} gate-fix round(s) spent and the gate still fails"),
5709 );
5710 }
5711 return Ok(GateFix::Stop);
5712 }
5713 if !gate_fixable(outcomes) {
5714 self.state.event(
5715 "gate",
5716 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5717 command or similar); not spending a fix round on it",
5718 );
5719 return Ok(GateFix::Stop);
5720 }
5721 let min_free = self.state.config.disk.min_free_bytes;
5722 if min_free > 0 {
5723 match crate::disk::free_bytes(&winner.worktree) {
5724 Ok(free) if crate::disk::enough_space(free, min_free) => {}
5725 Ok(free) => {
5726 self.state.event(
5727 "gate",
5728 format!(
5729 "only {free} bytes free ({min_free} required by `[disk] \
5730 min_free_bytes`); not spending a fix round on a failure the disk \
5731 may explain"
5732 ),
5733 );
5734 return Ok(GateFix::Stop);
5735 }
5736 Err(e) => {
5737 self.state.event(
5738 "gate",
5739 format!("free disk space could not be measured ({e:#}); no fix round"),
5740 );
5741 return Ok(GateFix::Stop);
5742 }
5743 }
5744 }
5745
5746 let attempt = spent + 1;
5747 let run_id = self.state.id.clone();
5748 let prompts = self.state.config.prompts.clone();
5749 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5750 let base = self.landing_base();
5751 let (fix_spec, fix_seat_key) = self.fixer_spec(winner);
5752 let seat = self.seat(&fix_seat_key, &fix_spec.id);
5753 let job = SeatJob {
5754 prompt: prompt::gate_fix(
5755 &self.state.instruction,
5756 &failed,
5757 attempt,
5758 cap,
5759 &self.state.config.graph.language,
5760 ),
5761 spec: fix_spec,
5762 seat,
5763 cwd: winner.worktree.clone(),
5764 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5765 allow_write: true,
5766 sessions: self.state.config.graph.sessions,
5767 artifacts: agent::artifacts_dir(&self.state.dir()),
5768 stem: format!("gate-fix-{attempt}"),
5769 handover: None,
5770 };
5771 self.state.event(
5772 "gate",
5773 format!("gate failed; gate-fix round {attempt} of {cap}"),
5774 );
5775 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5776 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5777 let cache = self.state.config.cache_dir();
5778 let ctx = WaveCtx {
5779 carry_seats: false,
5780 run: &run_id,
5781 node: "gate-fix",
5782 prompts: &prompts,
5783 cache: cache.as_deref(),
5784 round: None,
5785 };
5786 let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5787 let mut record = GateFixRecord {
5788 agent: seat.agent.clone(),
5789 failed,
5790 notes: String::new(),
5791 committed: false,
5792 error: None,
5793 };
5794 match out {
5795 AgentOutcome::Ok(o) => {
5796 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5799 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5800 }
5801 }
5802 AgentOutcome::Dropped(_) => {
5803 record.error = Some("the CLI dropped the stream".to_owned());
5804 }
5805 AgentOutcome::Quota(o) => {
5806 self.state.quota.push(QuotaLoss {
5807 seat: seat.key.clone(),
5808 node: "gate-fix".to_owned(),
5809 at: Timestamp::now(),
5810 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5811 });
5812 record.error = Some("rate limited (quota); fixer could not run".to_owned());
5813 }
5814 AgentOutcome::Failed(e) => record.error = Some(e),
5815 }
5816 self.state.seats.insert(seat.key.clone(), seat);
5817 if let Ok(r) = git::rescue_commit(
5818 &winner.worktree,
5819 &format!("magi: gate fix {attempt} (uncommitted work)"),
5820 )
5821 .await
5822 {
5823 self.state.note_withheld("gate-fix", &r.withheld);
5824 }
5825 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5826 record.committed = after != before;
5827 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5828 let note = record.error.clone();
5829 self.state.gate_fixes.push(record);
5830 self.state.save()?;
5831 if !changed {
5832 self.state.event(
5833 "gate",
5834 match note {
5835 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5836 None => format!("gate-fix round {attempt}: the tree did not change"),
5837 },
5838 );
5839 return Ok(GateFix::Stop);
5840 }
5841 self.state.event(
5842 "gate",
5843 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5844 );
5845
5846 let commands = self.state.config.verify.e2e.clone();
5847 if !commands.is_empty() {
5848 let shell = self.state.config.shell();
5849 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5850 let cache_dir = self.state.config.cache_dir();
5851 let context = format!("gate-fix round {attempt}");
5852 let (e2e, _) = with_cache_lease(
5853 &mut self.state,
5854 cache_dir.as_deref(),
5855 "e2e",
5856 "e2e",
5857 &winner.worktree,
5858 &after,
5859 timeout,
5860 &context,
5861 |state, budget| {
5862 let shell = shell.clone();
5863 let commands = commands.clone();
5864 let context = context.clone();
5865 let worktree = winner.worktree.clone();
5866 async move {
5867 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5868 .await
5869 }
5870 },
5871 )
5872 .await;
5873 if verify_inconclusive(&e2e) {
5874 return Ok(GateFix::Defer);
5875 }
5876 if e2e.iter().any(|o| !o.ok()) {
5877 self.state.event(
5878 "gate",
5879 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5880 );
5881 return Ok(GateFix::Stop);
5882 }
5883 }
5884 Ok(GateFix::Retry)
5885 }
5886
5887 async fn merge(&mut self) -> Result<()> {
5890 if self
5905 .state
5906 .base_sync
5907 .as_ref()
5908 .is_some_and(|s| s.conflict.is_some())
5909 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5910 != Some(RunStatus::Gating)
5911 || !self.state.gate_status().ok()
5920 {
5921 return Ok(());
5922 }
5923 if self.state.merge.is_some() {
5932 return Ok(());
5933 }
5934 let Some(winner) = self.state.winner().cloned() else {
5935 return Ok(());
5936 };
5937 let repo = self.state.repo.clone();
5938 let base = self.state.base_branch.clone();
5939 let mode = self.state.config.merge.mode;
5940 let style = self.state.config.merge.style;
5941 let facts = if is_review_run(&self.state) {
5942 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
5943 let start = review_base(
5944 &repo,
5945 &self.state.config.merge.remote,
5946 &base,
5947 &self.state.base_commit,
5948 &winner.branch,
5949 )
5950 .await;
5951 branch_facts(&repo, &start, &winner.branch).await
5952 } else {
5953 None
5954 };
5955 let leaked = if is_review_run(&self.state) {
5958 leaked_subjects(&self.state, &winner.branch).await
5959 } else {
5960 Some(Vec::new())
5961 };
5962 let pr = pr_message_with(&self.state, winner.label, facts.as_ref());
5963 let message = pr.commit_message();
5964
5965 let outcome = match mode {
5966 MergeMode::None => MergeOutcome {
5967 mode,
5968 ok: true,
5969 detail: manual_merge_command(style, &repo, &winner.branch, &message),
5970 empty: false,
5971 },
5972 MergeMode::Pr | MergeMode::Local
5973 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
5974 {
5975 MergeOutcome {
5976 mode,
5977 ok: false,
5978 detail: empty_candidate_detail(&self.state, &base),
5979 empty: true,
5980 }
5981 }
5982 MergeMode::Local => {
5983 let on = git::current_branch(&repo).await?;
5984 if on.as_deref() != Some(base.as_str()) {
5985 MergeOutcome {
5986 mode,
5987 ok: false,
5988 detail: format!(
5989 "{} has {} checked out, not the base branch {base}",
5990 repo.display(),
5991 on.unwrap_or_else(|| "a detached HEAD".to_owned())
5992 ),
5993 empty: false,
5994 }
5995 } else if !git::is_clean(&repo).await? {
5996 MergeOutcome {
5997 mode,
5998 ok: false,
5999 detail: format!("{} is dirty; refusing to merge", repo.display()),
6000 empty: false,
6001 }
6002 } else {
6003 let out = match style {
6004 MergeStyle::Merge => {
6005 git::merge_no_ff(&repo, &winner.branch, &message).await?
6006 }
6007 MergeStyle::Squash => {
6008 git::merge_squash(&repo, &winner.branch, &message).await?
6009 }
6010 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6011 };
6012 MergeOutcome {
6013 mode,
6014 ok: out.ok(),
6015 detail: if out.ok() { out.stdout } else { out.stderr },
6016 empty: false,
6017 }
6018 }
6019 }
6020 MergeMode::Pr => {
6021 let remote = self.state.config.merge.remote.clone();
6022 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6023 if !pushed.ok() {
6024 MergeOutcome {
6025 mode,
6026 ok: false,
6027 detail: pushed.stderr,
6028 empty: false,
6029 }
6030 } else {
6031 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6037 let out = match pr_merge_plan(found) {
6038 PrPlan::Create => {
6039 gh_pr_create(
6040 &winner.worktree,
6041 &base,
6042 &winner.branch,
6043 &pr.title,
6044 &pr.body,
6045 )
6046 .await
6047 }
6048 PrPlan::Adopt { url, title } => {
6049 self.state
6050 .event("merge", format!("Pr: adopted open pull request {url}"));
6051 if title != pr.title
6052 && (!is_review_run(&self.state)
6053 || leaked
6054 .as_deref()
6055 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6056 && let Err(e) =
6057 land::set_pr_title(&winner.worktree, &url, &pr.title).await
6058 {
6059 tracing::warn!("could not refresh title of {url}: {e:#}");
6060 self.state
6061 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6062 }
6063 Ok(url)
6064 }
6065 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6066 };
6067 match out {
6068 Ok(url) => MergeOutcome {
6069 mode,
6070 ok: true,
6071 detail: url,
6072 empty: false,
6073 },
6074 Err(e) => MergeOutcome {
6075 mode,
6076 ok: false,
6077 detail: e.to_string(),
6078 empty: false,
6079 },
6080 }
6081 }
6082 }
6083 };
6084
6085 self.state.status = match (mode, outcome.ok) {
6086 (MergeMode::None, _) => RunStatus::Ready,
6087 (_, true) => RunStatus::Merged,
6088 (_, false) => RunStatus::Blocked,
6089 };
6090 self.state.event(
6091 "merge",
6092 format!(
6093 "{:?}: {}",
6094 mode,
6095 outcome.detail.lines().next().unwrap_or("")
6096 ),
6097 );
6098 self.state.merge = Some(outcome);
6099 self.state.save()?;
6100
6101 if self.state.config.graph.land
6107 && mode == MergeMode::Pr
6108 && self.state.status == RunStatus::Merged
6109 {
6110 self.run_land().await?;
6111 }
6112 self.settle_questions();
6117 Ok(())
6118 }
6119
6120 async fn run_land(&mut self) -> Result<()> {
6131 let url = self
6132 .state
6133 .merge
6134 .as_ref()
6135 .map(|m| m.detail.clone())
6136 .unwrap_or_default();
6137 let url = url.lines().next().unwrap_or("").trim().to_owned();
6138 if !url.starts_with("http") {
6139 return Ok(());
6140 }
6141 match land::land(&mut self.state, &url).await {
6144 Ok(pr) if self.state.parked => {
6145 let _ = pr;
6149 }
6150 Ok(pr) => {
6151 self.state.status = match pr.state {
6152 land::PrLifecycle::Merged => RunStatus::Merged,
6153 _ => RunStatus::Blocked,
6154 };
6155 if bump::should_release_bump(self.state.status)
6162 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6163 {
6164 self.state
6170 .event("bump", format!("release bump skipped: {e:#}"));
6171 }
6172 if self.state.status == RunStatus::Merged {
6175 crate::followup::after_merge(&mut self.state, &pr.url).await;
6176 }
6177 self.state.save()?;
6178 }
6179 Err(e) => {
6180 self.state.status = RunStatus::Blocked;
6181 self.state.event("land", format!("gave up: {e}"));
6182 self.state.save()?;
6183 }
6184 }
6185 Ok(())
6186 }
6187
6188 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6192 if let Some(existing) = self.state.seats.get(key)
6193 && existing.agent == agent
6194 {
6195 return existing.clone();
6196 }
6197 let fresh = if self.state.seats.contains_key(key) {
6200 handover_seat(key, agent, self.state.next_seat_seed())
6201 } else {
6202 SeatState::new(key, agent, self.state.seed)
6203 };
6204 self.state.seats.insert(key.to_owned(), fresh.clone());
6205 fresh
6206 }
6207
6208 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6214 match self.state.seats.get(key) {
6215 Some(s) if s.agent != spec.id => {
6216 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6217 }
6218 _ => spec,
6219 }
6220 }
6221
6222 fn view(&self, c: &Candidate) -> CandidateView {
6224 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6225 .unwrap_or_default();
6226 let (patch, _) = blind::sanitize_patch(
6227 &format!("candidate {} patch", c.label),
6228 &raw,
6229 &self.state.config.blind,
6230 );
6231 CandidateView {
6232 label: c.label,
6233 branch: c.branch.clone(),
6234 summary: c.summary.clone(),
6235 stat: c.stat.clone(),
6236 patch,
6237 }
6238 }
6239
6240 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6242 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6243 prompt::judge(
6244 "(see above)",
6245 &views,
6246 self.roles.judges.len(),
6247 base_short,
6248 "en",
6249 )
6250 }
6251
6252 fn vote_prompt_full(
6258 &self,
6259 j: usize,
6260 labels: &[char],
6261 language: &str,
6262 candidates: &[Candidate],
6263 base_short: &str,
6264 ) -> String {
6265 let mut text = format!(
6266 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6267 prompt::final_vote(labels, language),
6268 self.state.instruction,
6269 self.candidate_block(candidates, base_short)
6270 );
6271 if let Some(own) = self
6272 .state
6273 .judgements
6274 .get(j)
6275 .filter(|r| !r.ranking.is_empty())
6276 {
6277 let reasons = own
6278 .reasons
6279 .iter()
6280 .map(|(k, v)| format!("- {k}: {v}"))
6281 .collect::<Vec<_>>()
6282 .join("\n");
6283 text.push_str(&format!(
6284 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6285 own.ranking.iter().collect::<String>(),
6286 if reasons.is_empty() {
6287 ""
6288 } else {
6289 ", because:\n"
6290 }
6291 ));
6292 }
6293 if !self.state.deliberation.is_empty() {
6294 text.push_str("\n# What was argued before this vote\n");
6295 for t in self.transcript(&[], j) {
6296 text.push_str(&format!(
6297 "\n## {}{}\n\n{}\n",
6298 t.who,
6299 if t.is_self { " (you)" } else { "" },
6300 t.body.trim()
6301 ));
6302 }
6303 }
6304 text
6305 }
6306
6307 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6314 let mut turns = Vec::new();
6315 for j in &self.state.judgements {
6316 if j.ranking.is_empty() {
6317 continue;
6318 }
6319 let reasons = j
6320 .reasons
6321 .iter()
6322 .map(|(k, v)| format!("- {k}: {v}"))
6323 .collect::<Vec<_>>()
6324 .join("\n");
6325 turns.push(Turn {
6326 who: format!("Judge {} (opening ranking)", j.judge),
6327 is_self: j.judge == self_idx + 1,
6328 body: format!(
6329 "Ranked {}{}{reasons}",
6330 j.ranking.iter().collect::<String>(),
6331 if reasons.is_empty() {
6332 ""
6333 } else {
6334 ", because:\n"
6335 }
6336 ),
6337 });
6338 }
6339 for t in self
6340 .state
6341 .deliberation
6342 .iter()
6343 .flat_map(|r| r.turns.iter())
6344 .chain(current)
6345 {
6346 turns.push(Turn {
6347 who: format!("Judge {}", t.judge),
6348 is_self: t.judge == self_idx + 1,
6349 body: t.body.clone(),
6350 });
6351 }
6352 turns
6353 }
6354}
6355
6356fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6358 agent::has_session(spec.kind, seat, sessions)
6359}
6360
6361fn next_untried_in_roster<'a>(
6382 roster: &'a [AgentSpec],
6383 start: usize,
6384 tried: &BTreeSet<String>,
6385) -> Option<&'a AgentSpec> {
6386 roster
6387 .get(start + 1..)?
6388 .iter()
6389 .find(|s| !tried.contains(&s.id))
6390}
6391
6392fn pick_successor<'a>(
6413 roster: &'a [AgentSpec],
6414 start: usize,
6415 tried: &BTreeSet<String>,
6416 carried: Option<&BTreeSet<String>>,
6417 others: &BTreeSet<String>,
6418) -> Option<&'a AgentSpec> {
6419 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6420 roster
6421 .get(start + 1..)
6422 .and_then(|tail| {
6423 tail.iter()
6424 .filter(free)
6425 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6426 })
6427 .or_else(|| {
6428 carried
6429 .is_some()
6430 .then(|| roster.iter().find(free))
6431 .flatten()
6432 })
6433 .or_else(|| match carried {
6434 Some(c) => next_for_seat(roster, start, tried, c),
6435 None => next_untried_in_roster(roster, start, tried),
6436 })
6437}
6438
6439fn next_for_seat<'a>(
6449 roster: &'a [AgentSpec],
6450 start: usize,
6451 round_tried: &BTreeSet<String>,
6452 carried_failed: &BTreeSet<String>,
6453) -> Option<&'a AgentSpec> {
6454 roster
6455 .get(start + 1..)?
6456 .iter()
6457 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6458 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6459}
6460
6461fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6467 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6468 return spec;
6469 };
6470 let ok = |id: &str| !h.failed.contains(id);
6471 if let Some(last) = h.last_ok.as_deref()
6472 && ok(last)
6473 && let Some(s) = roster.iter().find(|s| s.id == last)
6474 {
6475 return s.clone();
6476 }
6477 if ok(&spec.id) {
6478 return spec;
6479 }
6480 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6481 roster
6482 .get(from + 1..)
6483 .into_iter()
6484 .flatten()
6485 .chain(roster.iter())
6486 .find(|s| ok(&s.id))
6487 .cloned()
6488 .unwrap_or(spec)
6489}
6490
6491fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6495 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6496}
6497
6498const TIMED_OUT: &str = "timed out";
6501
6502impl FailClass {
6503 fn of(out: &AgentOutcome) -> Option<Self> {
6505 match out {
6506 AgentOutcome::Ok(_) => None,
6507 AgentOutcome::Quota(_) => Some(Self::Quota),
6508 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6509 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6510 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6511 }
6512 }
6513
6514 fn stem_word(&self) -> &'static str {
6516 match self {
6517 Self::Quota => "quota",
6518 _ => "handover",
6519 }
6520 }
6521}
6522
6523fn failure_signature(msg: &str) -> String {
6527 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6528 let mut out = Vec::new();
6529 for word in line.split_whitespace() {
6530 if word.contains('/') || word.contains('\\') {
6531 out.push("<path>".to_owned());
6532 continue;
6533 }
6534 let mut w = String::new();
6535 let mut in_digits = false;
6536 for c in word.chars() {
6537 if c.is_ascii_digit() {
6538 if !in_digits {
6539 w.push('#');
6540 }
6541 in_digits = true;
6542 } else {
6543 in_digits = false;
6544 w.push(c);
6545 }
6546 }
6547 out.push(w);
6548 }
6549 out.join(" ").chars().take(120).collect()
6550}
6551
6552fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6559 match cur {
6560 FailClass::Quota | FailClass::Timeout => true,
6561 FailClass::Other(_) => prev != Some(cur),
6562 }
6563}
6564
6565fn fail_reason(out: &AgentOutcome) -> String {
6567 match out {
6568 AgentOutcome::Ok(_) => String::new(),
6569 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6570 AgentOutcome::Dropped(o) => format!(
6571 "the CLI dropped the stream ({})",
6572 o.dropped
6573 .as_ref()
6574 .map(|d| d.why.as_str())
6575 .unwrap_or("it ended without delivering its answer")
6576 ),
6577 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6578 }
6579}
6580
6581fn record_handover(
6584 state: &mut RunState,
6585 node: &str,
6586 seat: &str,
6587 from: &str,
6588 to: &str,
6589 class: &FailClass,
6590 reason: &str,
6591) {
6592 let message = if *class == FailClass::Quota {
6593 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6594 } else {
6595 format!("{seat}: handed over {from} -> {to} ({reason})")
6596 };
6597 state.event(node, message);
6598 state.handovers.push(Handover {
6599 at: Timestamp::now(),
6600 node: node.to_owned(),
6601 seat: seat.to_owned(),
6602 from: from.to_owned(),
6603 to: to.to_owned(),
6604 reason: reason.to_owned(),
6605 });
6606}
6607
6608fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6622 commands.iter().any(|c| c.exit_code.is_none())
6623}
6624
6625fn verified_noop_claim(
6638 usable: bool,
6639 commands: &[agent::CommandEvidence],
6640 text: &str,
6641) -> Option<String> {
6642 (usable && !has_unconfirmed_command(commands))
6643 .then(|| verdict::verified_noop(text))
6644 .flatten()
6645}
6646
6647fn short(commit: &str) -> String {
6648 commit.chars().take(7).collect()
6649}
6650
6651fn make_executable(path: &Path) -> Result<()> {
6652 #[cfg(unix)]
6653 {
6654 use std::os::unix::fs::PermissionsExt as _;
6655 let mut perms = std::fs::metadata(path)?.permissions();
6656 perms.set_mode(0o755);
6657 std::fs::set_permissions(path, perms)?;
6658 }
6659 #[cfg(not(unix))]
6660 {
6661 let _ = path;
6662 }
6663 Ok(())
6664}
6665
6666struct WaveCtx<'a> {
6673 run: &'a str,
6676 node: &'a str,
6678 prompts: &'a Prompts,
6679 cache: Option<&'a Path>,
6681 round: Option<usize>,
6684 carry_seats: bool,
6688}
6689
6690async fn run_one(
6692 job: SeatJob,
6693 sem: Arc<Semaphore>,
6694 ctx: &WaveCtx<'_>,
6695 state: &mut RunState,
6696 attempt: usize,
6697) -> (SeatState, AgentOutcome) {
6698 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6699 .await
6700 .pop()
6701 .expect("one job in, one result out");
6702 (seat, out)
6703}
6704
6705async fn wave(
6711 jobs: Vec<SeatJob>,
6712 sem: Arc<Semaphore>,
6713 ctx: &WaveCtx<'_>,
6714 state: &mut RunState,
6715 attempt: usize,
6716) -> Vec<(usize, SeatState, AgentOutcome)> {
6717 let WaveCtx {
6718 run,
6719 node,
6720 prompts,
6721 cache,
6722 round,
6723 carry_seats: _,
6724 } = *ctx;
6725 for job in &jobs {
6726 state.seat_started(node, &job.seat.key, job.timeout, attempt);
6727 }
6728 if let Err(e) = state.save() {
6729 tracing::warn!("could not persist in-progress seats: {e:#}");
6734 }
6735 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
6751 let wait_started = Instant::now();
6752 let cache_guard = if let Some(cache_dir) = cache {
6753 if jobs_had_a_writer {
6754 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
6755 let budget = jobs
6756 .iter()
6757 .map(|j| j.timeout)
6758 .max()
6759 .unwrap_or(Duration::from_secs(60));
6760 acquire_cache_lease(state, cache_dir, &owner, budget, node)
6761 .await
6762 .ok()
6763 } else {
6764 None
6765 }
6766 } else {
6767 None
6768 };
6769 let waited_for_lease = wait_started.elapsed();
6776 let mut set = tokio::task::JoinSet::new();
6777 let overlay = prompts.overlay(node);
6778 for (i, mut job) in jobs.into_iter().enumerate() {
6779 job.timeout = job.timeout.saturating_sub(waited_for_lease);
6780 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
6781 if cache.is_some() {
6782 job.prompt.push('\n');
6783 job.prompt
6784 .push_str(&prompt::build_cache_note(node, job.allow_write));
6785 }
6786 let sem = Arc::clone(&sem);
6787 let run = run.to_owned();
6788 let node = node.to_owned();
6789 let attachments = if node == "implement" {
6792 state.attachments.clone()
6793 } else {
6794 Vec::new()
6795 };
6796 let cache = cache
6807 .filter(|_| job.allow_write && cache_guard.is_some())
6808 .map(Path::to_path_buf);
6809 set.spawn(async move {
6810 let _permit = sem.acquire().await;
6811 let mut seat = job.seat;
6812 let out = agent::invoke(
6813 &job.spec,
6814 &mut seat,
6815 &Invocation {
6816 cwd: &job.cwd,
6817 prompt: &job.prompt,
6818 timeout: job.timeout,
6819 allow_write: job.allow_write,
6820 sessions: job.sessions,
6821 artifacts: &job.artifacts,
6822 stem: &job.stem,
6823 run: &run,
6824 node: &node,
6825 cache_dir: cache.as_deref(),
6826 attachments: &attachments,
6827 writable: &[],
6828 },
6829 )
6830 .await;
6831 let out = match out {
6832 Ok(o) if o.usable() => AgentOutcome::Ok(o),
6833 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
6834 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
6842 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
6843 Ok(o) => AgentOutcome::Failed(format!(
6844 "exited with {:?} and no usable output",
6845 o.exit_code
6846 )),
6847 Err(e) => AgentOutcome::Failed(e.to_string()),
6848 };
6849 (i, seat, out)
6850 });
6851 }
6852 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
6853 while let Some(joined) = set.join_next().await {
6854 let (i, seat, out) = match joined {
6855 Ok(v) => v,
6856 Err(e) => {
6860 tracing::error!("agent task panicked: {e}");
6861 continue;
6862 }
6863 };
6864 state.seat_finished(&seat.key);
6865 record_jobs(state, node, round, &seat.key, &out);
6866 if let Err(e) = state.save() {
6867 tracing::warn!("could not persist a seat's completion: {e:#}");
6868 }
6869 if collected.len() <= i {
6870 collected.resize_with(i + 1, || None);
6871 }
6872 collected[i] = Some((i, seat, out));
6873 }
6874 if state
6880 .active
6881 .values()
6882 .any(|a| a.node == node && a.attempt == attempt)
6883 {
6884 state
6885 .active
6886 .retain(|_, a| !(a.node == node && a.attempt == attempt));
6887 if let Err(e) = state.save() {
6888 tracing::warn!("could not persist the end of a wave: {e:#}");
6889 }
6890 }
6891 if let Some(cache_dir) = cache
6898 && jobs_had_a_writer
6899 {
6900 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
6901 }
6902 if let Some(guard) = cache_guard {
6903 guard.release();
6904 }
6905 collected.into_iter().flatten().collect()
6906}
6907
6908fn record_jobs(
6919 state: &mut RunState,
6920 node: &str,
6921 round: Option<usize>,
6922 seat: &str,
6923 out: &AgentOutcome,
6924) {
6925 let commands: &[agent::CommandEvidence] = match out {
6926 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
6927 AgentOutcome::Failed(_) => &[],
6928 };
6929 let checked_at = Timestamp::now();
6930 for c in commands {
6931 state.jobs.push(JobRecord {
6932 node: node.to_owned(),
6933 round,
6934 seat: seat.to_owned(),
6935 id: c.id.clone(),
6936 description: c.description.clone(),
6937 checked_at,
6938 status: match c.exit_code {
6939 Some(0) => JobStatus::Completed,
6940 Some(_) => JobStatus::Failed,
6941 None => JobStatus::Unknown,
6942 },
6943 exit_code: c.exit_code,
6944 result_summary: c.result_summary.clone(),
6945 source: c.source.clone(),
6946 });
6947 }
6948}
6949
6950fn round_is_clean(
6971 blocking: usize,
6972 e2e_ok: bool,
6973 answered: usize,
6974 expected: usize,
6975 quota_missing: usize,
6976 policy: IncompleteReviewPolicy,
6977) -> bool {
6978 if blocking != 0 || !e2e_ok {
6979 return false;
6980 }
6981 if answered == expected || policy == IncompleteReviewPolicy::Warn {
6982 return true;
6983 }
6984 answered > 0 && expected - answered <= quota_missing
6985}
6986
6987fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7011 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7012 return Some(RunStatus::Gating);
7013 }
7014 let last = reviews.last()?;
7015 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7016 if reviews.len() < max_rounds && !stagnant {
7017 return None;
7018 }
7019 if last.incomplete() && last.blocking == 0 {
7020 return Some(RunStatus::Blocked);
7021 }
7022 if last.e2e_status() == E2eStatus::ResourceBlocked {
7023 return None;
7024 }
7025 Some(if last.e2e.iter().all(CommandOutcome::ok) {
7026 RunStatus::Gating
7027 } else {
7028 RunStatus::Blocked
7029 })
7030}
7031
7032fn retry_budget(full: Duration, nudged: bool) -> Duration {
7047 if nudged {
7048 (full / 4).max(Duration::from_secs(120)).min(full)
7049 } else {
7050 full
7051 }
7052}
7053
7054#[allow(clippy::too_many_arguments)]
7083async fn ask_json_wave<T>(
7084 jobs: Vec<SeatJob>,
7085 sem: Arc<Semaphore>,
7086 retries: usize,
7087 roster: &[AgentSpec],
7088 ctx: &WaveCtx<'_>,
7089 losses: &mut Vec<QuotaLoss>,
7090 state: &mut RunState,
7091 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7092) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7093where
7094 T: serde::de::DeserializeOwned + Send + 'static,
7095{
7096 ask_wave_with(
7097 jobs,
7098 sem,
7099 retries,
7100 roster,
7101 ctx,
7102 losses,
7103 state,
7104 &|text: &str| {
7105 let v = verdict::extract_json::<T>(text)?;
7106 validate(&v)?;
7107 Ok(v)
7108 },
7109 )
7110 .await
7111}
7112
7113#[allow(clippy::too_many_arguments)]
7122async fn ask_wave_with<T>(
7123 jobs: Vec<SeatJob>,
7124 sem: Arc<Semaphore>,
7125 retries: usize,
7126 roster: &[AgentSpec],
7127 ctx: &WaveCtx<'_>,
7128 losses: &mut Vec<QuotaLoss>,
7129 state: &mut RunState,
7130 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7131) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7132where
7133 T: Send + 'static,
7134{
7135 let n = jobs.len();
7136 let originals: Vec<SeatJob> = jobs;
7137 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7138 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7139 let mut nudges: Vec<usize> = vec![0; n];
7146 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7150 let mut tried: Vec<BTreeSet<String>> = specs
7151 .iter()
7152 .map(|s| BTreeSet::from([s.id.clone()]))
7153 .collect();
7154 let starts: Vec<usize> = specs
7155 .iter()
7156 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7157 .collect();
7158 let carry = ctx.carry_seats && !roster.is_empty();
7159 let carried: Vec<BTreeSet<String>> = originals
7162 .iter()
7163 .map(|j| {
7164 state
7165 .seat_history
7166 .get(&j.seat.key)
7167 .filter(|_| carry)
7168 .map(|h| h.failed.clone())
7169 .unwrap_or_default()
7170 })
7171 .collect();
7172 let mut prev: Vec<Option<FailClass>> = originals
7173 .iter()
7174 .map(|j| {
7175 state
7176 .seat_history
7177 .get(&j.seat.key)
7178 .filter(|_| carry)
7179 .and_then(|h| h.last_fail.clone())
7180 })
7181 .collect();
7182 let next_agent =
7183 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7184 let others: BTreeSet<String> = specs
7185 .iter()
7186 .enumerate()
7187 .filter(|(j, _)| *j != i)
7188 .map(|(_, s)| s.id.clone())
7189 .collect();
7190 pick_successor(
7191 roster,
7192 starts[i],
7193 tried,
7194 carry.then(|| &carried[i]),
7195 &others,
7196 )
7197 .cloned()
7198 };
7199 let mut fresh: Vec<Option<String>> = vec![None; n];
7200 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7201 let mut pending: Vec<usize> = (0..n).collect();
7202
7203 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7208 for round in 0..max_rounds {
7209 if pending.is_empty() {
7210 break;
7211 }
7212 let mut batch = Vec::with_capacity(pending.len());
7213 let mut renudged: Vec<&str> = Vec::new();
7214 for &i in &pending {
7215 let src = &originals[i];
7216 let full: &str = match &src.handover {
7221 Some(h) if specs[i].id != src.spec.id => h,
7222 _ => &src.prompt,
7223 };
7224 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7227 (
7228 full.to_owned(),
7229 src.timeout,
7230 format!("{}-{word}-{}", src.stem, specs[i].id),
7231 )
7232 } else if nudges[i] == 0 {
7233 (full.to_owned(), src.timeout, src.stem.clone())
7234 } else {
7235 renudged.push(src.seat.key.as_str());
7236 let why = done[i]
7237 .as_ref()
7238 .and_then(|r| r.as_ref().err().map(ToString::to_string))
7239 .unwrap_or_else(|| "no parsable answer".to_owned());
7240 let nudge = prompt::nudge(&why);
7241 let nudged = has_context(&specs[i], &seats[i], src.sessions);
7242 let prompt = if nudged {
7243 nudge
7244 } else {
7245 format!("{full}\n\n---\n\n{nudge}")
7246 };
7247 (
7248 prompt,
7249 retry_budget(src.timeout, nudged),
7250 format!("{}-retry{}", src.stem, nudges[i]),
7251 )
7252 };
7253 batch.push(SeatJob {
7254 spec: specs[i].clone(),
7255 seat: seats[i].clone(),
7256 cwd: src.cwd.clone(),
7257 prompt,
7258 timeout,
7259 allow_write: src.allow_write,
7260 sessions: src.sessions,
7261 artifacts: src.artifacts.clone(),
7262 stem,
7263 handover: None,
7264 });
7265 }
7266
7267 if !renudged.is_empty() {
7268 state.event(
7269 ctx.node,
7270 format!("retry {round}: re-asking {}", renudged.join(", ")),
7271 );
7272 }
7273 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7274 let mut still = Vec::new();
7275 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7276 seats[i] = seat;
7277 let class = FailClass::of(&out);
7278 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7281 && nudges[i] < retries
7282 && !roster.is_empty();
7283 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7284 let next = should_hand_over(prev[i].as_ref(), &cur)
7285 .then(|| next_agent(i, &tried[i], &specs))
7286 .flatten();
7287 if carry {
7288 let h = state
7289 .seat_history
7290 .entry(originals[i].seat.key.clone())
7291 .or_default();
7292 h.failed.insert(specs[i].id.clone());
7293 h.last_fail = Some(cur.clone());
7294 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7295 h.last_ok = None;
7296 }
7297 if let Err(e) = state.save() {
7300 tracing::warn!("could not persist a seat's failure history: {e:#}");
7301 }
7302 }
7303 if let Some(next) = next {
7304 record_handover(
7305 state,
7306 ctx.node,
7307 &originals[i].seat.key,
7308 &specs[i].id,
7309 &next.id,
7310 &cur,
7311 &fail_reason(&out),
7312 );
7313 tried[i].insert(next.id.clone());
7314 prev[i] = Some(cur.clone());
7315 seats[i] =
7316 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7317 specs[i] = next;
7318 fresh[i] = Some(cur.stem_word().to_owned());
7319 nudges[i] = 0;
7320 done[i] = Some(Err(anyhow::anyhow!(
7321 "handed over after: {}",
7322 fail_reason(&out)
7323 )));
7324 still.push(i);
7325 continue;
7326 }
7327 }
7328 if carry
7329 && !nudge_first
7330 && let Some(cur) = class.clone()
7331 {
7332 let h = state
7334 .seat_history
7335 .entry(originals[i].seat.key.clone())
7336 .or_default();
7337 h.failed.insert(specs[i].id.clone());
7338 h.last_fail = Some(cur);
7339 }
7340 let parsed = match out {
7341 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7342 AgentOutcome::Quota(o) => {
7343 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7344 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7345 }
7346 AgentOutcome::Dropped(o) => {
7350 let why = o
7351 .dropped
7352 .as_ref()
7353 .map(|d| d.why.as_str())
7354 .unwrap_or("the CLI ended the stream without delivering its answer");
7355 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7356 }
7357 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7358 };
7359 let quota = class == Some(FailClass::Quota);
7360 let failed = parsed.is_err();
7361 done[i] = Some(parsed);
7362 if carry && !failed {
7363 let h = state
7364 .seat_history
7365 .entry(originals[i].seat.key.clone())
7366 .or_default();
7367 h.failed.remove(&specs[i].id);
7368 h.last_ok = Some(specs[i].id.clone());
7369 h.last_fail = None;
7370 }
7371 let agent_failure = class.is_some()
7379 && !nudge_first
7380 && !roster.is_empty()
7381 && next_agent(i, &tried[i], &specs).is_some();
7382 if failed && !quota && !agent_failure && nudges[i] < retries {
7383 nudges[i] += 1;
7384 still.push(i);
7385 }
7386 }
7387 pending = still;
7388 }
7389
7390 for (i, q) in last_quota.into_iter().enumerate() {
7393 if let Some(reset) = q {
7394 losses.push(QuotaLoss {
7395 seat: originals[i].seat.key.clone(),
7396 node: ctx.node.to_owned(),
7397 at: Timestamp::now(),
7398 reset,
7399 });
7400 }
7401 }
7402
7403 seats
7404 .into_iter()
7405 .zip(done)
7406 .zip(nudges)
7407 .map(|((seat, res), attempts)| {
7408 (
7409 seat,
7410 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7411 attempts,
7412 )
7413 })
7414 .collect()
7415}
7416
7417async fn acquire_cache_lease(
7430 state: &mut RunState,
7431 cache_dir: &Path,
7432 owner: &crate::cache::Owner,
7433 budget: Duration,
7434 context: &str,
7435) -> Result<crate::cache::Guard> {
7436 let home = crate::run::home();
7437 let started = Instant::now();
7438 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7439 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7440 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7441 Err(e) => {
7442 state.event(
7443 "verify",
7444 format!("{context}: could not check the shared build cache: {e:#}"),
7445 );
7446 if let Err(e2) = state.save() {
7447 tracing::warn!("could not persist a cache-check failure: {e2:#}");
7448 }
7449 return Err(e);
7450 }
7451 };
7452 state.event(
7453 "verify",
7454 format!(
7455 "{context}: waiting for the shared build cache at {} ({})",
7456 cache_dir.display(),
7457 busy.describe()
7458 ),
7459 );
7460 if let Err(e) = state.save() {
7461 tracing::warn!("could not persist a cache wait: {e:#}");
7462 }
7463 let remaining = budget.saturating_sub(started.elapsed());
7464 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7465 Ok(g) => Ok(g),
7466 Err(e) => {
7467 state.event("verify", format!("{context}: {e:#}"));
7468 if let Err(e2) = state.save() {
7469 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7470 }
7471 Err(e)
7472 }
7473 }
7474}
7475
7476#[allow(clippy::too_many_arguments)]
7497async fn with_cache_lease<'s, F, Fut>(
7498 state: &'s mut RunState,
7499 cache_dir: Option<&Path>,
7500 node: &str,
7501 seat: &str,
7502 worktree: &Path,
7503 head: &str,
7504 budget: Duration,
7505 context: &str,
7506 body: F,
7507) -> (Vec<CommandOutcome>, bool)
7508where
7509 F: FnOnce(&'s mut RunState, Duration) -> Fut,
7510 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7511{
7512 let Some(cache_dir) = cache_dir else {
7513 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7514 return (outcomes, retried);
7515 };
7516 let home = crate::run::home();
7517 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7518 let started = Instant::now();
7519 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7520 Ok(g) => g,
7521 Err(e) => {
7522 return (
7523 vec![CommandOutcome {
7524 command: "(waiting for the shared build cache)".to_owned(),
7525 code: None,
7526 output_tail: e.to_string(),
7527 duration_ms: started.elapsed().as_millis() as u64,
7528 resource_blocked: true,
7529 }],
7530 false,
7531 );
7532 }
7533 };
7534 let identity = crate::cache::Identity::new(worktree, head);
7535 if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7536 state.event(
7544 "verify",
7545 format!(
7546 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7547 worktree.display(),
7548 short(head)
7549 ),
7550 );
7551 guard.release();
7552 return (
7553 vec![CommandOutcome {
7554 command: "(confirming the shared build cache is fresh)".to_owned(),
7555 code: None,
7556 output_tail: e.to_string(),
7557 duration_ms: started.elapsed().as_millis() as u64,
7558 resource_blocked: true,
7559 }],
7560 false,
7561 );
7562 }
7563 let remaining = budget.saturating_sub(started.elapsed());
7564 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7565 if !timed_out_pids.is_empty() {
7570 wait_for_timed_out_children_to_die(&timed_out_pids).await;
7571 }
7572 guard.release();
7573 (outcomes, retried)
7574}
7575
7576async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7588 wait_for_pids_with(
7589 pids,
7590 crate::proc::pid_alive,
7591 LEASE_RELEASE_POLL,
7592 LEASE_RELEASE_MAX_WAIT,
7593 )
7594 .await;
7595}
7596
7597async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7603 pids: &[u32],
7604 alive: F,
7605 poll: Duration,
7606 max_wait: Duration,
7607) {
7608 let deadline = Instant::now() + max_wait;
7609 loop {
7610 if pids.iter().all(|&pid| !alive(pid)) {
7611 return;
7612 }
7613 if Instant::now() >= deadline {
7614 return;
7615 }
7616 tokio::time::sleep(poll).await;
7617 }
7618}
7619
7620fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7627 outcomes.iter().any(|o| o.resource_blocked)
7628}
7629
7630enum GateFix {
7632 Retry,
7634 Stop,
7637 Defer,
7640}
7641
7642fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7650 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7651 red.peek().is_some()
7652 && red.all(|o| {
7653 !o.resource_blocked
7654 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7655 && !o.output_tail.trim().is_empty()
7656 })
7657}
7658
7659fn e2e_outcome_label(o: &CommandOutcome) -> String {
7663 if o.ok() {
7664 return "pass".to_owned();
7665 }
7666 let reason = if o.build_failed() {
7667 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7668 } else {
7669 format!("FAIL ({:?})", o.code)
7670 };
7671 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7672}
7673
7674async fn run_e2e_with_retry(
7682 state: &mut RunState,
7683 shell: &[String],
7684 commands: &[String],
7685 worktree: &Path,
7686 timeout: Duration,
7687 context: &str,
7688) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7689 let (mut e2e, mut timed_out_pids) = run_commands(
7690 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7691 )
7692 .await;
7693 for o in &e2e {
7694 state.event(
7695 "verify",
7696 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7697 );
7698 }
7699 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7703 if verify_retried {
7704 state.event(
7705 "verify",
7706 format!(
7707 "{context}: verify could not build/link, not a test result — retrying once \
7708 before concluding"
7709 ),
7710 );
7711 let retried = run_commands(
7712 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7713 )
7714 .await;
7715 e2e = retried.0;
7716 timed_out_pids.extend(retried.1);
7719 for o in &e2e {
7720 state.event(
7721 "verify",
7722 format!(
7723 "{context}: retry `{}` -> {}",
7724 o.command,
7725 e2e_outcome_label(o)
7726 ),
7727 );
7728 }
7729 }
7730 (e2e, verify_retried, timed_out_pids)
7731}
7732
7733#[allow(clippy::too_many_arguments)]
7749async fn run_commands(
7750 state: &mut RunState,
7751 node: &str,
7752 task: &str,
7753 attempt: usize,
7754 shell: &[String],
7755 commands: &[String],
7756 cwd: &Path,
7757 timeout: Duration,
7758) -> (Vec<CommandOutcome>, Vec<u32>) {
7759 if commands.is_empty() {
7760 return (Vec::new(), Vec::new());
7765 }
7766 let mut out = Vec::new();
7767 let mut timed_out_pids = Vec::new();
7768 let total = commands.len();
7769 for (idx, command) in commands.iter().enumerate() {
7770 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
7771 if let Err(e) = state.save() {
7772 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
7773 }
7774 let started = Instant::now();
7775 let mut cmd = tokio::process::Command::new(&shell[0]);
7776 cmd.quiet();
7777 cmd.args(&shell[1..])
7778 .arg(command)
7779 .current_dir(cwd)
7780 .stdin(std::process::Stdio::null())
7781 .stdout(std::process::Stdio::piped())
7782 .stderr(std::process::Stdio::piped())
7783 .kill_on_drop(true);
7784 let spawned = cmd.spawn();
7785 let (code, body) = match spawned {
7786 Ok(child) => {
7787 let pid = child.id();
7792 match tokio::time::timeout(timeout, child.wait_with_output()).await {
7793 Ok(Ok(o)) => {
7794 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
7795 body.push_str(&String::from_utf8_lossy(&o.stderr));
7796 (o.status.code(), body)
7797 }
7798 Ok(Err(e)) => (None, format!("failed to run: {e}")),
7799 Err(_) => {
7800 if let Some(pid) = pid {
7801 timed_out_pids.push(pid);
7802 }
7803 (None, format!("timed out after {}s", timeout.as_secs()))
7804 }
7805 }
7806 }
7807 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
7808 };
7809 out.push(CommandOutcome {
7810 command: command.clone(),
7811 code,
7812 output_tail: tail(&body, OUTPUT_TAIL),
7813 duration_ms: started.elapsed().as_millis() as u64,
7814 resource_blocked: false,
7815 });
7816 }
7817 state.task_finished(task);
7818 if let Err(e) = state.save() {
7819 tracing::warn!("could not persist the end of {task}: {e:#}");
7820 }
7821 (out, timed_out_pids)
7822}
7823
7824fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
7836 let repo = repo.display();
7837 match style {
7838 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
7839 MergeStyle::Squash => {
7840 let subject = message
7843 .lines()
7844 .next()
7845 .unwrap_or(branch)
7846 .replace(['\\', '"', '$', '`'], "");
7847 format!(
7848 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
7849 )
7850 }
7851 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
7852 }
7853}
7854
7855const PR_TITLE_MAX: usize = 240;
7866
7867fn english_title(text: &str, max: usize) -> Option<String> {
7878 let line = queue::first_line(text)?;
7879 let chars: Vec<char> = line.chars().collect();
7880 let (kept, mark) = if chars.len() <= max {
7881 (line.to_owned(), "")
7882 } else if let Some(end) = sentence_end(&chars, max) {
7883 (chars[..end].iter().collect::<String>(), "")
7884 } else {
7885 let room = max.saturating_sub(3);
7886 let cut = if chars[room].is_whitespace() {
7889 room
7890 } else {
7891 chars[..room]
7892 .iter()
7893 .rposition(|c| c.is_whitespace())
7894 .unwrap_or(room)
7895 };
7896 let head: String = chars[..cut].iter().collect();
7897 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
7898 (head.to_owned(), "...")
7899 };
7900 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
7901 return None;
7902 }
7903 Some(format!("{kept}{mark}"))
7904}
7905
7906fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
7910 const MIN: usize = 20;
7911 for i in MIN..max.min(chars.len()) {
7912 if !matches!(chars[i], '.' | '!' | '?') {
7913 continue;
7914 }
7915 let Some(&next) = chars.get(i + 1) else {
7916 continue;
7917 };
7918 if !next.is_whitespace() {
7919 continue;
7920 }
7921 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
7922 if after.is_some_and(|c| c.is_ascii_lowercase()) {
7923 continue;
7924 }
7925 let word: String = chars[..i]
7926 .iter()
7927 .rev()
7928 .take_while(|c| !c.is_whitespace())
7929 .collect::<Vec<_>>()
7930 .into_iter()
7931 .rev()
7932 .collect();
7933 let word = word.to_ascii_lowercase();
7934 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
7935 continue;
7936 }
7937 let end = chars[..i]
7938 .iter()
7939 .rposition(|c| !c.is_whitespace())
7940 .map_or(i, |p| p + 1);
7941 return Some(end);
7942 }
7943 None
7944}
7945
7946struct PrMessage {
7951 title: String,
7952 body: String,
7953}
7954
7955impl PrMessage {
7956 fn commit_message(&self) -> String {
7960 format!("{}\n\n{}", self.title, self.body)
7961 }
7962}
7963
7964fn title_marker(line: &str) -> Option<&str> {
7966 let line = line.trim();
7967 let head = line.get(..6)?;
7968 head.eq_ignore_ascii_case("title:")
7969 .then(|| line[6..].trim())
7970}
7971
7972fn summary_title(summary: &str) -> Option<String> {
7977 let first = summary.lines().find(|l| !l.trim().is_empty())?;
7978 let raw = title_marker(first)?;
7979 if raw.is_empty() {
7980 return None;
7981 }
7982 let title = queue::title_from(raw, PR_TITLE_MAX);
7983 let lower = title.to_ascii_lowercase();
7984 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7985 return None;
7986 }
7987 Some(title)
7988}
7989
7990const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
7992
7993const EXISTING_BRANCH: &str = "(existing branch)";
7995
7996fn is_review_run(state: &RunState) -> bool {
8000 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8001}
8002
8003fn review_title(state: &RunState) -> Option<String> {
8008 english_subject(state.reviewed_commits.as_ref()?.first()?)
8009}
8010
8011fn english_subject(raw: &str) -> Option<String> {
8014 let raw = raw.trim();
8015 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8016 return None;
8017 }
8018 let title = queue::title_from(raw, PR_TITLE_MAX);
8019 let lower = title.to_ascii_lowercase();
8020 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8021 return None;
8022 }
8023 Some(title)
8024}
8025
8026#[derive(Debug, Clone, PartialEq, Eq)]
8029struct BranchFacts {
8030 commits: Vec<(String, String)>,
8032 stat: String,
8034}
8035
8036const STAT_FILE_LINES: usize = 25;
8038const COMMITS_MAX_CHARS: usize = 20_000;
8040
8041async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8044 let commits = git::commit_log(repo, base, branch).await.ok()?;
8045 if commits.is_empty() {
8046 return None;
8047 }
8048 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8049 let lines: Vec<&str> = stat.lines().collect();
8050 let stat = if lines.len() > STAT_FILE_LINES + 1 {
8051 let omitted = lines.len() - 1 - STAT_FILE_LINES;
8052 let more = format!(" ... {omitted} more file(s)");
8053 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8054 kept.push(&more);
8055 kept.push(lines[lines.len() - 1]);
8056 kept.join("\n")
8057 } else {
8058 lines.join("\n")
8059 };
8060 Some(BranchFacts { commits, stat })
8061}
8062
8063fn markdown_safe(text: &str) -> String {
8066 text.replace("</details>", "</details>")
8067 .replace("\x60\x60\x60", "~~~")
8068}
8069
8070fn neutral_title(state: &RunState, winner: char) -> String {
8071 format!(
8072 "chore: land candidate {} of run {}",
8073 winner.to_ascii_uppercase(),
8074 state.id
8075 )
8076}
8077
8078pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8084 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8085 ""
8086 } else {
8087 pr_title
8088 }
8089}
8090
8091pub fn landing_subject_source(state: &RunState) -> String {
8095 if is_review_run(state) {
8096 let winner = state.candidates.first().map_or('A', |c| c.label);
8097 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8098 }
8099 state.instruction.clone()
8100}
8101
8102fn summary_without_title(summary: &str) -> String {
8105 let mut lines = summary.trim().lines().peekable();
8106 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8107 lines.next();
8108 }
8109 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8110}
8111
8112#[cfg(test)]
8126fn pr_message(state: &RunState, winner: char) -> PrMessage {
8127 pr_message_with(state, winner, None)
8128}
8129
8130fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8133 let summary = state
8134 .candidates
8135 .iter()
8136 .find(|c| c.label == winner)
8137 .map(|c| c.summary.as_str())
8138 .unwrap_or_default();
8139 let review = is_review_run(state);
8142 let title = if review {
8143 facts
8144 .and_then(|f| english_subject(&f.commits.first()?.0))
8145 .or_else(|| review_title(state))
8146 .or_else(|| {
8147 state
8148 .candidates
8149 .iter()
8150 .find(|c| c.label == winner)
8151 .filter(|c| !c.branch.starts_with("magi/"))
8152 .and_then(|c| english_subject(&c.branch))
8153 })
8154 .unwrap_or_else(|| neutral_title(state, winner))
8155 } else {
8156 summary_title(summary).unwrap_or_else(|| {
8157 english_title(&state.instruction, PR_TITLE_MAX)
8158 .unwrap_or_else(|| neutral_title(state, winner))
8159 })
8160 };
8161
8162 let mut body = String::new();
8163 let what = summary_without_title(summary);
8164 if !what.is_empty() {
8165 body.push_str("## Summary\n\n");
8166 body.push_str(&what);
8167 body.push_str("\n\n");
8168 }
8169
8170 let notes: Vec<(usize, &str)> = state
8173 .reviews
8174 .iter()
8175 .filter_map(|r| {
8176 let n = r.fix.as_ref()?.notes.trim();
8177 (!n.is_empty()).then_some((r.round, n))
8178 })
8179 .collect();
8180 if !notes.is_empty() {
8181 body.push_str("## Review fixes\n\n");
8182 if let [(_, only)] = notes.as_slice() {
8183 body.push_str(only);
8184 body.push_str("\n\n");
8185 } else {
8186 for (round, n) in ¬es {
8187 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8188 }
8189 }
8190 }
8191 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8192
8193 let open = state.open_findings();
8194 if !open.is_empty() {
8195 body.push_str("## Open review findings\n\n");
8196 for f in &open {
8197 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8198 }
8199 body.push('\n');
8200 }
8201
8202 if let Some(fix) = fix
8203 && !fix.rejected.is_empty()
8204 {
8205 body.push_str("## Declined by the fixer\n\n");
8206 for r in &fix.rejected {
8207 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8208 }
8209 body.push('\n');
8210 }
8211
8212 if review {
8213 body.push_str("## Commits under review\n\n");
8215 if let Some(facts) = facts {
8216 let mut left = COMMITS_MAX_CHARS;
8217 for (i, (subject, text)) in facts.commits.iter().enumerate() {
8218 let mut entry = format!("- {}\n", markdown_safe(subject));
8219 for l in markdown_safe(text).lines() {
8220 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
8221 }
8222 if left == 0 {
8223 body.push_str(&format!(
8224 "- ... {} more commit(s)\n",
8225 facts.commits.len() - i
8226 ));
8227 break;
8228 }
8229 if entry.len() > left {
8230 let mut end = left;
8233 while !entry.is_char_boundary(end) {
8234 end -= 1;
8235 }
8236 entry.truncate(end);
8237 entry.push_str("\n ... (truncated)\n");
8238 left = 0;
8239 } else {
8240 left -= entry.len();
8241 }
8242 body.push_str(&entry);
8243 }
8244 if !facts.stat.trim().is_empty() {
8245 body.push_str(&format!(
8246 "\n## Diff stat\n\n```\n{}\n```\n",
8247 markdown_safe(facts.stat.trim())
8248 ));
8249 }
8250 } else {
8251 match &state.reviewed_commits {
8252 Some(subjects) => {
8253 for s in subjects {
8254 body.push_str(&format!("- {}\n", s.trim()));
8255 }
8256 }
8257 None => {
8258 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8261 body.push_str(rest.trim());
8262 body.push('\n');
8263 }
8264 }
8265 }
8266 } else {
8267 let task = state.instruction.trim();
8268 let task = if task.is_empty() {
8269 "(empty task)"
8270 } else {
8271 task
8272 };
8273 body.push_str(&format!(
8274 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8275 task.replace("</details>", "</details>")
8276 ));
8277 }
8278
8279 body.push_str(&format!(
8280 "\n---\nmagi:run/{} magi:candidate-{}\n",
8281 state.id,
8282 winner.to_ascii_lowercase()
8283 ));
8284
8285 let id = crate::scrub::Identity::current();
8288 PrMessage {
8289 title: crate::scrub::scrub(&title, &id),
8290 body: crate::scrub::scrub(&body, &id),
8291 }
8292}
8293
8294fn seeded_instruction(state: &RunState) -> String {
8298 match refs::describe(&state.seeds) {
8299 Some(facts) => format!(
8300 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8301 Candidates start from the unmerged branch named above, when there \
8302 is one, and carry any unmerged commit named by sha as a \
8303 cherry-pick. Check that this is what the task meant before \
8304 building on it.",
8305 state.instruction
8306 ),
8307 None => state.instruction.clone(),
8308 }
8309}
8310
8311async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8315 let base = &state.base_branch;
8316 let mut against = base.clone();
8317 if mode == MergeMode::Pr {
8318 let remote = &state.config.merge.remote;
8322 let tracking = format!("{remote}/{base}");
8323 let fetched = git::fetch(repo, remote, base).await;
8324 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8325 against = tracking;
8326 } else {
8327 return false;
8328 }
8329 }
8330 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8331}
8332
8333fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8336 let mut detail = format!(
8337 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8338 nothing to open a pull request for"
8339 );
8340 match refs::describe(&state.seeds) {
8341 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8342 None => detail.push_str(
8343 "\nThe task names no existing branch or commit; if it means to land work \
8344 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8345 ),
8346 }
8347 detail
8348}
8349
8350#[derive(Debug, PartialEq, Eq)]
8353enum PrPlan {
8354 Create,
8355 Adopt { url: String, title: String },
8356 Stop(String),
8357}
8358
8359fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8362 match found {
8363 Ok(land::OpenPr::None) => PrPlan::Create,
8364 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8365 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8366 "several open pull requests exist for this branch, not picking one: {}",
8367 urls.join(" ")
8368 )),
8369 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8370 }
8371}
8372
8373async fn gh_pr_create(
8375 cwd: &Path,
8376 base: &str,
8377 head: &str,
8378 title: &str,
8379 body: &str,
8380) -> Result<String> {
8381 let out = tokio::process::Command::new("gh")
8382 .args([
8383 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8384 ])
8385 .current_dir(cwd)
8386 .quiet()
8387 .stdin(std::process::Stdio::null())
8388 .output()
8389 .await
8390 .context("spawn gh")?;
8391 if out.status.success() {
8392 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8393 } else {
8394 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8395 }
8396}
8397
8398pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8407 let repo = state.repo.clone();
8408 let root = state.worktree_root();
8409 let winner = state.tally.as_ref().map(|t| t.winner);
8410 let mut removed = Vec::new();
8411
8412 for i in 0..state.candidates.len() {
8413 let c = state.candidates[i].clone();
8414 let is_winner = Some(c.label) == winner;
8415 if is_winner && !drop_winner {
8416 continue;
8417 }
8418 if c.worktree.exists() {
8419 git::worktree_remove(&repo, &c.worktree).await.ok();
8420 removed.push(c.worktree.to_string_lossy().into_owned());
8421 }
8422 let handed_over = state.released_branches.contains(&c.branch);
8425 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8426 git::branch_delete(&repo, &c.branch).await.ok();
8427 removed.push(c.branch.clone());
8428 }
8429 state.candidates[i].folded = true;
8430 }
8431
8432 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8433 let path = name.path();
8434 let keep = !drop_winner
8435 && winner.is_some_and(|w| {
8436 path.file_name()
8437 .is_some_and(|n| n == format!("cand-{w}").as_str())
8438 });
8439 if keep {
8440 continue;
8441 }
8442 git::worktree_remove(&repo, &path).await.ok();
8443 removed.push(path.to_string_lossy().into_owned());
8444 }
8445
8446 remove_if_empty(&root);
8455
8456 if state.enabled_worktree_config && drop_winner {
8457 git::release_worktree_config(&repo).await.ok();
8461 state.enabled_worktree_config = false;
8462 }
8463 state.save_under(home)?;
8464 Ok(removed)
8465}
8466
8467fn remove_if_empty(dir: &Path) {
8478 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8479 std::fs::remove_dir(dir).ok();
8480 }
8481}
8482
8483pub fn worst_open(state: &RunState) -> Option<Severity> {
8485 state
8486 .reviews
8487 .last()?
8488 .reviews
8489 .iter()
8490 .flat_map(|r| r.findings.iter())
8491 .map(|f| f.severity)
8492 .max()
8493}
8494
8495#[cfg(test)]
8496mod tests {
8497 #[test]
8498 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8499 let leaked = vec!["chore(deps): update a crate".to_owned()];
8500 let own = "fix(daemon): apply a chosen action";
8501 assert!(should_retitle("", own, &leaked));
8502 assert!(should_retitle(
8503 &format!("{REVIEW_PROMPT_OPENING} `x`"),
8504 own,
8505 &leaked
8506 ));
8507 assert!(should_retitle(
8508 "chore: land candidate A of run 1",
8509 own,
8510 &leaked
8511 ));
8512 assert!(should_retitle(
8513 "magi: candidate A (uncommitted work)",
8514 own,
8515 &leaked
8516 ));
8517 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
8518 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
8519 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
8520 }
8521
8522 #[test]
8523 fn pr_merge_plan_creates_adopts_or_stops() {
8524 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
8525 assert_eq!(
8526 pr_merge_plan(Ok(land::OpenPr::One {
8527 url: "u".into(),
8528 title: "t".into()
8529 })),
8530 PrPlan::Adopt {
8531 url: "u".into(),
8532 title: "t".into()
8533 }
8534 );
8535 let PrPlan::Stop(many) =
8536 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
8537 else {
8538 panic!("many must stop");
8539 };
8540 assert!(many.contains('a') && many.contains('b'));
8541 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
8542 panic!("a failed lookup must stop");
8543 };
8544 assert!(err.contains("bad token"));
8545 }
8546
8547 use super::*;
8548 use crate::run::GateStatus;
8549 use std::collections::BTreeMap;
8550 use std::time::Duration;
8551
8552 fn conductor() -> AgentSpec {
8553 AgentSpec {
8554 id: "conductor".to_owned(),
8555 kind: crate::config::AgentKind::Command,
8556 model: None,
8557 command: vec!["true".to_owned()],
8558 extra_args: Vec::new(),
8559 env: BTreeMap::new(),
8560 prompt_delivery: None,
8561 }
8562 }
8563
8564 fn spec(id: &str) -> AgentSpec {
8565 AgentSpec {
8566 id: id.to_owned(),
8567 kind: crate::config::AgentKind::Command,
8568 model: None,
8569 command: vec!["true".to_owned()],
8570 extra_args: Vec::new(),
8571 env: BTreeMap::new(),
8572 prompt_delivery: None,
8573 }
8574 }
8575
8576 fn ids(xs: &[&str]) -> BTreeSet<String> {
8577 xs.iter().map(|s| (*s).to_owned()).collect()
8578 }
8579
8580 #[test]
8581 fn pick_successor_skips_an_agent_another_seat_holds() {
8582 let roster = [spec("a"), spec("b"), spec("c")];
8584 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8585 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8586 }
8587
8588 #[test]
8589 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
8590 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
8592 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8593 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8594 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
8595 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
8596 }
8597
8598 #[test]
8599 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
8600 let roster = [spec("a"), spec("b")];
8601 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8602 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8603 let carried = ids(&["b"]);
8604 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
8605 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8606 }
8607
8608 #[test]
8609 fn pick_successor_returns_none_without_any_untried_successor() {
8610 let roster = [spec("a"), spec("b")];
8611 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
8612 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
8613 let carried = ids(&["a"]);
8614 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
8615 }
8616
8617 #[test]
8618 fn pick_successor_rescue_avoids_another_seats_occupant() {
8619 let roster = [spec("a"), spec("b"), spec("c")];
8621 let carried = ids(&["b", "c"]);
8622 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
8623 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8624 }
8625
8626 #[test]
8627 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
8628 let roster = [spec("a"), spec("b"), spec("c")];
8629 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
8630 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8631 }
8632
8633 #[test]
8634 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
8635 let roster = [spec("a"), spec("b"), spec("c")];
8636 let failed = ids(&["a", "b", "c"]);
8637 let mut tried = ids(&["b"]);
8639 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8640 assert_eq!(first.id, "a");
8641 tried.insert(first.id.clone());
8642 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8643 assert_eq!(second.id, "c");
8644 tried.insert(second.id.clone());
8645 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
8646 }
8647
8648 #[test]
8649 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
8650 let roster = [spec("a"), spec("b")];
8651 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
8652 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8653 }
8654
8655 #[test]
8656 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
8657 let roster = [spec("a"), spec("b"), spec("c")];
8658 let h = SeatHistory {
8659 failed: ids(&["a"]),
8660 last_ok: Some("b".to_owned()),
8661 last_fail: None,
8662 };
8663 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
8664 let gone = SeatHistory {
8666 last_ok: Some("zzz".to_owned()),
8667 ..h.clone()
8668 };
8669 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
8670 let failed = SeatHistory {
8671 failed: ids(&["a", "b"]),
8672 last_ok: Some("b".to_owned()),
8673 last_fail: None,
8674 };
8675 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
8676 }
8677
8678 #[test]
8679 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
8680 let first = SeatState::new("review-1", "alpha", 7);
8681 let next = handover_seat("review-1", "gamma", 7);
8682 assert_ne!(first.claude_session, next.claude_session);
8683 }
8684
8685 #[test]
8686 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
8687 let mut state = state_with_summary("x", "y");
8688 let a = handover_seat("review-1", "beta", state.next_seat_seed());
8689 let b = handover_seat("review-1", "beta", state.next_seat_seed());
8690 assert_ne!(a.claude_session, b.claude_session);
8691 }
8692
8693 #[test]
8694 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
8695 let roster = [spec("a"), spec("b")];
8696 let h = SeatHistory {
8697 failed: ids(&["a", "b"]),
8698 ..SeatHistory::default()
8699 };
8700 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
8701 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
8702 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
8703 }
8704
8705 #[test]
8712 fn failure_signature_ignores_numbers_and_paths() {
8713 assert_eq!(
8714 failure_signature("exited with Some(2) and no usable output"),
8715 failure_signature("exited with Some(137) and no usable output")
8716 );
8717 assert_eq!(
8718 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
8719 failure_signature("cannot open /var/x.txt: denied")
8720 );
8721 assert_ne!(failure_signature("boom"), failure_signature("bang"));
8722 }
8723
8724 #[test]
8725 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
8726 let other = FailClass::Other("x".into());
8727 assert!(should_hand_over(None, &FailClass::Quota));
8728 assert!(should_hand_over(Some(&other), &FailClass::Quota));
8729 assert!(should_hand_over(
8730 Some(&FailClass::Timeout),
8731 &FailClass::Timeout
8732 ));
8733 assert!(should_hand_over(None, &other));
8734 assert!(!should_hand_over(Some(&other), &other));
8735 assert!(should_hand_over(
8736 Some(&other),
8737 &FailClass::Other("y".into())
8738 ));
8739 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
8741 assert!(should_hand_over(Some(&FailClass::Quota), &other));
8742 }
8743
8744 #[test]
8745 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
8746 let a = SeatState::new("judge-1", "alpha", 7);
8747 let b = handover_seat("judge-1", "beta", 7);
8748 assert_ne!(a.claude_session, b.claude_session);
8749 assert_eq!(b.turns, 0);
8750 }
8751
8752 #[test]
8753 fn a_timeout_is_classified_apart_from_other_failures() {
8754 assert_eq!(
8755 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
8756 Some(FailClass::Timeout)
8757 );
8758 assert!(matches!(
8759 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
8760 Some(FailClass::Other(_))
8761 ));
8762 }
8763
8764 #[test]
8765 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
8766 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8767 let tried = BTreeSet::from(["beta".to_owned()]);
8768 let next = next_untried_in_roster(&roster, 1, &tried);
8771 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
8772 }
8773
8774 #[test]
8775 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
8776 let roster = vec![spec("alpha"), spec("beta")];
8777 let tried = BTreeSet::from(["beta".to_owned()]);
8778 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8782 }
8783
8784 #[test]
8785 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
8786 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8787 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
8788 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8792 }
8793
8794 #[test]
8795 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
8796 let roster = vec![spec("a"), spec("a"), spec("b")];
8797 let tried = BTreeSet::from(["a".to_owned()]);
8798 let next = next_untried_in_roster(&roster, 0, &tried);
8799 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8800 }
8801
8802 #[test]
8803 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
8804 let roster = vec![spec("a"), spec("b")];
8805 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
8806 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
8807 }
8808
8809 #[test]
8810 fn remove_if_empty_only_ever_takes_a_bare_directory() {
8811 let dir = tempfile::tempdir().unwrap();
8812 let bay = dir.path().join("ffff");
8813
8814 remove_if_empty(&bay);
8816 assert!(!bay.exists());
8817
8818 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
8821 remove_if_empty(&bay);
8822 assert!(bay.exists(), "non-empty directory must survive");
8823
8824 std::fs::remove_dir(bay.join("cand-A")).unwrap();
8826 remove_if_empty(&bay);
8827 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
8828 }
8829
8830 #[test]
8839 fn a_full_panel_that_found_nothing_is_clean() {
8840 assert!(round_is_clean(
8841 0,
8842 true,
8843 2,
8844 2,
8845 0,
8846 IncompleteReviewPolicy::Block
8847 ));
8848 }
8849
8850 #[test]
8851 fn a_missing_seat_is_never_clean_under_the_default_policy() {
8852 assert!(!round_is_clean(
8853 0,
8854 true,
8855 1,
8856 2,
8857 0,
8858 IncompleteReviewPolicy::Block
8859 ));
8860 }
8861
8862 #[test]
8863 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
8864 assert!(!round_is_clean(
8865 1,
8866 true,
8867 1,
8868 2,
8869 0,
8870 IncompleteReviewPolicy::Warn
8871 ));
8872 }
8873
8874 #[test]
8875 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
8876 assert!(round_is_clean(
8877 0,
8878 true,
8879 1,
8880 2,
8881 0,
8882 IncompleteReviewPolicy::Warn
8883 ));
8884 }
8885
8886 #[test]
8887 fn a_full_panel_with_an_open_finding_is_not_clean() {
8888 assert!(!round_is_clean(
8889 1,
8890 true,
8891 2,
8892 2,
8893 0,
8894 IncompleteReviewPolicy::Block
8895 ));
8896 }
8897
8898 #[test]
8899 fn a_full_panel_with_a_red_e2e_is_not_clean() {
8900 assert!(!round_is_clean(
8901 0,
8902 false,
8903 2,
8904 2,
8905 0,
8906 IncompleteReviewPolicy::Block
8907 ));
8908 }
8909
8910 #[test]
8917 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
8918 assert!(round_is_clean(
8921 0,
8922 true,
8923 1,
8924 2,
8925 1,
8926 IncompleteReviewPolicy::Block
8927 ));
8928 }
8929
8930 #[test]
8931 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
8932 assert!(!round_is_clean(
8935 0,
8936 true,
8937 1,
8938 2,
8939 0,
8940 IncompleteReviewPolicy::Block
8941 ));
8942 }
8943
8944 #[test]
8945 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
8946 assert!(!round_is_clean(
8947 1,
8948 true,
8949 1,
8950 2,
8951 1,
8952 IncompleteReviewPolicy::Block
8953 ));
8954 assert!(!round_is_clean(
8955 0,
8956 false,
8957 1,
8958 2,
8959 1,
8960 IncompleteReviewPolicy::Block
8961 ));
8962 }
8963
8964 #[test]
8965 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
8966 assert!(!round_is_clean(
8970 0,
8971 true,
8972 0,
8973 2,
8974 2,
8975 IncompleteReviewPolicy::Block
8976 ));
8977 }
8978
8979 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
8980 CommandOutcome {
8981 command: "test".to_owned(),
8982 code,
8983 output_tail: String::new(),
8984 duration_ms: 0,
8985 resource_blocked,
8986 }
8987 }
8988
8989 #[test]
8990 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
8991 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
8992 assert!(
8993 !verify_inconclusive(&[outcome(Some(1), false)]),
8994 "an ordinary failure is still evidence about the patch"
8995 );
8996 assert!(verify_inconclusive(&[outcome(None, true)]));
8997 assert!(
8998 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
8999 "one inconclusive outcome taints the whole batch"
9000 );
9001 assert!(!verify_inconclusive(&[]));
9002 }
9003
9004 #[tokio::test]
9005 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9006 let calls = std::sync::atomic::AtomicUsize::new(0);
9010 let started = Instant::now();
9011 wait_for_pids_with(
9012 &[123],
9013 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9014 Duration::from_millis(5),
9015 Duration::from_secs(5),
9016 )
9017 .await;
9018 assert!(
9019 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9020 "must keep checking rather than deciding on the first answer"
9021 );
9022 assert!(
9023 started.elapsed() < Duration::from_secs(1),
9024 "must return the moment it is confirmed dead, not wait out the ceiling"
9025 );
9026 }
9027
9028 #[tokio::test]
9029 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9030 let started = Instant::now();
9031 wait_for_pids_with(
9032 &[123],
9033 |_| true, Duration::from_millis(5),
9035 Duration::from_millis(30),
9036 )
9037 .await;
9038 let elapsed = started.elapsed();
9039 assert!(
9040 elapsed >= Duration::from_millis(30),
9041 "must not give up before its own ceiling: {elapsed:?}"
9042 );
9043 assert!(
9044 elapsed < Duration::from_secs(1),
9045 "must not wait past its own ceiling either: {elapsed:?}"
9046 );
9047 }
9048
9049 #[tokio::test]
9050 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9051 let started = Instant::now();
9052 wait_for_pids_with(
9053 &[],
9054 |_| true,
9055 Duration::from_secs(5),
9056 Duration::from_secs(5),
9057 )
9058 .await;
9059 assert!(
9060 started.elapsed() < Duration::from_millis(200),
9061 "an empty pid list has nothing to confirm"
9062 );
9063 }
9064
9065 fn review_round(
9071 clean: bool,
9072 blocking: usize,
9073 answered: usize,
9074 expected: usize,
9075 progressed: bool,
9076 e2e_ok: bool,
9077 ) -> ReviewRound {
9078 ReviewRound {
9079 round: 1,
9080 head: "h".to_owned(),
9081 verified_head: None,
9082 verified_at: None,
9083 reviews: Vec::new(),
9084 e2e: vec![CommandOutcome {
9085 command: "test".to_owned(),
9086 code: Some(if e2e_ok { 0 } else { 1 }),
9087 output_tail: String::new(),
9088 duration_ms: 0,
9089 resource_blocked: false,
9090 }],
9091 verify_retried: false,
9092 e2e_deferred: false,
9093 e2e_defer_reason: None,
9094 fix: None,
9095 blocking,
9096 answered,
9097 expected,
9098 clean,
9099 progressed,
9100 vote_split: false,
9101 reconsideration: Vec::new(),
9102 verdict: None,
9103 }
9104 }
9105
9106 #[test]
9107 fn review_conclusion_is_none_when_nothing_has_run() {
9108 assert_eq!(review_conclusion(&[], 3), None);
9109 }
9110
9111 #[test]
9112 fn review_conclusion_is_none_while_rounds_remain() {
9113 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9114 assert_eq!(review_conclusion(&rounds, 3), None);
9115 }
9116
9117 #[test]
9118 fn review_conclusion_is_gating_once_a_round_is_clean() {
9119 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9120 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9121 }
9122
9123 #[test]
9124 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9125 let rounds = vec![
9126 review_round(false, 1, 2, 2, true, true),
9127 review_round(false, 1, 2, 2, true, true),
9128 ];
9129 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9130 }
9131
9132 #[test]
9133 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9134 let rounds = vec![
9135 review_round(false, 1, 2, 2, true, true),
9136 review_round(false, 1, 2, 2, true, false),
9137 ];
9138 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9139 }
9140
9141 #[test]
9142 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9143 let mut blocked = review_round(false, 1, 2, 2, true, false);
9150 blocked.e2e[0].resource_blocked = true;
9151 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9152 assert_eq!(review_conclusion(&rounds, 2), None);
9153 }
9154
9155 #[test]
9156 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9157 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9159 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9160 }
9161
9162 #[test]
9163 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9164 let rounds = vec![
9165 review_round(false, 1, 2, 2, false, true),
9166 review_round(false, 1, 2, 2, false, true),
9167 ];
9168 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9169 }
9170
9171 fn secs(n: u64) -> Duration {
9172 Duration::from_secs(n)
9173 }
9174
9175 fn init_repo(dir: &Path) {
9178 let run = |args: &[&str]| {
9179 let out = std::process::Command::new("git")
9180 .args(args)
9181 .current_dir(dir)
9182 .quiet()
9183 .output()
9184 .expect("spawn git");
9185 assert!(
9186 out.status.success(),
9187 "git {args:?} failed: {}",
9188 String::from_utf8_lossy(&out.stderr)
9189 );
9190 };
9191 run(&["init", "-b", "main"]);
9192 run(&["config", "user.name", "magi test"]);
9193 run(&["config", "user.email", "magi@example.com"]);
9194 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9195 run(&["add", "-A"]);
9196 run(&["commit", "-m", "init"]);
9197 }
9198
9199 fn ask_test_home() {
9207 crate::run::pin_test_home();
9208 }
9209
9210 fn runner_at(status: RunStatus) -> Runner {
9213 let mut state = RunState::new(
9214 PathBuf::from("/nonexistent/repo"),
9215 "main".to_owned(),
9216 "deadbeef".to_owned(),
9217 "task".to_owned(),
9218 Config::default(),
9219 );
9220 state.status = status;
9221 Runner {
9222 state,
9223 roles: ResolvedRoles {
9224 implementers: Vec::new(),
9225 judges: Vec::new(),
9226 reviewers: Vec::new(),
9227 fixer: None,
9228 conductor: conductor(),
9229 implementer_roster: Vec::new(),
9230 judge_roster: Vec::new(),
9231 reviewer_roster: Vec::new(),
9232 },
9233 sem: Arc::new(Semaphore::new(1)),
9234 pause: Pause::new(),
9235 interrupt: Pause::new(),
9236 }
9237 }
9238
9239 #[test]
9243 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9244 crate::run::pin_test_home();
9245 let mut runner = runner_at(RunStatus::Implementing);
9246 let interrupt = Pause::new();
9247 runner.watch_interrupt(interrupt.clone());
9248
9249 interrupt.park_because("task a1b2 asked to run first");
9250
9251 assert!(runner.park_here().expect("park_here"));
9252 assert!(runner.state.parked);
9253 let last = runner.state.events.last().expect("a park event");
9254 assert_eq!(last.node, "park");
9255 assert!(
9256 last.message.contains("task a1b2 asked to run first"),
9257 "expected the interrupt reason in {:?}",
9258 last.message
9259 );
9260 }
9261
9262 #[test]
9270 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9271 crate::run::pin_test_home();
9272 let mut runner = runner_at(RunStatus::Implementing);
9273 let shutdown = Pause::new();
9274 runner.on_pause(shutdown.clone());
9275 let interrupt = Pause::new();
9276 runner.watch_interrupt(interrupt.clone());
9277
9278 assert!(!runner.park_here().expect("park_here"));
9280 assert!(!runner.state.parked);
9281
9282 interrupt.park_because("test");
9284 assert!(!shutdown.parked());
9285 assert!(runner.park_here().expect("park_here"));
9286 }
9287
9288 #[tokio::test]
9302 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9303 crate::run::pin_test_home();
9304 let mut runner = runner_at(RunStatus::Implementing);
9305 let interrupt = Pause::new();
9306 runner.watch_interrupt(interrupt.clone());
9307
9308 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9309 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9310
9311 let node = async move {
9315 started_tx.send(()).expect("send started");
9316 finish_rx.await.expect("recv finish");
9317 "node finished"
9318 };
9319
9320 let interrupter = async move {
9321 started_rx.await.expect("recv started");
9322 interrupt.park_because("higher-priority task waiting");
9324 tokio::task::yield_now().await;
9328 finish_tx.send(()).expect("send finish");
9329 };
9330
9331 let (node_result, ()) = tokio::join!(node, interrupter);
9332 assert_eq!(
9333 node_result, "node finished",
9334 "the in-flight call ran to completion"
9335 );
9336
9337 assert!(runner.park_here().expect("park_here"));
9340 assert!(runner.state.parked);
9341 }
9342
9343 #[test]
9349 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9350 crate::run::pin_test_home();
9351 let mut runner = runner_at(RunStatus::Judging);
9352 runner.state.config.agents = vec![conductor()];
9356 runner.state.candidates = vec![Candidate {
9357 index: 0,
9358 label: 'A',
9359 agent: "alpha".to_owned(),
9360 branch: "magi/x/A".to_owned(),
9361 worktree: PathBuf::from("/nonexistent/worktree"),
9362 summary: "did the thing".to_owned(),
9363 stat: "1 file changed".to_owned(),
9364 files: 1,
9365 commits: 1,
9366 empty: false,
9367 failed: None,
9368 verified_noop: None,
9369 duration_ms: 1234,
9370 folded: false,
9371 }];
9372 let run_id = runner.state.id.clone();
9373
9374 let interrupt = Pause::new();
9375 runner.watch_interrupt(interrupt.clone());
9376 interrupt.park_because("task c3d4 asked to run first");
9377 assert!(runner.park_here().expect("park_here"));
9378
9379 let resumed = Runner::resume(&run_id).expect("resume");
9380 assert_eq!(resumed.state.candidates.len(), 1);
9381 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9382 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9383 assert_eq!(resumed.state.status, runner.state.status);
9384 assert!(
9385 resumed.state.parked,
9386 "still parked until `execute` actually walks the graph again"
9387 );
9388 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9389 }
9390
9391 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9393 let mut q = ask::Question::new(
9394 run.to_owned(),
9395 "implement".to_owned(),
9396 "impl-A".to_owned(),
9397 "Which storage backend should the cache use?".to_owned(),
9398 String::new(),
9399 vec!["SQLite".to_owned(), "Redis".to_owned()],
9400 );
9401 store.put(&mut q).unwrap();
9402 q
9403 }
9404
9405 #[test]
9406 fn a_failed_runs_open_question_is_abandoned() {
9407 ask_test_home();
9408 let store = ask::Questions::open();
9409 let mut runner = runner_at(RunStatus::Failed);
9410 let run = runner.state.id.clone();
9411 let q = ask_open_question(&store, &run);
9412
9413 runner.settle_questions();
9414
9415 let back = store.get(&q.id).unwrap();
9416 assert!(
9417 !back.status.open(),
9418 "the seat that asked died with the run; nobody is left to read an answer"
9419 );
9420 assert!(
9421 back.detail.contains(&run) && back.detail.contains("failed"),
9422 "the reason names what the run became, not just that it is gone: {}",
9423 back.detail
9424 );
9425 }
9426
9427 #[test]
9428 fn a_merged_runs_open_question_is_abandoned_too() {
9429 ask_test_home();
9430 let store = ask::Questions::open();
9431 for status in [RunStatus::Merged, RunStatus::Ready] {
9434 let mut runner = runner_at(status);
9435 let run = runner.state.id.clone();
9436 let q = ask_open_question(&store, &run);
9437
9438 runner.settle_questions();
9439
9440 let back = store.get(&q.id).unwrap();
9441 assert!(
9442 !back.status.open(),
9443 "{status:?} run's question must not outlive the run"
9444 );
9445 }
9446 }
9447
9448 #[test]
9449 fn a_still_resumable_runs_open_question_is_left_alone() {
9450 ask_test_home();
9451 let store = ask::Questions::open();
9452 for status in [RunStatus::Blocked, RunStatus::Stalled] {
9458 let mut runner = runner_at(status);
9459 let run = runner.state.id.clone();
9460 let q = ask_open_question(&store, &run);
9461
9462 runner.settle_questions();
9463
9464 let back = store.get(&q.id).unwrap();
9465 assert!(
9466 back.status.open(),
9467 "{status:?} is still alive; the question must still be waiting"
9468 );
9469 }
9470 }
9471
9472 #[test]
9473 fn settle_questions_never_touches_an_already_answered_question() {
9474 ask_test_home();
9475 let store = ask::Questions::open();
9476 let mut runner = runner_at(RunStatus::Failed);
9477 let run = runner.state.id.clone();
9478 let mut q = ask_open_question(&store, &run);
9479 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9480 .unwrap();
9481 store.put(&mut q).unwrap();
9482
9483 runner.settle_questions();
9488 runner.settle_questions();
9489
9490 let back = store.get(&q.id).unwrap();
9491 assert_eq!(
9492 back.status,
9493 ask::QuestionStatus::Answered,
9494 "a real answer is a decision on record, never overwritten by a sweep"
9495 );
9496 }
9497
9498 #[tokio::test]
9509 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
9510 crate::run::pin_test_home();
9511 let tmp = tempfile::tempdir().expect("tempdir");
9512 let repo = tmp.path().join("repo");
9513 std::fs::create_dir_all(&repo).unwrap();
9514 init_repo(&repo);
9515
9516 let mut config = Config::default();
9517 config.graph.worktree_root = Some(tmp.path().join("wt"));
9518
9519 let mut state = RunState::new(
9520 repo.clone(),
9521 "main".to_owned(),
9522 "deadbeef".to_owned(),
9523 "task".to_owned(),
9524 config,
9525 );
9526 let root = state.worktree_root();
9527 let wt_a = root.join("cand-A");
9528 let wt_b = root.join("cand-B");
9529 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
9530 .await
9531 .expect("worktree A");
9532 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
9533 .await
9534 .expect("worktree B");
9535
9536 state.candidates = vec![
9537 Candidate {
9538 index: 0,
9539 label: 'A',
9540 agent: "alpha".to_owned(),
9541 branch: "magi/x/A".to_owned(),
9542 worktree: wt_a.clone(),
9543 summary: String::new(),
9544 stat: String::new(),
9545 files: 0,
9546 commits: 0,
9547 empty: false,
9548 failed: None,
9549 verified_noop: None,
9550 duration_ms: 0,
9551 folded: false,
9552 },
9553 Candidate {
9554 index: 1,
9555 label: 'B',
9556 agent: "beta".to_owned(),
9557 branch: "magi/x/B".to_owned(),
9558 worktree: wt_b.clone(),
9559 summary: String::new(),
9560 stat: String::new(),
9561 files: 0,
9562 commits: 0,
9563 empty: false,
9564 failed: None,
9565 verified_noop: None,
9566 duration_ms: 0,
9567 folded: false,
9568 },
9569 ];
9570 state.tally = Some(Tally {
9571 first_choice: BTreeMap::from([('A', 1)]),
9572 borda: BTreeMap::new(),
9573 winner: 'A',
9574 rankings: 1,
9575 unanimous_initial: true,
9576 deliberated: false,
9577 changed_votes: 0,
9578 unanimous_final: true,
9579 tie_break: None,
9580 judges: 1,
9581 present: 1,
9582 quorum: 1,
9583 met_quorum: true,
9584 uncontested: None,
9585 });
9586 state.status = RunStatus::Ready;
9587
9588 fold_run(&mut state, false, &crate::run::home())
9589 .await
9590 .expect("fold_run");
9591
9592 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
9593 assert!(
9594 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9595 "the unmerged winner's branch survives"
9596 );
9597 assert!(
9598 !state.candidates[0].folded,
9599 "the winner is not marked folded"
9600 );
9601
9602 assert!(!wt_b.exists(), "the loser's worktree is removed");
9603 assert!(
9604 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
9605 "the loser's branch is removed"
9606 );
9607 assert!(state.candidates[1].folded, "the loser is marked folded");
9608 }
9609
9610 #[tokio::test]
9613 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
9614 let tmp = tempfile::tempdir().expect("tempdir");
9615 let repo = tmp.path().join("repo");
9616 std::fs::create_dir_all(&repo).unwrap();
9617 init_repo(&repo);
9618 let home = tmp.path().join("home");
9619
9620 let mut config = Config::default();
9621 config.graph.worktree_root = Some(tmp.path().join("wt"));
9622 let mut state = RunState::new(
9623 repo.clone(),
9624 "main".to_owned(),
9625 "deadbeef".to_owned(),
9626 "task".to_owned(),
9627 config,
9628 );
9629 git::git(&repo, &["branch", "magi/x/A", "main"])
9631 .await
9632 .expect("branch");
9633 state.candidates = vec![Candidate {
9634 index: 0,
9635 label: 'A',
9636 agent: "alpha".to_owned(),
9637 branch: "magi/x/A".to_owned(),
9638 worktree: state.worktree_root().join("cand-A"),
9639 summary: String::new(),
9640 stat: String::new(),
9641 files: 0,
9642 commits: 0,
9643 empty: false,
9644 failed: None,
9645 verified_noop: None,
9646 duration_ms: 0,
9647 folded: true,
9648 }];
9649 state.released_to = Some("20260901-000000-new1".to_owned());
9650 state.released_branches = vec!["magi/x/A".to_owned()];
9651
9652 fold_run(&mut state, true, &home).await.expect("fold_run");
9653
9654 assert!(
9655 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9656 "the handed-over branch survives a fold"
9657 );
9658 }
9659
9660 #[tokio::test]
9664 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
9665 let tmp = tempfile::tempdir().expect("tempdir");
9666 let repo = tmp.path().join("repo");
9667 std::fs::create_dir_all(&repo).unwrap();
9668 init_repo(&repo);
9669 let run = |args: &[&str]| {
9670 let out = std::process::Command::new("git")
9671 .quiet()
9672 .args(args)
9673 .current_dir(&repo)
9674 .output()
9675 .expect("spawn git");
9676 assert!(out.status.success(), "git {args:?}");
9677 };
9678 run(&["branch", "magi/x/A"]);
9679 run(&["checkout", "-q", "-b", "magi/x/B"]);
9680 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
9681 run(&["add", "-A"]);
9682 run(&["commit", "-q", "-m", "work"]);
9683 run(&["checkout", "-q", "main"]);
9684
9685 let probe = RunState::new(
9689 repo.clone(),
9690 "main".to_owned(),
9691 "deadbeef".to_owned(),
9692 "task".to_owned(),
9693 Config::default(),
9694 );
9695 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
9696 let bare = tmp.path().join("origin.git");
9697 let out = std::process::Command::new("git")
9698 .quiet()
9699 .args(["init", "-q", "--bare"])
9700 .arg(&bare)
9701 .output()
9702 .expect("spawn git");
9703 assert!(out.status.success(), "git init --bare");
9704 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
9705 run(&["push", "-q", "origin", "main"]);
9706
9707 let mut state = RunState::new(
9708 repo.clone(),
9709 "main".to_owned(),
9710 "deadbeef".to_owned(),
9711 "task".to_owned(),
9712 Config::default(),
9713 );
9714 state.seeds = vec![refs::Seed {
9715 token: "magi/27b2/A".to_owned(),
9716 kind: refs::SeedKind::Unresolved,
9717 sha: String::new(),
9718 branch: true,
9719 detail: "no branch or commit named magi/27b2/A".to_owned(),
9720 }];
9721
9722 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
9723 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
9724 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
9725 let detail = empty_candidate_detail(&state, "main");
9726 assert!(detail.starts_with("empty candidate"), "{detail}");
9727 assert!(detail.contains("magi/27b2/A"), "{detail}");
9728 }
9729
9730 #[tokio::test]
9739 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
9740 let tmp = tempfile::tempdir().expect("tempdir");
9741 let repo = tmp.path().join("repo");
9742 std::fs::create_dir_all(&repo).unwrap();
9743 init_repo(&repo);
9744
9745 let mut config = Config::default();
9746 config.merge.mode = MergeMode::Local;
9747
9748 let mut state = RunState::new(
9749 repo.clone(),
9750 "main".to_owned(),
9751 "deadbeef".to_owned(),
9752 "task".to_owned(),
9753 config,
9754 );
9755 state.candidates = vec![Candidate {
9756 index: 0,
9757 label: 'A',
9758 agent: "alpha".to_owned(),
9759 branch: "does-not-exist".to_owned(),
9760 worktree: repo.clone(),
9761 summary: String::new(),
9762 stat: String::new(),
9763 files: 0,
9764 commits: 0,
9765 empty: false,
9766 failed: None,
9767 verified_noop: None,
9768 duration_ms: 0,
9769 folded: false,
9770 }];
9771 state.tally = Some(Tally {
9772 first_choice: BTreeMap::from([('A', 1)]),
9773 borda: BTreeMap::new(),
9774 winner: 'A',
9775 rankings: 1,
9776 unanimous_initial: true,
9777 deliberated: false,
9778 changed_votes: 0,
9779 unanimous_final: true,
9780 tie_break: None,
9781 judges: 0,
9782 present: 0,
9783 quorum: 0,
9784 met_quorum: true,
9785 uncontested: Some("only candidate A produced a change".to_owned()),
9786 });
9787 state.reviews = vec![ReviewRound {
9788 round: 1,
9789 head: "deadbeef".to_owned(),
9790 verified_head: None,
9791 verified_at: None,
9792 reviews: Vec::new(),
9793 e2e: Vec::new(),
9794 fix: None,
9795 blocking: 0,
9796 answered: 0,
9797 expected: 0,
9798 clean: true,
9799 verify_retried: false,
9800 e2e_deferred: false,
9801 e2e_defer_reason: None,
9802 progressed: false,
9803 vote_split: false,
9804 reconsideration: Vec::new(),
9805 verdict: None,
9806 }];
9807 state.gate = vec![CommandOutcome {
9808 command: "test".to_owned(),
9809 code: Some(0),
9810 output_tail: String::new(),
9811 duration_ms: 0,
9812 resource_blocked: false,
9813 }];
9814 state.gate_ran = true;
9815 state.status = RunStatus::Ready;
9820 state.merge = Some(MergeOutcome {
9821 mode: MergeMode::Local,
9822 ok: false,
9823 detail: "already concluded".to_owned(),
9824 empty: false,
9825 });
9826
9827 let mut runner = Runner {
9828 state,
9829 roles: ResolvedRoles {
9830 implementers: Vec::new(),
9831 judges: Vec::new(),
9832 reviewers: Vec::new(),
9833 fixer: None,
9834 conductor: conductor(),
9835 implementer_roster: Vec::new(),
9836 judge_roster: Vec::new(),
9837 reviewer_roster: Vec::new(),
9838 },
9839 sem: Arc::new(Semaphore::new(1)),
9840 pause: Pause::new(),
9841 interrupt: Pause::new(),
9842 };
9843
9844 runner.merge().await.expect("merge");
9845
9846 assert_eq!(
9847 runner.state.status,
9848 RunStatus::Ready,
9849 "a concluded run's status must not change on reentry"
9850 );
9851 assert_eq!(
9852 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
9853 Some("already concluded"),
9854 "merge must not run again once the node already recorded an outcome"
9855 );
9856 }
9857
9858 #[tokio::test]
9867 async fn merge_refuses_a_gate_that_has_not_actually_run() {
9868 let tmp = tempfile::tempdir().expect("tempdir");
9869 let repo = tmp.path().join("repo");
9870 std::fs::create_dir_all(&repo).unwrap();
9871 init_repo(&repo);
9872
9873 let mut config = Config::default();
9874 config.merge.mode = MergeMode::Local;
9875
9876 let mut state = RunState::new(
9877 repo.clone(),
9878 "main".to_owned(),
9879 "deadbeef".to_owned(),
9880 "task".to_owned(),
9881 config,
9882 );
9883 state.candidates = vec![Candidate {
9884 index: 0,
9885 label: 'A',
9886 agent: "alpha".to_owned(),
9887 branch: "does-not-exist".to_owned(),
9888 worktree: repo.clone(),
9889 summary: String::new(),
9890 stat: String::new(),
9891 files: 0,
9892 commits: 0,
9893 empty: false,
9894 failed: None,
9895 verified_noop: None,
9896 duration_ms: 0,
9897 folded: false,
9898 }];
9899 state.tally = Some(Tally {
9900 first_choice: BTreeMap::from([('A', 1)]),
9901 borda: BTreeMap::new(),
9902 winner: 'A',
9903 rankings: 1,
9904 unanimous_initial: true,
9905 deliberated: false,
9906 changed_votes: 0,
9907 unanimous_final: true,
9908 tie_break: None,
9909 judges: 0,
9910 present: 0,
9911 quorum: 0,
9912 met_quorum: true,
9913 uncontested: Some("only candidate A produced a change".to_owned()),
9914 });
9915 state.reviews = vec![ReviewRound {
9916 round: 1,
9917 head: "deadbeef".to_owned(),
9918 verified_head: None,
9919 verified_at: None,
9920 reviews: Vec::new(),
9921 e2e: Vec::new(),
9922 fix: None,
9923 blocking: 0,
9924 answered: 0,
9925 expected: 0,
9926 clean: true,
9927 verify_retried: false,
9928 e2e_deferred: false,
9929 e2e_defer_reason: None,
9930 progressed: false,
9931 vote_split: false,
9932 reconsideration: Vec::new(),
9933 verdict: None,
9934 }];
9935 state.gate = Vec::new();
9937 state.gate_ran = false;
9938 state.status = RunStatus::Gating;
9939
9940 let mut runner = Runner {
9941 state,
9942 roles: ResolvedRoles {
9943 implementers: Vec::new(),
9944 judges: Vec::new(),
9945 reviewers: Vec::new(),
9946 fixer: None,
9947 conductor: conductor(),
9948 implementer_roster: Vec::new(),
9949 judge_roster: Vec::new(),
9950 reviewer_roster: Vec::new(),
9951 },
9952 sem: Arc::new(Semaphore::new(1)),
9953 pause: Pause::new(),
9954 interrupt: Pause::new(),
9955 };
9956
9957 runner.merge().await.expect("merge");
9958
9959 assert!(
9960 runner.state.merge.is_none(),
9961 "an empty gate must never be read as a passing one: {:?}",
9962 runner.state.merge
9963 );
9964 }
9965
9966 #[tokio::test]
9973 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
9974 let tmp = tempfile::tempdir().expect("tempdir");
9975 let repo = tmp.path().join("repo");
9976 std::fs::create_dir_all(&repo).unwrap();
9977 init_repo(&repo);
9978
9979 let config = Config::default();
9981
9982 let mut state = RunState::new(
9983 repo.clone(),
9984 "main".to_owned(),
9985 "deadbeef".to_owned(),
9986 "task".to_owned(),
9987 config,
9988 );
9989 state.candidates = vec![Candidate {
9990 index: 0,
9991 label: 'A',
9992 agent: "alpha".to_owned(),
9993 branch: "does-not-exist".to_owned(),
9994 worktree: repo.clone(),
9995 summary: String::new(),
9996 stat: String::new(),
9997 files: 0,
9998 commits: 0,
9999 empty: false,
10000 failed: None,
10001 verified_noop: None,
10002 duration_ms: 0,
10003 folded: false,
10004 }];
10005 state.tally = Some(Tally {
10006 first_choice: BTreeMap::from([('A', 1)]),
10007 borda: BTreeMap::new(),
10008 winner: 'A',
10009 rankings: 1,
10010 unanimous_initial: true,
10011 deliberated: false,
10012 changed_votes: 0,
10013 unanimous_final: true,
10014 tie_break: None,
10015 judges: 0,
10016 present: 0,
10017 quorum: 0,
10018 met_quorum: true,
10019 uncontested: Some("only candidate A produced a change".to_owned()),
10020 });
10021 state.reviews = vec![ReviewRound {
10022 round: 1,
10023 head: "deadbeef".to_owned(),
10024 verified_head: None,
10025 verified_at: None,
10026 reviews: Vec::new(),
10027 e2e: Vec::new(),
10028 fix: None,
10029 blocking: 0,
10030 answered: 0,
10031 expected: 0,
10032 clean: true,
10033 verify_retried: false,
10034 e2e_deferred: false,
10035 e2e_defer_reason: None,
10036 progressed: false,
10037 vote_split: false,
10038 reconsideration: Vec::new(),
10039 verdict: None,
10040 }];
10041
10042 let mut runner = Runner {
10043 state,
10044 roles: ResolvedRoles {
10045 implementers: Vec::new(),
10046 judges: Vec::new(),
10047 reviewers: Vec::new(),
10048 fixer: None,
10049 conductor: conductor(),
10050 implementer_roster: Vec::new(),
10051 judge_roster: Vec::new(),
10052 reviewer_roster: Vec::new(),
10053 },
10054 sem: Arc::new(Semaphore::new(1)),
10055 pause: Pause::new(),
10056 interrupt: Pause::new(),
10057 };
10058
10059 runner.gate().await.expect("gate");
10060 assert!(
10061 runner.state.gate_ran,
10062 "zero configured commands is still a real attempt, not an unrun gate"
10063 );
10064 assert!(runner.state.gate.is_empty());
10065 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10066 assert_ne!(
10067 runner.state.status,
10068 RunStatus::Blocked,
10069 "a gate with nothing to check must not read as failed"
10070 );
10071
10072 runner.merge().await.expect("merge");
10073 assert_eq!(
10074 runner.state.status,
10075 RunStatus::Ready,
10076 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10077 );
10078 }
10079
10080 #[tokio::test]
10091 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10092 crate::run::pin_test_home();
10093 let home = crate::run::home();
10094
10095 let tmp = tempfile::tempdir().expect("tempdir");
10096 let repo = tmp.path().join("repo");
10097 std::fs::create_dir_all(&repo).unwrap();
10098 init_repo(&repo);
10099 let cache_dir = tmp.path().join("target");
10102
10103 let mut config = Config::default();
10104 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10105 config.graph.timeout_verify = Some(2);
10108
10109 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10110 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10111 .expect("no io error acquiring directly")
10112 {
10113 crate::cache::AcquireOutcome::Acquired(g) => g,
10114 crate::cache::AcquireOutcome::Busy(b) => {
10115 panic!("expected the direct acquire to win the lease first: {b:?}")
10116 }
10117 };
10118
10119 let mut state = RunState::new(
10120 repo.clone(),
10121 "main".to_owned(),
10122 "deadbeef".to_owned(),
10123 "task".to_owned(),
10124 config,
10125 );
10126 state.candidates = vec![Candidate {
10127 index: 0,
10128 label: 'A',
10129 agent: "alpha".to_owned(),
10130 branch: "does-not-exist".to_owned(),
10131 worktree: repo.clone(),
10132 summary: String::new(),
10133 stat: String::new(),
10134 files: 0,
10135 commits: 0,
10136 empty: false,
10137 failed: None,
10138 verified_noop: None,
10139 duration_ms: 0,
10140 folded: false,
10141 }];
10142 state.tally = Some(Tally {
10143 first_choice: BTreeMap::from([('A', 1)]),
10144 borda: BTreeMap::new(),
10145 winner: 'A',
10146 rankings: 1,
10147 unanimous_initial: true,
10148 deliberated: false,
10149 changed_votes: 0,
10150 unanimous_final: true,
10151 tie_break: None,
10152 judges: 0,
10153 present: 0,
10154 quorum: 0,
10155 met_quorum: true,
10156 uncontested: Some("only candidate A produced a change".to_owned()),
10157 });
10158 state.reviews = vec![ReviewRound {
10159 round: 1,
10160 head: "deadbeef".to_owned(),
10161 verified_head: None,
10162 verified_at: None,
10163 reviews: Vec::new(),
10164 e2e: Vec::new(),
10165 fix: None,
10166 blocking: 0,
10167 answered: 0,
10168 expected: 0,
10169 clean: true,
10170 verify_retried: false,
10171 e2e_deferred: false,
10172 e2e_defer_reason: None,
10173 progressed: false,
10174 vote_split: false,
10175 reconsideration: Vec::new(),
10176 verdict: None,
10177 }];
10178
10179 let mut runner = Runner {
10180 state,
10181 roles: ResolvedRoles {
10182 implementers: Vec::new(),
10183 judges: Vec::new(),
10184 reviewers: Vec::new(),
10185 fixer: None,
10186 conductor: conductor(),
10187 implementer_roster: Vec::new(),
10188 judge_roster: Vec::new(),
10189 reviewer_roster: Vec::new(),
10190 },
10191 sem: Arc::new(Semaphore::new(1)),
10192 pause: Pause::new(),
10193 interrupt: Pause::new(),
10194 };
10195
10196 let started = std::time::Instant::now();
10197 runner.gate().await.expect("gate");
10198 assert!(
10199 started.elapsed() < Duration::from_secs(1),
10200 "a gate with nothing to run must never wait on a lease it never needed"
10201 );
10202 assert!(
10203 runner.state.gate_ran,
10204 "zero commands is still a real, immediate attempt"
10205 );
10206 assert!(runner.state.gate.is_empty());
10207 assert_ne!(
10208 runner.state.status,
10209 RunStatus::Blocked,
10210 "must not read as resource-blocked on a lease it never asked for"
10211 );
10212 }
10213
10214 #[tokio::test]
10224 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10225 crate::run::pin_test_home();
10226
10227 let tmp = tempfile::tempdir().expect("tempdir");
10228 let repo = tmp.path().join("repo");
10229 std::fs::create_dir_all(&repo).unwrap();
10230 init_repo(&repo);
10231
10232 let mut config = Config::default();
10233 config.verify.gate = vec![
10234 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10235 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10236 .to_owned(),
10237 ];
10238
10239 let mut state = RunState::new(
10240 repo.clone(),
10241 "main".to_owned(),
10242 "deadbeef".to_owned(),
10243 "task".to_owned(),
10244 config,
10245 );
10246 let run_id = state.id.clone();
10247 state.candidates = vec![Candidate {
10248 index: 0,
10249 label: 'A',
10250 agent: "alpha".to_owned(),
10251 branch: "does-not-exist".to_owned(),
10252 worktree: repo.clone(),
10253 summary: String::new(),
10254 stat: String::new(),
10255 files: 0,
10256 commits: 0,
10257 empty: false,
10258 failed: None,
10259 verified_noop: None,
10260 duration_ms: 0,
10261 folded: false,
10262 }];
10263 state.tally = Some(Tally {
10264 first_choice: BTreeMap::from([('A', 1)]),
10265 borda: BTreeMap::new(),
10266 winner: 'A',
10267 rankings: 1,
10268 unanimous_initial: true,
10269 deliberated: false,
10270 changed_votes: 0,
10271 unanimous_final: true,
10272 tie_break: None,
10273 judges: 0,
10274 present: 0,
10275 quorum: 0,
10276 met_quorum: true,
10277 uncontested: Some("only candidate A produced a change".to_owned()),
10278 });
10279 state.reviews = vec![ReviewRound {
10280 round: 1,
10281 head: "deadbeef".to_owned(),
10282 verified_head: None,
10283 verified_at: None,
10284 reviews: Vec::new(),
10285 e2e: Vec::new(),
10286 fix: None,
10287 blocking: 0,
10288 answered: 0,
10289 expected: 0,
10290 clean: true,
10291 verify_retried: false,
10292 e2e_deferred: false,
10293 e2e_defer_reason: None,
10294 progressed: false,
10295 vote_split: false,
10296 reconsideration: Vec::new(),
10297 verdict: None,
10298 }];
10299
10300 let mut runner = Runner {
10301 state,
10302 roles: ResolvedRoles {
10303 implementers: Vec::new(),
10304 judges: Vec::new(),
10305 reviewers: Vec::new(),
10306 fixer: None,
10307 conductor: conductor(),
10308 implementer_roster: Vec::new(),
10309 judge_roster: Vec::new(),
10310 reviewer_roster: Vec::new(),
10311 },
10312 sem: Arc::new(Semaphore::new(1)),
10313 pause: Pause::new(),
10314 interrupt: Pause::new(),
10315 };
10316
10317 let started_marker = repo.join("started.marker");
10318 let release_marker = repo.join("release.marker");
10319 let poller = tokio::spawn(async move {
10320 for _ in 0..100 {
10325 if started_marker.exists()
10326 && let Ok(s) = crate::run::RunState::load(&run_id)
10327 && let Some(a) = s.active.get("gate")
10328 {
10329 std::fs::write(&release_marker, b"go").expect("release marker");
10330 return Some(a.clone());
10331 }
10332 tokio::time::sleep(Duration::from_millis(50)).await;
10333 }
10334 None
10335 });
10336
10337 runner.gate().await.expect("gate");
10338 let captured = poller.await.expect("poller task");
10339 let captured = captured.expect(
10340 "the poller never saw a `gate` task entry in run.json while the command was \
10341 still blocked on its own release marker",
10342 );
10343
10344 assert_eq!(captured.task.as_deref(), Some("gate"));
10345 assert_eq!(captured.node, "gate");
10346 assert_eq!(captured.index, Some(1));
10347 assert_eq!(captured.total, Some(1));
10348 assert!(
10349 captured
10350 .command
10351 .as_deref()
10352 .is_some_and(|c| c.contains("started.marker")),
10353 "{captured:?}"
10354 );
10355
10356 assert!(
10357 runner.state.active.is_empty(),
10358 "the entry must be cleared once the command actually finished: {:?}",
10359 runner.state.active
10360 );
10361 assert!(runner.state.gate_ran);
10362 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10363 }
10364
10365 #[tokio::test]
10368 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10369 use crate::verdict::{Finding, ReviewVote, Severity};
10370 crate::run::pin_test_home();
10371 let tmp = tempfile::tempdir().expect("tempdir");
10372 let repo = tmp.path().join("repo");
10373 std::fs::create_dir_all(&repo).unwrap();
10374 init_repo(&repo);
10375
10376 for (severity, vote, expect) in [
10377 (Severity::Major, ReviewVote::Reject, true),
10378 (Severity::Minor, ReviewVote::Reject, false),
10379 (Severity::Major, ReviewVote::Approve, false),
10380 ] {
10381 let mut round = review_round(false, 1, 1, 1, false, true);
10382 round.reviews = vec![ReviewRecord {
10383 reviewer: 1,
10384 agent: "alpha".to_owned(),
10385 summary: String::new(),
10386 findings: vec![Finding {
10387 id: "R1-1-1".to_owned(),
10388 severity,
10389 file: None,
10390 line: None,
10391 title: "t".to_owned(),
10392 detail: String::new(),
10393 }],
10394 vote: Some(vote),
10395 failed: None,
10396 duration_ms: 0,
10397 attempts: 0,
10398 }];
10399 let mut state = RunState::new(
10400 repo.clone(),
10401 "main".to_owned(),
10402 "deadbeef".to_owned(),
10403 "task".to_owned(),
10404 Config::default(),
10405 );
10406 state.reviews = vec![round];
10407 let mut runner = Runner {
10408 state,
10409 roles: ResolvedRoles {
10410 implementers: Vec::new(),
10411 judges: Vec::new(),
10412 reviewers: Vec::new(),
10413 fixer: None,
10414 conductor: conductor(),
10415 implementer_roster: Vec::new(),
10416 judge_roster: Vec::new(),
10417 reviewer_roster: Vec::new(),
10418 },
10419 sem: Arc::new(Semaphore::new(1)),
10420 pause: Pause::new(),
10421 interrupt: Pause::new(),
10422 };
10423 let shell = runner.state.config.shell();
10424 runner
10425 .stop_reviewing("round budget spent", &shell, &repo)
10426 .await
10427 .expect("stop_reviewing");
10428 assert_eq!(runner.state.status, RunStatus::Gating);
10429 assert_eq!(
10430 runner.state.contested_handoff.is_some(),
10431 expect,
10432 "{severity:?} + {vote:?}"
10433 );
10434 }
10435 }
10436
10437 #[tokio::test]
10450 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10451 crate::run::pin_test_home();
10452 let home = crate::run::home();
10453
10454 let tmp = tempfile::tempdir().expect("tempdir");
10455 let repo = tmp.path().join("repo");
10456 std::fs::create_dir_all(&repo).unwrap();
10457 init_repo(&repo);
10458 let head = crate::git::rev_parse(&repo, "HEAD")
10459 .await
10460 .expect("rev-parse");
10461 let cache_dir = tmp.path().join("target");
10464
10465 let mut config = Config::default();
10466 config.verify.e2e = vec![format!(
10467 "CARGO_TARGET_DIR='{}' test -f README.md",
10468 cache_dir.display()
10469 )];
10470 config.graph.review_rounds = 1;
10471 config.graph.timeout_verify = Some(2);
10474
10475 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10476 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10477 .expect("no io error acquiring directly")
10478 {
10479 crate::cache::AcquireOutcome::Acquired(g) => g,
10480 crate::cache::AcquireOutcome::Busy(b) => {
10481 panic!("expected the direct acquire to win the lease first: {b:?}")
10482 }
10483 };
10484
10485 let mut state = RunState::new(
10486 repo.clone(),
10487 "main".to_owned(),
10488 head.clone(),
10489 "task".to_owned(),
10490 config,
10491 );
10492 state.candidates = vec![Candidate {
10493 index: 0,
10494 label: 'A',
10495 agent: "alpha".to_owned(),
10496 branch: "does-not-exist".to_owned(),
10497 worktree: repo.clone(),
10498 summary: String::new(),
10499 stat: String::new(),
10500 files: 0,
10501 commits: 0,
10502 empty: false,
10503 failed: None,
10504 verified_noop: None,
10505 duration_ms: 0,
10506 folded: false,
10507 }];
10508 state.tally = Some(Tally {
10509 first_choice: BTreeMap::from([('A', 1)]),
10510 borda: BTreeMap::new(),
10511 winner: 'A',
10512 rankings: 1,
10513 unanimous_initial: true,
10514 deliberated: false,
10515 changed_votes: 0,
10516 unanimous_final: true,
10517 tie_break: None,
10518 judges: 0,
10519 present: 0,
10520 quorum: 0,
10521 met_quorum: true,
10522 uncontested: Some("only candidate A produced a change".to_owned()),
10523 });
10524 state.reviews = vec![ReviewRound {
10528 round: 1,
10529 head: head.clone(),
10530 verified_head: None,
10531 verified_at: None,
10532 reviews: Vec::new(),
10533 e2e: Vec::new(),
10534 fix: None,
10535 blocking: 1,
10536 answered: 1,
10537 expected: 1,
10538 clean: false,
10539 verify_retried: false,
10540 e2e_deferred: true,
10541 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
10542 progressed: false,
10543 vote_split: false,
10544 reconsideration: Vec::new(),
10545 verdict: None,
10546 }];
10547
10548 let mut runner = Runner {
10549 state,
10550 roles: ResolvedRoles {
10551 implementers: Vec::new(),
10552 judges: Vec::new(),
10553 reviewers: Vec::new(),
10554 fixer: None,
10555 conductor: conductor(),
10556 implementer_roster: Vec::new(),
10557 judge_roster: Vec::new(),
10558 reviewer_roster: Vec::new(),
10559 },
10560 sem: Arc::new(Semaphore::new(1)),
10561 pause: Pause::new(),
10562 interrupt: Pause::new(),
10563 };
10564
10565 let shell = runner.state.config.shell();
10566 runner
10567 .stop_reviewing("round budget spent", &shell, &repo)
10568 .await
10569 .expect("stop_reviewing");
10570
10571 let last = runner.state.reviews.last().expect("round record");
10572 assert_eq!(
10573 last.e2e_status(),
10574 E2eStatus::ResourceBlocked,
10575 "the shared cache is still held; the attempt must read as blocked, not deferred or \
10576 failed: {last:?}"
10577 );
10578 assert_eq!(
10579 last.verified_head.as_deref(),
10580 Some(head.as_str()),
10581 "which commit this attempt targeted is known even though nothing finished checking \
10582 it"
10583 );
10584 let first_attempt_at = last
10585 .verified_at
10586 .expect("when this attempt ran is known too");
10587 assert_ne!(
10588 runner.state.status,
10589 RunStatus::Blocked,
10590 "contention is evidence about the machine, not the patch — it must not settle the \
10591 run as blocked: {:?}",
10592 runner.state.status
10593 );
10594 assert!(
10595 !runner
10596 .state
10597 .events
10598 .iter()
10599 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
10600 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
10601 runner.state.events
10602 );
10603
10604 runner
10609 .stop_reviewing("round budget spent", &shell, &repo)
10610 .await
10611 .expect("stop_reviewing retry");
10612 assert_eq!(
10613 runner.state.reviews.len(),
10614 1,
10615 "no new round was started: {:?}",
10616 runner.state.reviews
10617 );
10618 let last = runner.state.reviews.last().expect("round record");
10619 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
10620 assert!(
10621 last.verified_at.expect("still known") > first_attempt_at,
10622 "a second reentry must be a fresh attempt, not a stale copy of the first"
10623 );
10624 assert_ne!(runner.state.status, RunStatus::Blocked);
10625
10626 held.release();
10627 }
10628
10629 #[tokio::test]
10641 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
10642 crate::run::pin_test_home();
10643 let home = crate::run::home();
10644
10645 let tmp = tempfile::tempdir().expect("tempdir");
10646 let repo = tmp.path().join("repo");
10647 std::fs::create_dir_all(&repo).unwrap();
10648 init_repo(&repo);
10649 let head = crate::git::rev_parse(&repo, "HEAD")
10650 .await
10651 .expect("rev-parse");
10652 let cache_dir = tmp.path().join("target");
10653
10654 let mut config = Config::default();
10655 config.verify.e2e = vec![format!(
10656 "CARGO_TARGET_DIR='{}' test -f README.md",
10657 cache_dir.display()
10658 )];
10659 config.graph.review_rounds = 1;
10660 config.graph.timeout_verify = Some(2);
10661
10662 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10663 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10664 .expect("no io error acquiring directly")
10665 {
10666 crate::cache::AcquireOutcome::Acquired(g) => g,
10667 crate::cache::AcquireOutcome::Busy(b) => {
10668 panic!("expected the direct acquire to win the lease first: {b:?}")
10669 }
10670 };
10671
10672 let mut state = RunState::new(
10673 repo.clone(),
10674 "main".to_owned(),
10675 head.clone(),
10676 "task".to_owned(),
10677 config,
10678 );
10679 state.candidates = vec![Candidate {
10680 index: 0,
10681 label: 'A',
10682 agent: "alpha".to_owned(),
10683 branch: "does-not-exist".to_owned(),
10684 worktree: repo.clone(),
10685 summary: String::new(),
10686 stat: String::new(),
10687 files: 0,
10688 commits: 0,
10689 empty: false,
10690 failed: None,
10691 verified_noop: None,
10692 duration_ms: 0,
10693 folded: false,
10694 }];
10695 state.tally = Some(Tally {
10696 first_choice: BTreeMap::from([('A', 1)]),
10697 borda: BTreeMap::new(),
10698 winner: 'A',
10699 rankings: 1,
10700 unanimous_initial: true,
10701 deliberated: false,
10702 changed_votes: 0,
10703 unanimous_final: true,
10704 tie_break: None,
10705 judges: 0,
10706 present: 0,
10707 quorum: 0,
10708 met_quorum: true,
10709 uncontested: Some("only candidate A produced a change".to_owned()),
10710 });
10711 state.reviews = vec![ReviewRound {
10715 round: 1,
10716 head: head.clone(),
10717 verified_head: Some(head.clone()),
10718 verified_at: Some(jiff::Timestamp::now()),
10719 reviews: Vec::new(),
10720 e2e: vec![CommandOutcome {
10721 command: format!(
10722 "CARGO_TARGET_DIR='{}' test -f README.md",
10723 cache_dir.display()
10724 ),
10725 code: None,
10726 output_tail: "waiting for the shared build cache".to_owned(),
10727 duration_ms: 0,
10728 resource_blocked: true,
10729 }],
10730 fix: None,
10731 blocking: 1,
10732 answered: 1,
10733 expected: 1,
10734 clean: false,
10735 verify_retried: false,
10736 e2e_deferred: false,
10737 e2e_defer_reason: None,
10738 progressed: false,
10739 vote_split: false,
10740 reconsideration: Vec::new(),
10741 verdict: None,
10742 }];
10743
10744 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
10745 let mut runner = Runner {
10746 state,
10747 roles: ResolvedRoles {
10748 implementers: Vec::new(),
10749 judges: Vec::new(),
10750 reviewers: Vec::new(),
10751 fixer: None,
10752 conductor: conductor(),
10753 implementer_roster: Vec::new(),
10754 judge_roster: Vec::new(),
10755 reviewer_roster: Vec::new(),
10756 },
10757 sem: Arc::new(Semaphore::new(1)),
10758 pause: Pause::new(),
10759 interrupt: Pause::new(),
10760 };
10761
10762 runner.review_loop().await.expect("review_loop");
10767
10768 assert_eq!(
10769 runner.state.reviews.len(),
10770 1,
10771 "no new round was started on top of the unresolved one: {:?}",
10772 runner.state.reviews
10773 );
10774 let last = &runner.state.reviews[0];
10775 assert_eq!(
10776 last.e2e_status(),
10777 E2eStatus::ResourceBlocked,
10778 "still contended: {last:?}"
10779 );
10780 assert!(
10781 last.verified_at.expect("still known") > first_attempt_at,
10782 "review_loop must have actually retried the check, not left it exactly as found"
10783 );
10784 assert_ne!(
10785 runner.state.status,
10786 RunStatus::Blocked,
10787 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
10788 runner.state.status
10789 );
10790
10791 held.release();
10792 }
10793
10794 #[tokio::test]
10795 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
10796 crate::run::pin_test_home();
10797 let tmp = tempfile::tempdir().expect("tempdir");
10798 let repo = tmp.path().join("repo");
10799 std::fs::create_dir_all(&repo).unwrap();
10800 init_repo(&repo);
10801
10802 let mut config = Config::default();
10803 config.merge.mode = MergeMode::Pr;
10804 config.graph.land = true;
10805 config.graph.land_approval = false;
10806
10807 let mut state = RunState::new(
10808 repo.clone(),
10809 "main".to_owned(),
10810 "deadbeef".to_owned(),
10811 "task".to_owned(),
10812 config,
10813 );
10814 state.candidates = vec![Candidate {
10815 index: 0,
10816 label: 'A',
10817 agent: "alpha".to_owned(),
10818 branch: "does-not-exist".to_owned(),
10819 worktree: repo.clone(),
10820 summary: String::new(),
10821 stat: String::new(),
10822 files: 0,
10823 commits: 0,
10824 empty: false,
10825 failed: None,
10826 verified_noop: None,
10827 duration_ms: 0,
10828 folded: false,
10829 }];
10830 state.tally = Some(Tally {
10831 first_choice: BTreeMap::from([('A', 1)]),
10832 borda: BTreeMap::new(),
10833 winner: 'A',
10834 rankings: 1,
10835 unanimous_initial: true,
10836 deliberated: false,
10837 changed_votes: 0,
10838 unanimous_final: true,
10839 tie_break: None,
10840 judges: 0,
10841 present: 0,
10842 quorum: 0,
10843 met_quorum: true,
10844 uncontested: Some("only candidate A produced a change".to_owned()),
10845 });
10846 state.reviews = vec![ReviewRound {
10847 round: 1,
10848 head: "deadbeef".to_owned(),
10849 verified_head: None,
10850 verified_at: None,
10851 reviews: Vec::new(),
10852 e2e: Vec::new(),
10853 fix: None,
10854 blocking: 0,
10855 answered: 0,
10856 expected: 0,
10857 clean: true,
10858 verify_retried: false,
10859 e2e_deferred: false,
10860 e2e_defer_reason: None,
10861 progressed: false,
10862 vote_split: false,
10863 reconsideration: Vec::new(),
10864 verdict: None,
10865 }];
10866 state.gate = vec![CommandOutcome {
10867 command: "test".to_owned(),
10868 code: Some(0),
10869 output_tail: String::new(),
10870 duration_ms: 0,
10871 resource_blocked: false,
10872 }];
10873 state.gate_ran = true;
10874 state.status = RunStatus::Landing;
10878 state.merge = Some(MergeOutcome {
10879 mode: MergeMode::Pr,
10880 ok: true,
10881 detail: "https://example.invalid/x/y/pull/1".to_owned(),
10882 empty: false,
10883 });
10884
10885 ask_test_home();
10889 let store = ask::Questions::open();
10890 let q = ask_open_question(&store, &state.id);
10891
10892 let mut runner = Runner {
10893 state,
10894 roles: ResolvedRoles {
10895 implementers: Vec::new(),
10896 judges: Vec::new(),
10897 reviewers: Vec::new(),
10898 fixer: None,
10899 conductor: conductor(),
10900 implementer_roster: Vec::new(),
10901 judge_roster: Vec::new(),
10902 reviewer_roster: Vec::new(),
10903 },
10904 sem: Arc::new(Semaphore::new(1)),
10905 pause: Pause::new(),
10906 interrupt: Pause::new(),
10907 };
10908
10909 runner.execute().await.expect("execute");
10914
10915 assert_eq!(
10916 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10917 Some("https://example.invalid/x/y/pull/1"),
10918 "reentry must not push again or open a second pull request over the \
10919 one `land` is already watching"
10920 );
10921 assert_ne!(
10922 runner.state.status,
10923 RunStatus::Landing,
10924 "land could not actually reach the fake pull request, so it must \
10925 have given up rather than left the run silently parked forever"
10926 );
10927 assert_eq!(runner.state.status, RunStatus::Blocked);
10931 assert!(
10932 store.get(&q.id).unwrap().status.open(),
10933 "Blocked is still alive; settle_questions must have been a no-op here"
10934 );
10935 }
10936
10937 fn state_with_round(round: ReviewRound) -> RunState {
10938 let mut s = RunState::new(
10939 PathBuf::from("/repo"),
10940 "main".to_owned(),
10941 "abc1234".to_owned(),
10942 "add retries".to_owned(),
10943 Config::default(),
10944 );
10945 s.reviews = vec![round];
10946 s
10947 }
10948
10949 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
10950 crate::verdict::Finding {
10951 id: id.to_owned(),
10952 severity,
10953 file: None,
10954 line: None,
10955 title: title.to_owned(),
10956 detail: String::new(),
10957 }
10958 }
10959
10960 #[test]
10961 fn pr_body_names_open_findings_and_declined_ones() {
10962 let round = ReviewRound {
10963 round: 2,
10964 head: "deadbee".to_owned(),
10965 verified_head: None,
10966 verified_at: None,
10967 reviews: vec![ReviewRecord {
10968 attempts: 0,
10969 reviewer: 1,
10970 agent: "alpha".to_owned(),
10971 summary: String::new(),
10972 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
10973 vote: None,
10974 failed: None,
10975 duration_ms: 0,
10976 }],
10977 e2e: vec![CommandOutcome {
10978 command: "cargo test".to_owned(),
10979 code: Some(0),
10980 output_tail: String::new(),
10981 duration_ms: 0,
10982 resource_blocked: false,
10983 }],
10984 verify_retried: false,
10985 e2e_deferred: false,
10986 e2e_defer_reason: None,
10987 fix: Some(FixRecord {
10988 agent: "alpha".to_owned(),
10989 addressed: Vec::new(),
10990 rejected: vec![crate::verdict::Rejection {
10991 id: "R1-1-1".to_owned(),
10992 why: "not reachable from any caller".to_owned(),
10993 }],
10994 notes: String::new(),
10995 committed: true,
10996 failed: None,
10997 duration_ms: 0,
10998 continuation: None,
10999 }),
11000 blocking: 0,
11001 answered: 1,
11002 expected: 1,
11003 clean: false,
11004 progressed: true,
11005 vote_split: false,
11006 reconsideration: Vec::new(),
11007 verdict: None,
11008 };
11009 let state = state_with_round(round);
11010 let body = pr_message(&state, 'A').body;
11011
11012 assert!(body.contains("add retries"), "the task must still be there");
11013 assert!(body.contains("R2-1-1"), "{body}");
11014 assert!(body.contains("unused import"), "{body}");
11015 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11016 assert!(
11017 body.contains("not reachable from any caller"),
11018 "the reason it was declined: {body}"
11019 );
11020 }
11021
11022 #[test]
11023 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11024 let round = ReviewRound {
11025 round: 1,
11026 head: "deadbee".to_owned(),
11027 verified_head: None,
11028 verified_at: None,
11029 reviews: vec![ReviewRecord {
11030 attempts: 0,
11031 reviewer: 1,
11032 agent: "alpha".to_owned(),
11033 summary: String::new(),
11034 findings: Vec::new(),
11035 vote: None,
11036 failed: None,
11037 duration_ms: 0,
11038 }],
11039 e2e: Vec::new(),
11040 verify_retried: false,
11041 e2e_deferred: false,
11042 e2e_defer_reason: None,
11043 fix: None,
11044 blocking: 0,
11045 answered: 1,
11046 expected: 1,
11047 clean: true,
11048 progressed: false,
11049 vote_split: false,
11050 reconsideration: Vec::new(),
11051 verdict: None,
11052 };
11053 let state = state_with_round(round);
11054 let body = pr_message(&state, 'A').body;
11055 assert!(!body.contains("Open review findings"), "{body}");
11056 assert!(!body.contains("Declined"), "{body}");
11057 }
11058
11059 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11060 let mut state = RunState::new(
11061 PathBuf::from("/repo"),
11062 "main".to_owned(),
11063 "abc1234".to_owned(),
11064 instruction.to_owned(),
11065 Config::default(),
11066 );
11067 state.candidates.push(Candidate {
11068 index: 0,
11069 label: 'A',
11070 agent: "alpha".to_owned(),
11071 branch: "magi/x/A".to_owned(),
11072 worktree: PathBuf::from("/wt"),
11073 summary: summary.to_owned(),
11074 stat: String::new(),
11075 files: 1,
11076 commits: 1,
11077 empty: false,
11078 failed: None,
11079 verified_noop: None,
11080 folded: false,
11081 duration_ms: 0,
11082 });
11083 state
11084 }
11085
11086 fn review_state(subjects: &[&str]) -> RunState {
11087 let mut state = state_with_summary(
11088 "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",
11089 "",
11090 );
11091 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11092 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11093 state
11094 }
11095
11096 #[tokio::test]
11099 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11100 ask_test_home();
11101 let tmp = tempfile::tempdir().unwrap();
11102 let repo = tmp.path().join("repo");
11103 std::fs::create_dir_all(&repo).unwrap();
11104 init_repo(&repo);
11105 let origin = tmp.path().join("origin.git");
11106 let g = |dir: &Path, args: &[&str]| {
11107 let out = std::process::Command::new("git")
11108 .args(args)
11109 .current_dir(dir)
11110 .quiet()
11111 .output()
11112 .expect("spawn git");
11113 assert!(
11114 out.status.success(),
11115 "git {args:?}: {}",
11116 String::from_utf8_lossy(&out.stderr)
11117 );
11118 };
11119 g(
11120 tmp.path(),
11121 &[
11122 "clone",
11123 "--bare",
11124 "-q",
11125 repo.to_str().unwrap(),
11126 origin.to_str().unwrap(),
11127 ],
11128 );
11129 g(
11130 &repo,
11131 &["remote", "add", "origin", origin.to_str().unwrap()],
11132 );
11133 let c1 = git::rev_parse(&repo, "main").await.unwrap();
11134
11135 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11137 g(&repo, &["add", "-A"]);
11138 g(
11139 &repo,
11140 &["commit", "-q", "-m", "chore(deps): update a crate"],
11141 );
11142 g(&repo, &["push", "-q", "origin", "main"]);
11143 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11144 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11145 g(&repo, &["add", "-A"]);
11146 g(
11147 &repo,
11148 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11149 );
11150 g(&repo, &["checkout", "-q", "main"]);
11151
11152 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11153 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11154 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11156 assert_eq!(fallback, c1);
11157
11158 let mut state = review_state(&[
11159 "chore(deps): update a crate",
11160 "fix(daemon): apply a chosen action",
11161 ]);
11162 state.repo = repo.clone();
11163 state.base_branch = "main".to_owned();
11164 state.base_commit = c1;
11165 refresh_reviewed_commits(&mut state, "feat/own").await;
11166 assert_eq!(
11167 state.reviewed_commits,
11168 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11169 );
11170 assert_eq!(
11171 review_title(&state).as_deref(),
11172 Some("fix(daemon): apply a chosen action")
11173 );
11174 assert_eq!(
11175 leaked_subjects(&state, "feat/own").await,
11176 Some(vec!["chore(deps): update a crate".to_owned()])
11177 );
11178 state.config.merge.remote = "nowhere".to_owned();
11181 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11182 }
11183
11184 #[test]
11185 fn pr_message_review_single_commit_uses_its_subject() {
11186 let state = review_state(&["feat(nats): per-role user"]);
11187 let m = pr_message(&state, 'A');
11188 assert_eq!(m.title, "feat(nats): per-role user");
11189 assert!(!m.body.contains("Review the work already"), "{}", m.body);
11190 assert!(m.body.contains("## Commits under review"), "{}", m.body);
11191 }
11192
11193 #[test]
11194 fn pr_message_review_multi_commit_takes_the_oldest() {
11195 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11196 let m = pr_message(&state, 'A');
11197 assert_eq!(m.title, "feat: the change");
11198 for s in ["feat: the change", "fix: typo", "fix: again"] {
11199 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11200 }
11201 }
11202
11203 #[test]
11204 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11205 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11206 let mut state = review_state(&[first, "fix: later fixup"]);
11207 state.candidates[0].branch = "機能/ログイン".to_owned();
11208 let m = pr_message(&state, 'A');
11209 assert!(
11210 m.title.starts_with("chore: land candidate A of run"),
11211 "{}",
11212 m.title
11213 );
11214 }
11215 }
11216
11217 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11218 BranchFacts {
11219 commits: commits
11220 .iter()
11221 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11222 .collect(),
11223 stat: stat.to_owned(),
11224 }
11225 }
11226
11227 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11228 let mut r = review_round(true, 0, 1, 1, true, true);
11229 r.round = round;
11230 r.fix = notes.map(|n| FixRecord {
11231 agent: "fixer".to_owned(),
11232 addressed: Vec::new(),
11233 rejected: Vec::new(),
11234 notes: n.to_owned(),
11235 committed: true,
11236 failed: None,
11237 duration_ms: 0,
11238 continuation: None,
11239 });
11240 r
11241 }
11242
11243 #[test]
11244 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11245 let state = review_state(&["ignored"]);
11246 let f = facts(
11247 &[
11248 (
11249 "fix(login): resolve PATH on macOS",
11250 "Login shells skip rc files.",
11251 ),
11252 ("fix: address review", ""),
11253 ],
11254 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11255 );
11256 let m = pr_message_with(&state, 'A', Some(&f));
11257 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11258 assert!(
11259 m.body
11260 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
11261 "{}",
11262 m.body
11263 );
11264 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11265 assert!(m.body.contains("## Diff stat"), "{}", m.body);
11266 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11267 for banned in [
11268 "Review the work already",
11269 "no task statement",
11270 "Original task",
11271 ] {
11272 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11273 }
11274 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11275 }
11276
11277 #[test]
11278 fn pr_message_review_truncates_a_huge_first_commit_body() {
11279 let state = review_state(&["ignored"]);
11280 let f = facts(
11281 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11282 "s",
11283 );
11284 let m = pr_message_with(&state, 'A', Some(&f));
11285 assert!(m.body.len() < 30_000, "{}", m.body.len());
11286 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11287 assert!(
11288 m.body.contains("- ... 1 more commit(s)"),
11289 "{}",
11290 m.body.len()
11291 );
11292 assert!(m.body.ends_with("magi:candidate-a\n"));
11293 }
11294
11295 #[test]
11296 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11297 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11298 assert_eq!(m.title, "feat: x");
11299 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11300 assert!(!m.body.contains("Diff stat"), "{}", m.body);
11301 assert!(!m.body.contains("no task statement"), "{}", m.body);
11302 }
11303
11304 #[test]
11305 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11306 let mut state = review_state(&["日本語の件名"]);
11307 state.candidates[0].branch = "fix/macos-login-path".to_owned();
11308 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11309 state.candidates[0].branch = "機能/ログイン".to_owned();
11310 assert!(
11311 pr_message(&state, 'A')
11312 .title
11313 .starts_with("chore: land candidate A")
11314 );
11315 }
11316
11317 #[test]
11318 fn pr_message_review_fixes_survive_a_clean_final_round() {
11319 let mut state = review_state(&["feat: x"]);
11320 state.reviews = vec![
11321 round_with_notes(1, Some("handled the PATH case")),
11322 round_with_notes(2, None),
11323 ];
11324 let body = pr_message(&state, 'A').body;
11325 assert!(
11326 body.contains("## Review fixes\n\nhandled the PATH case\n"),
11327 "{body}"
11328 );
11329 assert!(!body.contains("### Round"), "{body}");
11330
11331 state.reviews = vec![
11332 round_with_notes(1, Some("first fix")),
11333 round_with_notes(2, Some("")),
11334 round_with_notes(3, Some("second fix")),
11335 round_with_notes(4, None),
11336 ];
11337 let body = pr_message(&state, 'A').body;
11338 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11339 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11340 assert!(!body.contains("### Round 2"), "{body}");
11341 }
11342
11343 #[test]
11344 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11345 let state = state_with_summary(
11346 "add retries to the client",
11347 "TITLE: feat: retries\n\nDid it.",
11348 );
11349 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11350 assert_eq!(m.title, "feat: retries");
11351 assert!(
11352 m.body.contains("<summary>Original task</summary>"),
11353 "{}",
11354 m.body
11355 );
11356 assert!(!m.body.contains("Commits under review"), "{}", m.body);
11357 assert!(
11358 m.body
11359 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11360 "{}",
11361 m.body
11362 );
11363 }
11364
11365 #[test]
11366 fn pr_message_review_bounds_a_long_english_subject() {
11367 let long = format!("feat: {}", "word ".repeat(100));
11368 let m = pr_message(&review_state(&[&long]), 'A');
11369 assert!(m.title.starts_with("feat: word"), "{}", m.title);
11370 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11371 }
11372
11373 #[test]
11374 fn pr_message_implementation_run_is_unchanged_by_review_support() {
11375 let state = state_with_summary("add retries\n\ndetails", "- did some things");
11376 let m = pr_message(&state, 'A');
11377 assert_eq!(m.title, "add retries");
11378 assert!(m.body.contains("<summary>Original task</summary>"));
11379 assert!(!m.body.contains("Commits under review"));
11380 assert_eq!(landing_subject_source(&state), state.instruction);
11381 }
11382
11383 #[test]
11384 fn review_run_squash_subject_is_the_change_not_the_prompt() {
11385 let state = review_state(&["feat: the change", "fix: typo"]);
11386 let source = landing_subject_source(&state);
11387 assert_eq!(land::merge_subject("", &source), "feat: the change");
11388 assert_eq!(
11389 land::merge_subject("magi: candidate A (uncommitted work)", &source),
11390 "feat: the change"
11391 );
11392 assert_eq!(
11394 land::merge_subject("feat: renamed by hand", &source),
11395 "feat: renamed by hand"
11396 );
11397 let blank = review_state(&["日本語"]);
11398 assert!(
11399 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11400 );
11401 }
11402
11403 #[test]
11404 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11405 let state = review_state(&["feat: the change"]);
11406 let source = landing_subject_source(&state);
11407 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11408 assert_eq!(
11409 land::merge_subject(landing_title(&state, old), &source),
11410 "feat: the change"
11411 );
11412 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11413 let task = state_with_summary("add retries", "");
11414 assert_eq!(landing_title(&task, old), old);
11415 }
11416
11417 #[test]
11418 fn pr_message_describes_the_change_not_the_task() {
11419 let state = state_with_summary(
11420 "今回やってほしいこと: results projector を直す",
11421 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11422 );
11423 let m = pr_message(&state, 'A');
11424 assert_eq!(m.title, "fix(web): batch the runs list reads");
11425 assert!(
11426 m.body.starts_with("## Summary\n\n- reads run.json once"),
11427 "{}",
11428 m.body
11429 );
11430 assert!(!m.body.contains("TITLE:"), "{}", m.body);
11431 let task_at = m.body.find("今回やってほしいこと").unwrap();
11432 let details_at = m.body.find("<details>").unwrap();
11433 assert!(
11434 details_at < task_at,
11435 "the task lives inside <details>: {}",
11436 m.body
11437 );
11438 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11439 assert!(m.body.contains("magi:candidate-a"));
11440 }
11441
11442 #[test]
11443 fn pr_message_falls_back_to_the_task_without_a_title_line() {
11444 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11445 let m = pr_message(&state, 'A');
11446 assert_eq!(m.title, "add retries");
11447 assert!(
11448 m.body.contains("## Summary\n\n- did some things"),
11449 "{}",
11450 m.body
11451 );
11452
11453 let none = RunState::new(
11454 PathBuf::from("/repo"),
11455 "main".to_owned(),
11456 "abc1234".to_owned(),
11457 "add retries".to_owned(),
11458 Config::default(),
11459 );
11460 let m = pr_message(&none, 'A');
11461 assert_eq!(m.title, "add retries");
11462 assert!(!m.body.contains("## Summary"), "{}", m.body);
11463 }
11464
11465 #[test]
11466 fn pr_message_refuses_the_candidate_commit_subject() {
11467 for bad in [
11468 "TITLE: magi: candidate A (uncommitted work)",
11469 "TITLE: chore: stuff (uncommitted work)",
11470 "TITLE: ",
11471 ] {
11472 let state = state_with_summary("add retries", bad);
11473 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11474 }
11475 }
11476
11477 #[test]
11478 fn pr_message_bounds_a_very_long_task_and_title() {
11479 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11480 let state = state_with_summary(&long, "- nothing");
11481 let m = pr_message(&state, 'A');
11482 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11483 assert!(!m.title.contains('\n'));
11484
11485 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11486 let m = pr_message(&state, 'A');
11487 assert!(m.title.starts_with("feat: "));
11488 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11489 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11490 }
11491
11492 fn long_title_of(instruction: &str) -> String {
11493 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11494 }
11495
11496 #[test]
11497 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11498 let first = "Make the landing path keep a readable title for long tasks";
11499 let line = format!(
11500 "{first}. {}",
11501 "And then keep going with more words ".repeat(20)
11502 );
11503 let t = long_title_of(&line);
11504 assert_eq!(t, first);
11505 assert!(!t.starts_with("chore: land"));
11506 }
11507
11508 #[test]
11509 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
11510 let line = "word ".repeat(200);
11511 let t = long_title_of(&line);
11512 assert!(t.ends_with("word..."), "{t}");
11513 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
11514 }
11515
11516 #[test]
11517 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
11518 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
11519 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
11520 }
11521 }
11522
11523 #[test]
11524 fn pr_message_title_limit_is_exact() {
11525 let at = "a".repeat(PR_TITLE_MAX);
11526 assert_eq!(long_title_of(&at), at);
11527 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
11528 assert!(over.ends_with("..."), "{over}");
11529 assert_eq!(over.chars().count(), PR_TITLE_MAX);
11530 }
11531
11532 #[test]
11533 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
11534 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
11535 let t = long_title_of(&line);
11536 assert!(t.ends_with("..."), "{t}");
11537 assert!(t.is_ascii(), "{t}");
11538 }
11539
11540 #[test]
11541 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
11542 let line = format!(
11543 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
11544 "plus more filler words ".repeat(20)
11545 );
11546 let t = long_title_of(&line);
11547 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
11548 }
11549
11550 #[test]
11551 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
11552 let line = format!("\n\n# {}", "title words ".repeat(40));
11553 let state = state_with_summary(&line, "- nothing");
11554 let m = pr_message(&state, 'A');
11555 assert!(m.title.starts_with("title words"), "{}", m.title);
11556 assert_eq!(
11557 land::merge_subject(&m.title, &landing_subject_source(&state)),
11558 m.title
11559 );
11560 assert_eq!(
11562 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
11563 "feat: renamed by hand"
11564 );
11565 }
11566
11567 #[test]
11568 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
11569 let mut state = state_with_summary(
11573 "add retries",
11574 "TITLE: fix(web): batch reads\n- reads run.json once",
11575 );
11576 state.config.graph.language = "ja".to_owned();
11577 let m = pr_message(&state, 'A');
11578 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
11579
11580 let task = "今回やってほしいこと: results projector を直す";
11583 let mut state = state_with_summary(task, "- no title line");
11584 state.config.graph.language = "ja".to_owned();
11585 let m = pr_message(&state, 'A');
11586 assert_eq!(
11587 m.title,
11588 format!("chore: land candidate A of run {}", state.id)
11589 );
11590 assert!(
11591 m.body.contains(&format!(
11592 "<summary>Original task</summary>\n\n{task}\n\n</details>"
11593 )),
11594 "{}",
11595 m.body
11596 );
11597 }
11598
11599 #[test]
11600 fn pr_message_scrubs_home_paths_and_addresses() {
11601 let state = state_with_summary(
11602 "fix it in /Users/someone/src/x",
11603 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
11604 );
11605 let m = pr_message(&state, 'A');
11606 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
11607 assert!(!m.body.contains(leak), "{}", m.body);
11608 }
11609 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
11610 }
11611
11612 #[test]
11613 fn pr_message_survives_a_task_that_closes_details() {
11614 let state = state_with_summary("a </details> b", "TITLE: fix: x");
11615 let m = pr_message(&state, 'A');
11616 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
11617 }
11618
11619 #[test]
11620 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
11621 let cmd = manual_merge_command(
11622 MergeStyle::Squash,
11623 Path::new("/repo"),
11624 "b",
11625 "fix: \"quoted\" $(x) `y`\n\nbody",
11626 );
11627 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
11628 }
11629
11630 #[test]
11631 fn manual_merge_command_matches_the_configured_style() {
11632 let repo = Path::new("/repo");
11633 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
11634
11635 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
11636 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
11637
11638 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
11639 assert_eq!(
11640 squash,
11641 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
11642 \"Merge magi run 0832 (candidate A)\""
11643 );
11644
11645 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
11646 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
11647 }
11648
11649 #[test]
11650 fn a_nudge_gets_a_quarter_of_the_budget() {
11651 assert_eq!(retry_budget(secs(1200), true), secs(300));
11653 assert_eq!(retry_budget(secs(3600), true), secs(900));
11654 }
11655
11656 #[test]
11657 fn a_resent_prompt_keeps_the_whole_budget() {
11658 assert_eq!(retry_budget(secs(1200), false), secs(1200));
11661 assert_eq!(retry_budget(secs(60), false), secs(60));
11662 }
11663
11664 #[test]
11665 fn the_floor_never_exceeds_the_original_budget() {
11666 assert_eq!(retry_budget(secs(60), true), secs(60));
11670 assert_eq!(retry_budget(secs(480), true), secs(120));
11671 assert_eq!(retry_budget(secs(0), true), secs(0));
11672 }
11673
11674 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
11675 agent::CommandEvidence {
11676 id: "item1".to_owned(),
11677 description: "cargo test".to_owned(),
11678 exit_code,
11679 result_summary: String::new(),
11680 source: "codex".to_owned(),
11681 }
11682 }
11683
11684 #[test]
11685 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
11686 assert!(!has_unconfirmed_command(&[]));
11690 }
11691
11692 #[test]
11693 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
11694 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
11698 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
11699 assert!(!has_unconfirmed_command(&[
11700 evidence(Some(0)),
11701 evidence(Some(101))
11702 ]));
11703 }
11704
11705 #[test]
11706 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
11707 assert!(has_unconfirmed_command(&[
11708 evidence(Some(0)),
11709 evidence(None)
11710 ]));
11711 }
11712
11713 #[test]
11714 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
11715 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11716 assert_eq!(
11717 verified_noop_claim(true, &[], text).as_deref(),
11718 Some("already fixed by b32cfc4, on main.")
11719 );
11720 }
11721
11722 #[test]
11723 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
11724 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11727 assert!(verified_noop_claim(false, &[], text).is_none());
11728 }
11729
11730 #[test]
11731 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
11732 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11733 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
11734 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
11736 }
11737
11738 #[test]
11739 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
11740 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
11741 }
11742
11743 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
11746 runner.state.candidates = shape
11747 .iter()
11748 .enumerate()
11749 .map(|(i, &(empty, verified))| Candidate {
11750 index: i,
11751 label: (b'A' + i as u8) as char,
11752 agent: "sonnet".to_owned(),
11753 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
11754 worktree: PathBuf::from(format!("/wt/{i}")),
11755 summary: String::new(),
11756 stat: String::new(),
11757 files: 0,
11758 commits: 0,
11759 empty,
11760 failed: None,
11761 verified_noop: verified.map(str::to_owned),
11762 duration_ms: 0,
11763 folded: false,
11764 })
11765 .collect();
11766 }
11767
11768 #[test]
11769 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
11770 ask_test_home();
11771 let mut runner = runner_at(RunStatus::Implementing);
11772 set_candidates(
11773 &mut runner,
11774 &[
11775 (true, Some("already on main at b32cfc4")),
11776 (true, Some("same fix, see the existing test")),
11777 ],
11778 );
11779
11780 runner
11781 .after_implement()
11782 .expect("a verified no-op is not an error");
11783
11784 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
11785 }
11786
11787 #[test]
11788 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
11789 ask_test_home();
11790 let mut runner = runner_at(RunStatus::Implementing);
11791 set_candidates(
11795 &mut runner,
11796 &[(true, Some("already on main at b32cfc4")), (true, None)],
11797 );
11798
11799 let err = runner
11800 .after_implement()
11801 .expect_err("an unverified empty candidate must still fail the run");
11802
11803 assert!(
11804 err.to_string().contains("no candidate produced a change"),
11805 "{err}"
11806 );
11807 assert_eq!(runner.state.status, RunStatus::Failed);
11808 }
11809
11810 #[test]
11811 fn after_implement_still_fails_an_ordinary_all_empty_run() {
11812 ask_test_home();
11813 let mut runner = runner_at(RunStatus::Implementing);
11814 set_candidates(&mut runner, &[(true, None), (true, None)]);
11815
11816 let err = runner
11817 .after_implement()
11818 .expect_err("no candidate declared anything; this is an ordinary failure");
11819
11820 assert!(
11821 err.to_string().contains("no candidate produced a change"),
11822 "{err}"
11823 );
11824 assert_eq!(runner.state.status, RunStatus::Failed);
11825 }
11826}