1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35 AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36 ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51 GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52 MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, QuotaLoss,
53 ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory, Tally,
54 VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58 ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62const OUTPUT_TAIL: usize = 8_000;
64
65const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106const BASE_SYNC_ROUNDS: usize = 4;
119
120const MAX_FIX_CONTINUATIONS: usize = 2;
136
137#[derive(Clone)]
143struct SeatJob {
144 spec: AgentSpec,
145 seat: SeatState,
146 cwd: PathBuf,
147 prompt: String,
148 timeout: Duration,
149 allow_write: bool,
150 sessions: bool,
151 artifacts: PathBuf,
152 stem: String,
153 handover: Option<String>,
158}
159
160enum AgentOutcome {
172 Ok(AgentOutput),
174 Quota(AgentOutput),
176 Dropped(AgentOutput),
179 Failed(String),
181}
182
183#[derive(Debug, Clone, Default)]
210pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
211
212impl Pause {
213 #[must_use]
215 pub fn new() -> Self {
216 Self::default()
217 }
218
219 pub fn park(&self) {
221 self.0.store(true, Ordering::SeqCst);
222 }
223
224 pub fn park_because(&self, reason: impl Into<String>) {
230 let mut reason_guard = self
231 .1
232 .lock()
233 .unwrap_or_else(std::sync::PoisonError::into_inner);
234 if reason_guard.is_none() {
235 *reason_guard = Some(reason.into());
236 }
237 drop(reason_guard);
238 self.park();
239 }
240
241 #[must_use]
243 pub fn parked(&self) -> bool {
244 self.0.load(Ordering::SeqCst)
245 }
246
247 #[must_use]
249 pub fn reason(&self) -> Option<String> {
250 self.1
251 .lock()
252 .unwrap_or_else(std::sync::PoisonError::into_inner)
253 .clone()
254 }
255}
256
257pub struct Runner {
259 pub state: RunState,
261 roles: ResolvedRoles,
262 sem: Arc<Semaphore>,
263 pause: Pause,
267 interrupt: Pause,
273}
274
275async fn review_base(
280 repo: &Path,
281 remote: &str,
282 base_branch: &str,
283 base_commit: &str,
284 branch: &str,
285) -> String {
286 review_base_checked(repo, remote, base_branch, base_commit, branch)
287 .await
288 .0
289}
290
291async fn review_base_checked(
296 repo: &Path,
297 remote: &str,
298 base_branch: &str,
299 base_commit: &str,
300 branch: &str,
301) -> (String, bool) {
302 let tracking = format!("{remote}/{base_branch}");
303 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
304 if git::rev_exists(repo, &tracking).await
305 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
306 && !mb.is_empty()
307 {
308 return (mb, fresh);
309 }
310 let mb = git::merge_base(repo, base_commit, branch)
311 .await
312 .ok()
313 .filter(|mb| !mb.is_empty())
314 .unwrap_or_else(|| base_commit.to_owned());
315 (mb, false)
316}
317
318pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
322 if !is_review_run(state) {
323 return;
324 }
325 let base = review_base(
326 &state.repo,
327 &state.config.merge.remote,
328 &state.base_branch,
329 &state.base_commit,
330 branch,
331 )
332 .await;
333 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
334 && !subjects.is_empty()
335 && state.reviewed_commits.as_ref() != Some(&subjects)
336 {
337 state.reviewed_commits = Some(subjects);
338 state.save().ok();
339 }
340}
341
342async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
346 let (base, trusted) = review_base_checked(
347 &state.repo,
348 &state.config.merge.remote,
349 &state.base_branch,
350 &state.base_commit,
351 branch,
352 )
353 .await;
354 if !trusted {
355 return None;
356 }
357 git::subjects(&state.repo, &state.base_commit, &base)
358 .await
359 .ok()
360}
361
362fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
366 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
367 if is_leak(computed) {
368 return false;
369 }
370 let cur = current.trim();
371 cur.is_empty()
372 || cur.starts_with(REVIEW_PROMPT_OPENING)
373 || cur.starts_with("chore: land candidate")
374 || cur.starts_with("magi: candidate")
375 || is_leak(cur)
376}
377
378async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
407 let tracking = format!("{remote}/{branch}");
408 let fetched = git::fetch(repo, remote, branch).await;
409 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
410 let local_exists = git::branch_exists(repo, branch).await?;
411 if !fresh {
412 if !local_exists {
413 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
414 }
415 tracing::warn!(
416 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
417 );
418 return Ok(());
419 }
420 let remote_sha = git::rev_parse(repo, &tracking).await?;
421 if !local_exists {
422 git::git(repo, &["branch", branch, &tracking]).await?;
423 return Ok(());
424 }
425 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
426 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
427 return Ok(());
428 }
429 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
430 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
436 .await?
437 {
438 crate::reconcile::Reconciliation::Pushed => {
439 tracing::warn!(
440 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
441 short(&local_sha),
442 short(&remote_sha)
443 );
444 return Ok(());
445 }
446 crate::reconcile::Reconciliation::Placeholder => {}
447 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
448 }
449 }
450 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
451 if !out.ok() {
452 bail!(
453 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
454 short(&local_sha),
455 short(&remote_sha),
456 out.stderr
457 );
458 }
459 tracing::warn!(
460 "local `{branch}` was stale: fast-forwarded {} -> {}",
461 short(&local_sha),
462 short(&remote_sha)
463 );
464 Ok(())
465}
466
467async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
468 let tracking = format!("{remote}/{base_branch}");
469 let fetched = git::fetch(repo, remote, base_branch).await;
470 if let Ok(out) = &fetched
471 && out.ok()
472 && git::rev_exists(repo, &tracking).await
473 {
474 return git::rev_parse(repo, &tracking).await;
475 }
476 let why = match &fetched {
477 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
478 Ok(_) => format!("{remote} has no {base_branch}"),
479 Err(e) => e.to_string(),
480 };
481 bail!(
484 "cannot read {tracking} ({why}); refusing to branch off the local \
485 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
486 base / remote in magi.toml"
487 )
488}
489
490struct FixClaim {
507 path: PathBuf,
508}
509
510impl FixClaim {
511 fn acquire(dir: &Path) -> Result<Self> {
512 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
513 let path = dir.join("fix.lock");
514 match Self::create(&path) {
515 Ok(claim) => Ok(claim),
516 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
517 if Self::reclaim_if_dead(&path) {
518 Self::create(&path).with_context(|| format!("lock {}", path.display()))
519 } else {
520 bail!(
521 "another `magi fix` is already running for this run ({} exists)",
522 path.display()
523 )
524 }
525 }
526 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
527 }
528 }
529
530 fn create(path: &Path) -> std::io::Result<Self> {
531 let mut f = std::fs::OpenOptions::new()
532 .write(true)
533 .create_new(true)
534 .open(path)?;
535 use std::io::Write as _;
536 writeln!(f, "{}", std::process::id())?;
538 Ok(Self {
539 path: path.to_owned(),
540 })
541 }
542
543 fn reclaim_if_dead(path: &Path) -> bool {
547 let dead = std::fs::read_to_string(path)
548 .ok()
549 .and_then(|body| body.trim().parse::<u32>().ok())
550 .is_some_and(|pid| !crate::proc::pid_alive(pid));
551 dead && std::fs::remove_file(path).is_ok()
552 }
553}
554
555impl Drop for FixClaim {
556 fn drop(&mut self) {
557 let _ = std::fs::remove_file(&self.path);
558 }
559}
560
561impl Runner {
562 pub async fn start(
564 repo: &Path,
565 instruction: String,
566 config: Config,
567 origin: Origin,
568 ) -> Result<Self> {
569 Self::start_naming(repo, instruction, "", config, origin).await
570 }
571
572 pub async fn start_naming(
576 repo: &Path,
577 instruction: String,
578 also_scan: &str,
579 config: Config,
580 origin: Origin,
581 ) -> Result<Self> {
582 let repo = git::toplevel(repo).await?;
583 let missing = agent::missing_programs(&config.agents);
584 if !missing.is_empty() {
585 bail!(
586 "these agent programs are not on PATH: {}. Fix the roster in \
587 magi.toml or install them.",
588 missing.join(", ")
589 );
590 }
591 let base_branch =
592 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
593 .await?;
594 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
595 let roles = config.resolve_roles()?;
596 let max_parallel = config.graph.max_parallel.max(1);
597 let seeds = refs::resolve(
600 &repo,
601 &base_commit,
602 &config.merge.remote,
603 &format!("{also_scan}\n{instruction}"),
604 )
605 .await;
606 refs::plan(&repo, &seeds).await?;
607 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
608 state.origin = Some(origin);
612 for seed in &seeds {
613 state.event(
614 "seed",
615 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
616 );
617 }
618 state.seeds = seeds;
619 state.event("start", format!("run {} created", state.id));
620 state.save()?;
621 Ok(Self {
622 state,
623 roles,
624 sem: Arc::new(Semaphore::new(max_parallel)),
625 pause: Pause::new(),
626 interrupt: Pause::new(),
627 })
628 }
629
630 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
644 Self::review_taking_over(repo, branch, config, None, origin).await
645 }
646
647 pub async fn review_taking_over(
654 repo: &Path,
655 branch: &str,
656 config: Config,
657 takeover: Option<crate::handover::Takeover>,
658 origin: Origin,
659 ) -> Result<Self> {
660 let repo = git::toplevel(repo).await?;
661 let missing = agent::missing_programs(&config.agents);
662 if !missing.is_empty() {
663 bail!(
664 "these agent programs are not on PATH: {}. Fix the roster in \
665 magi.toml or install them.",
666 missing.join(", ")
667 );
668 }
669 let base_branch =
670 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
671 .await?;
672 if base_branch == branch {
673 bail!("`{branch}` is the base branch; there is nothing to review against");
674 }
675 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
676
677 let roles = config.resolve_roles()?;
678 let max_parallel = config.graph.max_parallel.max(1);
679 let mut state = RunState::new(
680 repo.clone(),
681 base_branch,
682 base_commit.clone(),
683 String::new(),
684 config,
685 );
686 state.origin = Some(origin);
687
688 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
695 earlier: Vec::new(),
696 home: crate::run::home(),
697 choice: None,
698 });
699 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
700 if let Some(released) = &released {
701 state.event(
702 "release",
703 format!(
704 "took `{branch}` over from run {}: its worktree was released: {}",
705 crate::run::short_of(&released.old_id),
706 released.audit
707 ),
708 );
709 }
710 if let Some(choice) = takeover.choice.as_ref()
714 && let Err(e) =
715 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
716 .await
717 {
718 if let Some(released) = &released {
719 released.restore(&repo, branch).await;
720 }
721 return Err(e.context("applying the owner's answer about the diverged branch"));
722 }
723 let opened =
724 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
725 if opened.is_err()
726 && let Some(released) = &released
727 {
728 released.restore(&repo, branch).await;
729 }
730 opened
731 }
732
733 async fn open_review(
736 repo: &Path,
737 branch: &str,
738 mut state: RunState,
739 roles: ResolvedRoles,
740 max_parallel: usize,
741 base_commit: String,
742 ) -> Result<Self> {
743 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
744 let start = review_base(
747 repo,
748 &state.config.merge.remote,
749 &state.base_branch,
750 &base_commit,
751 branch,
752 )
753 .await;
754 let log = git::log_oneline(repo, &start, branch)
755 .await
756 .unwrap_or_default();
757 let instruction = format!(
758 "Review the work already on branch `{branch}`. There is no task \
759 statement: what the change claims to do is whatever its commits \
760 say.\n\n{}",
761 if log.trim().is_empty() {
762 "(no commit messages)"
763 } else {
764 log.trim()
765 }
766 );
767 state.instruction = instruction;
768 state.reviewed_commits = Some(
769 git::subjects(repo, &start, branch)
770 .await
771 .unwrap_or_default(),
772 );
773
774 let worktree = state.worktree_root().join("under-review");
777 if let Some(parent) = worktree.parent() {
778 tokio::fs::create_dir_all(parent).await.ok();
779 }
780 let path = worktree.to_string_lossy().to_string();
781 git::git(repo, &["worktree", "add", &path, branch])
782 .await
783 .with_context(|| {
784 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
785 })?;
786
787 let setup = match ensure_setup_config(&mut state, repo).await {
788 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
789 Err(e) => Err(e),
790 };
791 if let Err(e) = setup {
792 git::worktree_remove(repo, &worktree).await.ok();
793 return Err(e);
794 }
795 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
796 .await
797 .unwrap_or(0);
798 if commits == 0 {
799 git::worktree_remove(repo, &worktree).await.ok();
800 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
801 }
802 let files = git::changed_files(&worktree, &base_commit, "HEAD")
803 .await
804 .map(|f| f.len())
805 .unwrap_or(0);
806 if files == 0
807 && let (Ok(head_tree), Ok(base_tree)) = (
808 git::tree_of(&worktree, "HEAD").await,
809 git::tree_of(&worktree, &base_commit).await,
810 )
811 && head_tree == base_tree
812 {
813 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
814 git::worktree_remove(repo, &worktree).await.ok();
815 bail!(
816 "`{branch}` at {} has a tree identical to base {}; this usually means \
817 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
818 against `{}/{branch}`) rather than an empty change",
819 short(&head),
820 short(&base_commit),
821 state.config.merge.remote
822 );
823 }
824 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
825 .await
826 .unwrap_or_default();
827
828 state.candidates.push(Candidate {
829 index: 0,
830 label: 'A',
831 agent: EXISTING_BRANCH.to_owned(),
834 branch: branch.to_owned(),
835 worktree,
836 summary: String::new(),
837 stat,
838 files,
839 commits,
840 empty: false,
841 failed: None,
842 verified_noop: None,
843 duration_ms: 0,
844 folded: false,
845 });
846 state.tally = Some(Tally {
847 first_choice: BTreeMap::from([('A', 0)]),
848 borda: BTreeMap::new(),
849 winner: 'A',
850 rankings: 0,
851 unanimous_initial: false,
852 deliberated: false,
853 changed_votes: 0,
854 unanimous_final: false,
855 tie_break: None,
856 judges: 0,
860 present: 0,
861 quorum: 0,
862 met_quorum: true,
863 uncontested: Some("review-only run: nothing competed".to_owned()),
864 });
865 state.status = RunStatus::Reviewing;
866 state.event(
867 "start",
868 format!(
869 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
870 state.id
871 ),
872 );
873 state.save()?;
874 Ok(Self {
875 state,
876 roles,
877 sem: Arc::new(Semaphore::new(max_parallel)),
878 pause: Pause::new(),
879 interrupt: Pause::new(),
880 })
881 }
882
883 pub fn resume(id: &str) -> Result<Self> {
885 let state = RunState::load(id)?;
886 if let Some(to) = &state.released_to {
887 bail!(
888 "run {} cannot be resumed: its worktree was released to run {}",
889 state.short(),
890 crate::run::short_of(to)
891 );
892 }
893 let roles = state.config.resolve_roles()?;
894 let max_parallel = state.config.graph.max_parallel.max(1);
895 Ok(Self {
896 state,
897 roles,
898 sem: Arc::new(Semaphore::new(max_parallel)),
899 pause: Pause::new(),
900 interrupt: Pause::new(),
901 })
902 }
903
904 pub async fn execute(&mut self) -> Result<()> {
911 let result = self.execute_graph().await;
912 self.mark_driver_exited();
913 let ended = if result.is_err() {
914 Some(crate::notices::run_stopped(&self.state.id, &self.state))
915 } else {
916 crate::notices::run_ended(&self.state)
917 };
918 if let Some(notice) = ended {
919 crate::notices::raise(notice);
920 }
921 result
922 }
923
924 fn mark_driver_exited(&mut self) {
933 self.state.driver_exited = true;
934 let pid = std::process::id();
935 let Ok(mut disk) = RunState::load(&self.state.id) else {
936 return;
937 };
938 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
939 return;
940 }
941 disk.driver_exited = true;
942 if let Err(e) = disk.save() {
943 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
944 }
945 }
946
947 async fn execute_graph(&mut self) -> Result<()> {
948 self.state.parked = false;
953 self.state.clear_active();
960 if let Ok(disk) = RunState::load(&self.state.id)
978 && let Some(to) = &disk.released_to
979 {
980 bail!(
981 "run {} cannot continue: its worktree was released to run {}",
982 self.state.short(),
983 crate::run::short_of(to)
984 );
985 }
986 let pid = std::process::id();
987 self.state.driver_pid = Some(pid);
988 self.state.driver_started_at = crate::proc::process_started_at(pid);
989 self.state.driver_exited = false;
990 self.state.save()?;
991 if self.state.status == RunStatus::Stalled {
1004 if self.recover_stall().await? {
1005 self.finish_after_tally().await?;
1006 } else {
1007 self.state.save()?;
1009 }
1010 return Ok(());
1011 }
1012 if self.state.status == RunStatus::Landing {
1022 self.run_land().await?;
1023 self.settle_questions();
1028 return Ok(());
1029 }
1030 if self.state.github_text.is_some()
1035 && self.state.merge.is_none()
1036 && self.state.status == RunStatus::Gating
1037 {
1038 self.merge().await?;
1039 self.state.save()?;
1040 return Ok(());
1041 }
1042 self.prep().await?;
1043 if self.park_here()? {
1044 return Ok(());
1045 }
1046 self.advise().await?;
1047 if self.park_here()? {
1048 return Ok(());
1049 }
1050 self.implement().await?;
1051 if self.park_here()? {
1052 return Ok(());
1053 }
1054 if self.state.status == RunStatus::VerifiedNoop {
1058 return Ok(());
1059 }
1060 self.judge().await?;
1061 if self.park_here()? {
1062 return Ok(());
1063 }
1064 self.deliberate().await?;
1065 if self.park_here()? {
1066 return Ok(());
1067 }
1068 self.vote().await?;
1069 if self.park_here()? {
1070 return Ok(());
1071 }
1072 self.tally()?;
1073 if self.state.status == RunStatus::Stalled {
1078 self.state.save()?;
1082 return Ok(());
1083 }
1084 self.finish_after_tally().await?;
1085 Ok(())
1086 }
1087
1088 fn park_here(&mut self) -> Result<bool> {
1095 if !self.pause.parked() && !self.interrupt.parked() {
1100 return Ok(false);
1101 }
1102 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1103 Some(reason) => format!(
1104 "parked after `{}` ({reason}) — resume to carry on from here",
1105 self.state.status.as_str()
1106 ),
1107 None => format!(
1108 "parked after `{}` — resume to carry on from here",
1109 self.state.status.as_str()
1110 ),
1111 };
1112 self.state.event("park", why);
1113 self.state.parked = true;
1114 self.state.save()?;
1115 Ok(true)
1116 }
1117
1118 pub fn on_pause(&mut self, pause: Pause) {
1120 self.pause = pause;
1121 }
1122
1123 pub fn watch_interrupt(&mut self, pause: Pause) {
1129 self.interrupt = pause;
1130 }
1131
1132 fn settle_questions(&mut self) {
1152 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1153 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1154 }
1155 }
1156
1157 async fn finish_after_tally(&mut self) -> Result<()> {
1160 self.graft().await?;
1163 self.fold_losers().await?;
1164 self.sync_to_base().await?;
1169 if self.state.status == RunStatus::AlreadyInBase {
1170 return Ok(());
1171 }
1172 self.review_loop().await?;
1173 self.sync_to_base().await?;
1174 if self.state.status == RunStatus::AlreadyInBase {
1175 return Ok(());
1176 }
1177 self.gate().await?;
1178 self.merge().await?;
1179 self.state.save()?;
1180 Ok(())
1181 }
1182
1183 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1190 let result = match ensure_setup_config(&mut self.state, repo).await {
1191 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1192 Err(e) => Err(e),
1193 };
1194 if let Err(e) = result {
1195 git::worktree_remove(repo, wt).await.ok();
1196 if in_prep {
1197 self.rollback_prep(repo).await;
1198 }
1199 self.state.status = RunStatus::Blocked;
1200 self.state.event(
1201 "setup",
1202 format!("worktree setup failed in {}: {e:#}", wt.display()),
1203 );
1204 self.state.save()?;
1205 return Err(e);
1206 }
1207 Ok(())
1208 }
1209
1210 async fn rollback_prep(&mut self, repo: &Path) {
1212 let root = self.state.worktree_root();
1213 for c in std::mem::take(&mut self.state.candidates) {
1214 git::worktree_remove(repo, &c.worktree).await.ok();
1215 git::branch_delete(repo, &c.branch).await.ok();
1216 }
1217 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1218 for j in 1..=seats {
1219 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1220 .await
1221 .ok();
1222 }
1223 for k in 1..=self.state.config.graph.advisors {
1224 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1225 .await
1226 .ok();
1227 }
1228 }
1229
1230 async fn prep(&mut self) -> Result<()> {
1231 if !self.state.candidates.is_empty() {
1232 return Ok(());
1233 }
1234 self.state.status = RunStatus::Prep;
1235 let repo = self.state.repo.clone();
1236 let base = self.state.base_commit.clone();
1237 let plan = refs::plan(&repo, &self.state.seeds).await?;
1238 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1239 let root = self.state.worktree_root();
1240 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1241
1242 let hooks_dir = self.state.dir().join("hooks");
1245 if self.state.config.blind.commit_msg_hook {
1246 std::fs::create_dir_all(&hooks_dir)
1247 .with_context(|| format!("create {}", hooks_dir.display()))?;
1248 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1249 let path = hooks_dir.join("commit-msg");
1250 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1251 make_executable(&path)?;
1252 git::acquire_worktree_config(&repo).await?;
1260 self.state.enabled_worktree_config = true;
1261 }
1262
1263 ensure_setup_config(&mut self.state, &repo).await?;
1266
1267 for (index, (spec, label)) in self
1268 .roles
1269 .implementers
1270 .clone()
1271 .into_iter()
1272 .zip(labels)
1273 .enumerate()
1274 {
1275 let branch = self.state.branch_for(label);
1276 let worktree = root.join(format!("cand-{label}"));
1277 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1278 if self.state.config.blind.commit_msg_hook {
1279 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1280 }
1281 git::local_exclude(&worktree, "/.magi/").await?;
1282 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1283 git::worktree_remove(&repo, &worktree).await.ok();
1286 git::branch_delete(&repo, &branch).await.ok();
1287 self.rollback_prep(&repo).await;
1288 self.state.status = RunStatus::Blocked;
1289 self.state
1290 .event("prep", format!("worktree setup failed: {e:#}"));
1291 self.state.save()?;
1292 return Err(e);
1293 }
1294 for pick in &plan.picks {
1295 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1296 self.state.status = RunStatus::Blocked;
1297 self.state
1298 .event("prep", format!("cannot apply referenced commit: {e}"));
1299 self.state.save()?;
1300 return Err(e);
1301 }
1302 }
1303 self.state.candidates.push(Candidate {
1304 index,
1305 label,
1306 agent: spec.id.clone(),
1307 branch,
1308 worktree,
1309 summary: String::new(),
1310 stat: String::new(),
1311 files: 0,
1312 commits: 0,
1313 empty: false,
1314 failed: None,
1315 verified_noop: None,
1316 duration_ms: 0,
1317 folded: false,
1318 });
1319 }
1320
1321 for j in 1..=self.roles.judges.len() {
1322 let wt = root.join(format!("judge-{j}"));
1323 if !wt.exists() {
1324 git::worktree_add_detached(&repo, &wt, &base).await?;
1325 self.setup_seat_worktree(&repo, &wt, true).await?;
1326 }
1327 }
1328
1329 if self.state.config.graph.advise {
1337 for k in 1..=self.state.config.graph.advisors {
1338 let wt = root.join(format!("advisor-{k}"));
1339 if !wt.exists() {
1340 git::worktree_add_detached(&repo, &wt, &base).await?;
1341 self.setup_seat_worktree(&repo, &wt, true).await?;
1342 }
1343 }
1344 }
1345
1346 let authors: Vec<&str> = self
1351 .roles
1352 .implementers
1353 .iter()
1354 .map(|a| a.id.as_str())
1355 .collect();
1356 let overlap: Vec<String> = self
1357 .roles
1358 .judges
1359 .iter()
1360 .enumerate()
1361 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1362 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1363 .collect();
1364 if !overlap.is_empty() {
1365 let note = format!(
1366 "{} also authored a candidate; blind, but the panel is less \
1367 independent than {} distinct agents would be",
1368 overlap.join(", "),
1369 self.roles.judges.len()
1370 );
1371 self.state.event("prep", note);
1372 }
1373
1374 self.state.event(
1375 "prep",
1376 format!(
1377 "{} candidates, {} judges, base {} ({})",
1378 self.state.candidates.len(),
1379 self.roles.judges.len(),
1380 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1381 self.state.base_branch
1382 ),
1383 );
1384 self.state.status = RunStatus::Implementing;
1385 self.state.save()?;
1386 Ok(())
1387 }
1388
1389 async fn advise(&mut self) -> Result<()> {
1422 let implement_untouched = self
1423 .state
1424 .candidates
1425 .iter()
1426 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1427 if !self.state.config.graph.advise || self.state.advise_attempted {
1428 return Ok(());
1429 }
1430 if !implement_untouched {
1431 self.state.event(
1432 "advise",
1433 "skipping the design-deliberation stage: at least one \
1434 candidate already shows implementation progress, so this \
1435 run is past the point the stage exists to run before"
1436 .to_owned(),
1437 );
1438 self.state.advise_attempted = true;
1439 self.state.save()?;
1440 return Ok(());
1441 }
1442 let run_id = self.state.id.clone();
1443 let prompts = self.state.config.prompts.clone();
1444 let instruction = self.state.instruction.clone();
1445 let language = self.state.config.graph.language.clone();
1446 let root = self.state.worktree_root();
1447 let n = self.state.config.graph.advisors;
1448 let where_recorded = self.state.dir().join("run.json");
1449
1450 let seats = match self.state.config.advisors() {
1451 Ok(seats) if !seats.is_empty() => seats,
1452 Ok(_) => {
1453 self.state.event(
1454 "advise",
1455 format!(
1456 "[graph] advisors is 0; skipping the design-deliberation \
1457 stage and continuing without a synthesis brief (see {})",
1458 where_recorded.display()
1459 ),
1460 );
1461 self.state.advise_attempted = true;
1462 self.state.save()?;
1463 return Ok(());
1464 }
1465 Err(e) => {
1466 self.state.event(
1467 "advise",
1468 format!(
1469 "could not resolve advisor seats ({e:#}); continuing \
1470 without a design-deliberation brief (see {})",
1471 where_recorded.display()
1472 ),
1473 );
1474 self.state.advise_attempted = true;
1475 self.state.save()?;
1476 return Ok(());
1477 }
1478 };
1479
1480 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1481 let artifacts = agent::artifacts_dir(&self.state.dir());
1482 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1483
1484 let mut jobs = Vec::new();
1485 for (i, spec) in seats.iter().cloned().enumerate() {
1486 let seat_key = format!("advisor-{}", i + 1);
1487 let seat = self.seat(&seat_key, &spec.id);
1488 jobs.push(SeatJob {
1489 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1490 spec,
1491 seat,
1492 cwd: worktrees[i % worktrees.len()].clone(),
1493 timeout,
1494 allow_write: false,
1495 sessions: false,
1496 artifacts: artifacts.clone(),
1497 stem: seat_key,
1498 handover: None,
1499 });
1500 }
1501
1502 self.state.event(
1503 "advise",
1504 format!(
1505 "{} advisor seat(s) sketching a design in parallel",
1506 jobs.len()
1507 ),
1508 );
1509 let mut quota_losses = Vec::new();
1510 let cache = self.state.config.cache_dir();
1511 let ctx = WaveCtx {
1512 carry_seats: false,
1513 run: &run_id,
1514 node: "advise",
1515 prompts: &prompts,
1516 cache: cache.as_deref(),
1517 round: None,
1518 };
1519 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1520 let results = ask_json_wave::<Proposal>(
1521 jobs,
1522 Arc::clone(&self.sem),
1523 self.state.config.graph.retries,
1524 &advisor_roster,
1525 &ctx,
1526 &mut quota_losses,
1527 &mut self.state,
1528 &|p: &Proposal| p.validate(),
1529 )
1530 .await;
1531 self.state.quota.extend(quota_losses);
1532
1533 let mut records = Vec::with_capacity(results.len());
1534 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1535 let agent_id = seat.agent.clone();
1536 self.state.seats.insert(seat.key.clone(), seat);
1537 match res {
1538 Ok((proposal, out)) => {
1539 self.state
1540 .event("advise", format!("advisor-{} proposed a design", i + 1));
1541 records.push(advise::AdvisorRecord::proposed(
1542 i + 1,
1543 agent_id,
1544 proposal,
1545 out.duration_ms,
1546 ));
1547 }
1548 Err(e) => {
1549 self.state.event(
1550 "advise",
1551 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1552 );
1553 records.push(advise::AdvisorRecord::failed(
1554 i + 1,
1555 agent_id,
1556 e.to_string(),
1557 ));
1558 }
1559 }
1560 }
1561
1562 let mut advice = advise::Advice {
1563 records,
1564 synthesis: None,
1565 };
1566 if advice.proposals().is_empty() {
1567 self.state.event(
1568 "advise",
1569 "no advisor produced a usable proposal; continuing without a \
1570 synthesis brief"
1571 .to_owned(),
1572 );
1573 } else {
1574 match self
1575 .synthesize_brief(
1576 &advice,
1577 &instruction,
1578 &language,
1579 &worktrees[0],
1580 &artifacts,
1581 &run_id,
1582 &prompts,
1583 cache.as_deref(),
1584 )
1585 .await
1586 {
1587 Ok(Some(text)) => {
1588 self.state.event(
1589 "advise",
1590 "synthesized a design brief for the implementer".to_owned(),
1591 );
1592 advice.synthesis = Some(text);
1593 }
1594 Ok(None) => {
1595 self.state.event(
1596 "advise",
1597 "the synthesis seat produced nothing usable; continuing \
1598 without a design brief"
1599 .to_owned(),
1600 );
1601 }
1602 Err(e) => {
1603 self.state.event(
1604 "advise",
1605 format!("could not synthesize a design brief: {e:#}"),
1606 );
1607 }
1608 }
1609 }
1610 advise::apply_reflection(&mut advice);
1611
1612 self.state.advice = Some(advice);
1613 self.state.advise_attempted = true;
1614 self.state.save()?;
1615 Ok(())
1616 }
1617
1618 #[allow(clippy::too_many_arguments)]
1630 async fn synthesize_brief(
1631 &mut self,
1632 advice: &advise::Advice,
1633 instruction: &str,
1634 language: &str,
1635 cwd: &Path,
1636 artifacts: &Path,
1637 run_id: &str,
1638 prompts: &Prompts,
1639 cache: Option<&Path>,
1640 ) -> Result<Option<String>> {
1641 let chain = agent::pick_chain(
1642 &self.state.config.agents,
1643 self.state.config.roles.synthesizer.as_ref(),
1644 &agent::installed,
1645 "synthesizer",
1646 )?;
1647 let proposals = advice.proposals();
1648 let mut prompt = prompt::with_overlay(
1649 prompt::synthesize_brief(instruction, &proposals, language),
1650 prompts.overlay("advise"),
1651 );
1652 if cache.is_some() {
1653 prompt.push('\n');
1658 prompt.push_str(&prompt::build_cache_note("advise", false));
1659 }
1660 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1661 let mut last = None;
1666 for (n, spec) in chain.iter().enumerate() {
1667 if n > 0 {
1668 self.state
1669 .event("advise", format!("synthesis falling back to {}", spec.id));
1670 }
1671 let mut seat = self.seat("advise-synthesis", &spec.id);
1672 let outcome = agent::invoke(
1673 spec,
1674 &mut seat,
1675 &Invocation {
1676 cwd,
1677 prompt: &prompt,
1678 timeout,
1679 allow_write: false,
1680 unsandboxed: false,
1681 sessions: false,
1682 artifacts,
1683 stem: &if n == 0 {
1684 "advise-synthesis".to_owned()
1685 } else {
1686 format!("advise-synthesis-{}", spec.id)
1687 },
1688 run: run_id,
1689 node: "advise",
1690 cache_dir: None,
1691 attachments: &[],
1692 writable: &[],
1693 },
1694 )
1695 .await;
1696 if outcome.is_ok() {
1697 self.state.seats.insert(seat.key.clone(), seat);
1698 }
1699 let advance = agent::chain_advances(&outcome);
1700 last = Some(outcome);
1701 if !advance {
1702 break;
1703 }
1704 }
1705 let out = last.expect("a chain holds at least one agent")?;
1708 if !out.usable() {
1709 return Ok(None);
1710 }
1711 let text =
1712 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1713 Ok((!text.trim().is_empty()).then_some(text))
1714 }
1715
1716 async fn implement(&mut self) -> Result<()> {
1719 let run_id = self.state.id.clone();
1724 let prompts = self.state.config.prompts.clone();
1725 let todo: Vec<usize> = self
1726 .state
1727 .candidates
1728 .iter()
1729 .enumerate()
1730 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1731 .map(|(i, _)| i)
1732 .collect();
1733 if todo.is_empty() {
1734 return self.after_implement();
1735 }
1736 self.state.status = RunStatus::Implementing;
1737
1738 let language = self.state.config.graph.language.clone();
1739 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1740 let sessions = self.state.config.graph.sessions;
1741 let artifacts = agent::artifacts_dir(&self.state.dir());
1742 let brief = self
1746 .state
1747 .advice
1748 .as_ref()
1749 .and_then(|a| a.synthesis.as_deref())
1750 .map(str::to_owned);
1751 let attachments = self.state.attachments.clone();
1752
1753 let mut jobs = Vec::new();
1754 for &i in &todo {
1755 let (index, label, worktree) = {
1756 let c = &self.state.candidates[i];
1757 (c.index, c.label, c.worktree.clone())
1758 };
1759 let spec = self.roles.implementers[index].clone();
1760 let seat_key = format!("impl-{label}");
1761 let seat = self.seat(&seat_key, &spec.id);
1762 let instruction = seeded_instruction(&self.state);
1763 jobs.push(SeatJob {
1764 spec,
1765 seat,
1766 prompt: prompt::implement(
1767 &instruction,
1768 &worktree.to_string_lossy(),
1769 &language,
1770 brief.as_deref(),
1771 &attachments,
1772 ),
1773 cwd: worktree,
1774 timeout,
1775 allow_write: true,
1776 sessions,
1777 artifacts: artifacts.clone(),
1778 stem: format!("impl-{label}"),
1779 handover: None,
1780 });
1781 }
1782
1783 self.state.event(
1784 "implement",
1785 format!("{} candidates in parallel", jobs.len()),
1786 );
1787 let mut sent = jobs.clone();
1793 let cache = self.state.config.cache_dir();
1794 let ctx = WaveCtx {
1795 carry_seats: false,
1796 run: &run_id,
1797 node: "implement",
1798 prompts: &prompts,
1799 cache: cache.as_deref(),
1800 round: None,
1801 };
1802 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1803 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1804 .await;
1805 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1806 .await;
1807 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1808 .await;
1809
1810 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1811 let seat_key = seat.key.clone();
1812 let agent = seat.agent.clone();
1821 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1822 self.state.seats.insert(seat.key.clone(), seat);
1823 let label = self.state.candidates[i].label;
1824 let worktree = self.state.candidates[i].worktree.clone();
1825 let base = self.state.base_commit.clone();
1826
1827 let (summary, duration, failed, verified_claim) = match out {
1828 AgentOutcome::Ok(o) => {
1829 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1830 let failed = (!o.usable()).then(|| {
1831 if o.timed_out {
1832 "agent timed out".to_owned()
1833 } else {
1834 format!("agent exited with {:?}", o.exit_code)
1835 }
1836 });
1837 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1838 (text, o.duration_ms, failed, verified_claim)
1839 }
1840 AgentOutcome::Dropped(o) => {
1846 let why = o
1847 .dropped
1848 .as_ref()
1849 .map(|d| d.why.as_str())
1850 .unwrap_or("the CLI ended the stream without delivering its answer");
1851 (
1852 String::new(),
1853 o.duration_ms,
1854 Some(format!("the CLI dropped the stream ({why})")),
1855 None,
1856 )
1857 }
1858 AgentOutcome::Quota(o) => {
1859 self.state.quota.push(QuotaLoss {
1860 seat: seat_key,
1861 node: "implement".to_owned(),
1862 at: Timestamp::now(),
1863 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1864 });
1865 (
1866 String::new(),
1867 o.duration_ms,
1868 Some("rate limited (quota); produced no change".to_owned()),
1869 None,
1870 )
1871 }
1872 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1873 };
1874
1875 let rescued = match git::rescue_commit(
1878 &worktree,
1879 &format!("magi: candidate {label} (uncommitted work)"),
1880 )
1881 .await
1882 {
1883 Ok(r) => {
1884 self.state.note_withheld("implement", &r.withheld);
1885 r.committed
1886 }
1887 Err(_) => false,
1888 };
1889 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1890 .await
1891 .unwrap_or(0);
1892 let patch = git::diff(&worktree, &base, "HEAD")
1893 .await
1894 .unwrap_or_default();
1895 let stat = git::diff_stat(&worktree, &base, "HEAD")
1896 .await
1897 .unwrap_or_default();
1898 let files = git::changed_files(&worktree, &base, "HEAD")
1899 .await
1900 .map(|f| f.len())
1901 .unwrap_or(0);
1902 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1903
1904 let c = &mut self.state.candidates[i];
1905 if !exhausted_the_fallback_chain {
1906 c.agent = agent;
1907 }
1908 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1909 c.stat = stat;
1910 c.files = files;
1911 c.commits = commits;
1912 c.duration_ms = duration;
1913 c.empty = commits == 0 || patch.trim().is_empty();
1914 c.failed = match failed {
1917 Some(_) if c.empty => failed,
1918 _ => None,
1919 };
1920 c.verified_noop = if c.empty { verified_claim } else { None };
1925 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1926 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1927 (None, true, Some(_), _) => {
1928 format!("candidate {label}: no change produced (agent-verified no-op)")
1929 }
1930 (None, true, None, _) => format!("candidate {label}: no change produced"),
1931 (None, false, _, true) => {
1932 format!(
1933 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1934 )
1935 }
1936 (None, false, _, false) => {
1937 format!("candidate {label}: {files} files, {commits} commits")
1938 }
1939 };
1940 self.state.event("implement", note);
1941 self.state.save()?;
1942 }
1943
1944 self.after_implement()
1945 }
1946
1947 async fn resume_undelivered(
1975 &mut self,
1976 results: &mut [(usize, SeatState, AgentOutcome)],
1977 sent: &[SeatJob],
1978 prompts: &Prompts,
1979 run_id: &str,
1980 ) {
1981 for (wi, seat, out) in results.iter_mut() {
1982 let Some(dropped) = (match &*out {
1983 AgentOutcome::Dropped(o) => o.dropped.clone(),
1984 _ => None,
1985 }) else {
1986 continue;
1987 };
1988 let Some(job) = sent.get(*wi) else { continue };
1989 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1991 self.state.event(
1992 "implement",
1993 format!(
1994 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1995 work is in the tree",
1996 seat.key, dropped.output_tokens, dropped.why
1997 ),
1998 );
1999 continue;
2000 }
2001 if !has_context(&job.spec, seat, job.sessions) {
2009 self.state.event(
2010 "implement",
2011 format!(
2012 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2013 is no session left to resume",
2014 seat.key, dropped.output_tokens, dropped.why
2015 ),
2016 );
2017 continue;
2018 }
2019 self.state.event(
2020 "implement",
2021 format!(
2022 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2023 conversation",
2024 seat.key, dropped.output_tokens, dropped.why
2025 ),
2026 );
2027 let mut retry = job.clone();
2028 retry.seat = seat.clone();
2029 retry.prompt = prompt::resume_after_drop(&dropped.why);
2030 retry.timeout = retry_budget(job.timeout, true);
2031 retry.stem = format!("{}-resume", job.stem);
2032 let cache = self.state.config.cache_dir();
2033 let ctx = WaveCtx {
2034 carry_seats: false,
2035 run: run_id,
2036 node: "implement",
2037 prompts,
2038 cache: cache.as_deref(),
2039 round: None,
2040 };
2041 let (resumed_seat, resumed) =
2042 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2043 *seat = resumed_seat;
2044 *out = resumed;
2045 }
2046 }
2047
2048 async fn resume_seat_handovers(
2113 &mut self,
2114 results: &mut [(usize, SeatState, AgentOutcome)],
2115 sent: &mut [SeatJob],
2116 prompts: &Prompts,
2117 run_id: &str,
2118 ) {
2119 let instruction = seeded_instruction(&self.state);
2120 let language = self.state.config.graph.language.clone();
2121 let brief = self
2122 .state
2123 .advice
2124 .as_ref()
2125 .and_then(|a| a.synthesis.as_deref())
2126 .map(str::to_owned);
2127 let attachments = self.state.attachments.clone();
2128 for (wi, seat, out) in results.iter_mut() {
2129 let others: BTreeSet<String> = sent
2132 .iter()
2133 .enumerate()
2134 .filter(|(j, _)| j != wi)
2135 .map(|(_, j)| j.spec.id.clone())
2136 .collect();
2137 let Some(job) = sent.get_mut(*wi) else {
2138 continue;
2139 };
2140 let start = self
2145 .roles
2146 .implementer_roster
2147 .iter()
2148 .position(|s| s.id == job.spec.id)
2149 .unwrap_or(0);
2150 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2151 let mut fallback_attempt = 0usize;
2152 let mut prev: Option<FailClass> = None;
2153 while let Some(cur) = FailClass::of(&*out) {
2154 if !should_hand_over(prev.as_ref(), &cur) {
2155 break;
2156 }
2157 let Some(next) =
2158 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2159 .cloned()
2160 else {
2161 break;
2162 };
2163 tried.insert(next.id.clone());
2164 fallback_attempt += 1;
2165
2166 if let Ok(r) = git::rescue_commit(
2167 &job.cwd,
2168 &format!(
2169 "magi: candidate {} (uncommitted work before {} fallback)",
2170 seat.key,
2171 if cur == FailClass::Quota {
2172 "quota"
2173 } else {
2174 "handover"
2175 }
2176 ),
2177 )
2178 .await
2179 {
2180 self.state.note_withheld("implement", &r.withheld);
2181 }
2182
2183 record_handover(
2184 &mut self.state,
2185 "implement",
2186 &seat.key,
2187 &seat.agent,
2188 &next.id,
2189 &cur,
2190 &fail_reason(&*out),
2191 );
2192 prev = Some(cur.clone());
2193
2194 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2195 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2196 job.spec = next.clone();
2204 let mut retry = job.clone();
2205 retry.seat = new_seat;
2206 retry.prompt = prompt::implement(
2207 &instruction,
2208 &job.cwd.to_string_lossy(),
2209 &language,
2210 brief.as_deref(),
2211 &attachments,
2212 );
2213 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2214 let cache = self.state.config.cache_dir();
2215 let ctx = WaveCtx {
2216 carry_seats: false,
2217 run: run_id,
2218 node: "implement",
2219 prompts,
2220 cache: cache.as_deref(),
2221 round: None,
2222 };
2223 let (fallback_seat, fallback_out) = run_one(
2224 retry,
2225 Arc::clone(&self.sem),
2226 &ctx,
2227 &mut self.state,
2228 fallback_attempt,
2229 )
2230 .await;
2231 *seat = fallback_seat;
2232 *out = fallback_out;
2233 }
2234 }
2235 }
2236
2237 async fn resume_unconfirmed_commands(
2261 &mut self,
2262 results: &mut [(usize, SeatState, AgentOutcome)],
2263 sent: &[SeatJob],
2264 prompts: &Prompts,
2265 run_id: &str,
2266 ) {
2267 for (wi, seat, out) in results.iter_mut() {
2268 let AgentOutcome::Ok(o) = &*out else {
2269 continue;
2270 };
2271 if !has_unconfirmed_command(&o.commands) {
2272 continue;
2273 }
2274 let Some(job) = sent.get(*wi) else { continue };
2275 if !has_context(&job.spec, seat, job.sessions) {
2276 self.state.event(
2277 "implement",
2278 format!(
2279 "{}: the reply named a command whose own CLI never confirmed the exit \
2280 status of, but there is no session left to resume",
2281 seat.key
2282 ),
2283 );
2284 continue;
2285 }
2286 self.state.event(
2287 "implement",
2288 format!(
2289 "{}: the reply named a command whose own CLI never confirmed the exit \
2290 status of; resuming the conversation",
2291 seat.key
2292 ),
2293 );
2294 let mut retry = job.clone();
2295 retry.seat = seat.clone();
2296 retry.prompt = prompt::resume_incomplete(
2297 "a command in your last reply had no confirmed exit status",
2298 );
2299 retry.timeout = retry_budget(job.timeout, true);
2300 retry.stem = format!("{}-confirm", job.stem);
2301 let cache = self.state.config.cache_dir();
2302 let ctx = WaveCtx {
2303 carry_seats: false,
2304 run: run_id,
2305 node: "implement",
2306 prompts,
2307 cache: cache.as_deref(),
2308 round: None,
2309 };
2310 let (resumed_seat, resumed) =
2311 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2312 *seat = resumed_seat;
2313 *out = resumed;
2314 }
2315 }
2316
2317 async fn continue_fix_report(
2338 &mut self,
2339 mut seat: SeatState,
2340 parse_err: String,
2341 job: &SeatJob,
2342 prompts: &Prompts,
2343 run_id: &str,
2344 round: usize,
2345 ) -> (
2346 SeatState,
2347 Option<FixReport>,
2348 Option<String>,
2349 ContinuationRecord,
2350 ) {
2351 let mut last_err = parse_err;
2352 let mut cumulative_wait_ms = 0u64;
2353 let mut attempts = 0usize;
2354 loop {
2355 if !has_context(&job.spec, &seat, job.sessions) {
2356 self.state.event(
2357 "fix",
2358 format!(
2359 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2360 session left to resume into"
2361 ),
2362 );
2363 let outcome = if attempts == 0 {
2364 ContinuationOutcome::NoSession
2365 } else {
2366 ContinuationOutcome::Exhausted
2367 };
2368 return (
2369 seat,
2370 None,
2371 Some(format!("unparsable fix report: {last_err}")),
2372 ContinuationRecord {
2373 attempts,
2374 cumulative_wait_ms,
2375 outcome,
2376 },
2377 );
2378 }
2379 if attempts >= MAX_FIX_CONTINUATIONS {
2380 self.state.event(
2381 "fix",
2382 format!(
2383 "round {round}: fixer's reply still had no adoption report after \
2384 {attempts} continuation(s) ({last_err}); giving up"
2385 ),
2386 );
2387 return (
2388 seat,
2389 None,
2390 Some(format!(
2391 "unparsable fix report after {attempts} continuation(s): {last_err}"
2392 )),
2393 ContinuationRecord {
2394 attempts,
2395 cumulative_wait_ms,
2396 outcome: ContinuationOutcome::Exhausted,
2397 },
2398 );
2399 }
2400 attempts += 1;
2401 self.state.event(
2402 "fix",
2403 format!(
2404 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2405 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2406 ),
2407 );
2408 let mut retry = job.clone();
2409 retry.seat = seat.clone();
2410 retry.prompt = prompt::resume_incomplete(&last_err);
2411 retry.timeout = retry_budget(job.timeout, true);
2412 retry.stem = format!("{}-continue{attempts}", job.stem);
2413 let cache = self.state.config.cache_dir();
2414 let ctx = WaveCtx {
2415 carry_seats: false,
2416 run: run_id,
2417 node: "fix",
2418 prompts,
2419 cache: cache.as_deref(),
2420 round: Some(round),
2421 };
2422 let (resumed_seat, resumed_out) = run_one(
2423 retry,
2424 Arc::clone(&self.sem),
2425 &ctx,
2426 &mut self.state,
2427 attempts,
2428 )
2429 .await;
2430 seat = resumed_seat;
2431 match resumed_out {
2432 AgentOutcome::Ok(o) => {
2433 cumulative_wait_ms += o.duration_ms;
2434 match verdict::extract_json::<FixReport>(&o.text) {
2435 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2436 self.state.event(
2437 "fix",
2438 format!(
2439 "round {round}: fixer's adoption report recovered after \
2440 {attempts} continuation(s)"
2441 ),
2442 );
2443 return (
2444 seat,
2445 Some(report),
2446 None,
2447 ContinuationRecord {
2448 attempts,
2449 cumulative_wait_ms,
2450 outcome: ContinuationOutcome::Resumed,
2451 },
2452 );
2453 }
2454 Ok(_) => {
2462 last_err = "the reply parsed, but it reported a command whose own CLI \
2463 never confirmed an exit status"
2464 .to_owned();
2465 }
2466 Err(e) => last_err = e.to_string(),
2467 }
2468 }
2469 AgentOutcome::Quota(o) => {
2470 cumulative_wait_ms += o.duration_ms;
2471 self.state.quota.push(QuotaLoss {
2472 seat: seat.key.clone(),
2473 node: "fix".to_owned(),
2474 at: Timestamp::now(),
2475 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2476 });
2477 self.state.event(
2478 "fix",
2479 format!(
2480 "round {round}: continuation rate limited (quota); not retrying now"
2481 ),
2482 );
2483 return (
2484 seat,
2485 None,
2486 Some("rate limited (quota) while recovering the fix report".to_owned()),
2487 ContinuationRecord {
2488 attempts,
2489 cumulative_wait_ms,
2490 outcome: ContinuationOutcome::QuotaLost,
2491 },
2492 );
2493 }
2494 AgentOutcome::Dropped(o) => {
2495 cumulative_wait_ms += o.duration_ms;
2496 let why = o
2497 .dropped
2498 .as_ref()
2499 .map(|d| d.why.as_str())
2500 .unwrap_or("the CLI ended the stream without delivering its answer");
2501 last_err = format!("the CLI dropped the stream ({why})");
2502 }
2503 AgentOutcome::Failed(e) => last_err = e,
2504 }
2505 }
2506 }
2507
2508 fn after_implement(&mut self) -> Result<()> {
2509 if self.state.leaks.is_empty() {
2511 let cfg = self.state.config.blind.clone();
2512 let mut leaks = Vec::new();
2513 for c in &self.state.candidates {
2514 let Some(patch) =
2515 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2516 else {
2517 continue;
2518 };
2519 leaks.extend(blind::scan(
2520 &format!("candidate {} patch", c.label),
2521 &patch,
2522 &cfg.vendor_tokens,
2523 ));
2524 }
2525 if !leaks.is_empty() {
2526 let summary = leaks
2527 .iter()
2528 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2529 .collect::<Vec<_>>()
2530 .join(", ");
2531 match cfg.on_leak {
2532 LeakPolicy::Fail => {
2533 self.state.status = RunStatus::Failed;
2534 self.state
2535 .event("blind", format!("vendor text in a patch: {summary}"));
2536 self.state.leaks = leaks;
2537 self.state.save()?;
2538 self.settle_questions();
2539 bail!(
2540 "blind.on_leak = \"fail\" and vendor text reached a \
2541 judged patch: {summary}"
2542 );
2543 }
2544 LeakPolicy::Redact => self.state.event(
2545 "blind",
2546 format!("redacting vendor text for judging: {summary}"),
2547 ),
2548 LeakPolicy::Warn => self.state.event(
2549 "blind",
2550 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2551 ),
2552 }
2553 self.state.leaks = leaks;
2554 }
2555 }
2556
2557 if self.state.viable().is_empty() {
2558 if self.state.all_candidates_verified_noop() {
2559 self.state.status = RunStatus::VerifiedNoop;
2570 self.state.save()?;
2571 self.settle_questions();
2572 return Ok(());
2573 }
2574 self.state.status = RunStatus::Failed;
2575 self.state.save()?;
2576 self.settle_questions();
2577 bail!("no candidate produced a change; nothing to judge");
2578 }
2579 self.state.status = RunStatus::Judging;
2580 self.state.save()?;
2581 Ok(())
2582 }
2583
2584 async fn judge(&mut self) -> Result<()> {
2587 let run_id = self.state.id.clone();
2592 let prompts = self.state.config.prompts.clone();
2593 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2594 return Ok(());
2595 }
2596 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2597 if viable.len() == 1 {
2598 self.state.judge_skipped = true;
2605 self.state.event(
2606 "judge",
2607 format!(
2608 "only candidate {} produced a change; judging skipped",
2609 viable[0].label
2610 ),
2611 );
2612 self.state.save()?;
2613 return Ok(());
2614 }
2615 self.state.status = RunStatus::Judging;
2616
2617 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2618 let language = self.state.config.graph.language.clone();
2619 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2620 let sessions = self.state.config.graph.sessions;
2621 let artifacts = agent::artifacts_dir(&self.state.dir());
2622 let root = self.state.worktree_root();
2623 let base_short = short(&self.state.base_commit);
2624
2625 let mut jobs = Vec::new();
2626 let mut orders = Vec::new();
2627 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2628 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2629 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2630 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2631 let seat_key = format!("judge-{}", j + 1);
2632 let seat = self.seat(&seat_key, &spec.id);
2633 jobs.push(SeatJob {
2634 prompt: prompt::judge(
2635 &self.state.instruction,
2636 &views,
2637 self.roles.judges.len(),
2638 &base_short,
2639 &language,
2640 ),
2641 spec,
2642 seat,
2643 cwd: root.join(format!("judge-{}", j + 1)),
2644 timeout,
2645 allow_write: false,
2646 sessions,
2647 artifacts: artifacts.clone(),
2648 stem: format!("judge-{}", j + 1),
2649 handover: None,
2650 });
2651 }
2652
2653 self.state.event(
2654 "judge",
2655 format!(
2656 "{} judges ranking {} candidates blind",
2657 jobs.len(),
2658 viable.len()
2659 ),
2660 );
2661 let labels_for_check = labels.clone();
2662 let mut quota_losses = Vec::new();
2663 let cache = self.state.config.cache_dir();
2664 let ctx = WaveCtx {
2665 carry_seats: false,
2666 run: &run_id,
2667 node: "judge",
2668 prompts: &prompts,
2669 cache: cache.as_deref(),
2670 round: None,
2671 };
2672 let results = ask_json_wave::<Ranking>(
2673 jobs,
2674 Arc::clone(&self.sem),
2675 self.state.config.graph.retries,
2676 &self.roles.judge_roster,
2677 &ctx,
2678 &mut quota_losses,
2679 &mut self.state,
2680 &move |r: &Ranking| r.validate(&labels_for_check),
2681 )
2682 .await;
2683 self.state.quota.extend(quota_losses);
2684
2685 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2686 let agent_id = seat.agent.clone();
2687 self.state.seats.insert(seat.key.clone(), seat);
2688 let mut record = Judgement {
2689 judge: j + 1,
2690 seat: format!("judge-{}", j + 1),
2691 agent: agent_id,
2692 ranking: Vec::new(),
2693 reasons: BTreeMap::new(),
2694 confidence: None,
2695 strengths: BTreeMap::new(),
2696 order: orders[j].clone(),
2697 failed: None,
2698 duration_ms: 0,
2699 };
2700 match res {
2701 Ok((ranking, out)) => {
2702 record.ranking = ranking.normalized();
2703 record.reasons = ranking.reasons;
2704 record.strengths = ranking.strengths;
2705 record.confidence = ranking.confidence;
2706 record.duration_ms = out.duration_ms;
2707 self.state.event(
2708 "judge",
2709 format!(
2710 "judge {} ranked {}",
2711 j + 1,
2712 record.ranking.iter().collect::<String>()
2713 ),
2714 );
2715 }
2716 Err(e) => {
2717 record.failed = Some(e.to_string());
2718 self.state
2719 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2720 }
2721 }
2722 self.state.judgements.push(record);
2723 self.state.save()?;
2724 }
2725 Ok(())
2726 }
2727
2728 async fn deliberate(&mut self) -> Result<()> {
2731 let run_id = self.state.id.clone();
2736 let prompts = self.state.config.prompts.clone();
2737 if !self.state.deliberation.is_empty() {
2738 return Ok(());
2739 }
2740 let tops: Vec<char> = self
2741 .state
2742 .judgements
2743 .iter()
2744 .filter_map(|j| j.ranking.first().copied())
2745 .collect();
2746 let rounds = self.state.config.graph.deliberate_rounds;
2747 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2748 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2749 self.state.event(
2750 "deliberate",
2751 format!("judges agreed on {} outright; no deliberation", tops[0]),
2752 );
2753 }
2754 self.state.status = RunStatus::Voting;
2755 self.state.save()?;
2756 return Ok(());
2757 }
2758
2759 self.state.status = RunStatus::Deliberating;
2760 self.state.event(
2761 "deliberate",
2762 format!(
2763 "split: first choices were {} — opening {rounds} round(s)",
2764 tops.iter().collect::<String>()
2765 ),
2766 );
2767
2768 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2769 let language = self.state.config.graph.language.clone();
2770 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2771 let sessions = self.state.config.graph.sessions;
2772 let artifacts = agent::artifacts_dir(&self.state.dir());
2773 let root = self.state.worktree_root();
2774 let base_short = short(&self.state.base_commit);
2775
2776 for round in 1..=rounds {
2780 let mut turns: Vec<DeliberationTurn> = Vec::new();
2781 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2782 if self.state.judgements[j].failed.is_some() {
2783 continue;
2784 }
2785 let seat_key = format!("judge-{}", j + 1);
2786 let spec = self.occupant(&seat_key, spec);
2787 let mut seat = self.seat(&seat_key, &spec.id);
2788 let transcript = self.transcript(&turns, j);
2789 let build = |context: Option<&str>| {
2790 prompt::deliberate(
2791 &self.state.instruction,
2792 context,
2793 &transcript,
2794 round,
2795 rounds,
2796 &language,
2797 )
2798 };
2799 let block = self.candidate_block(&viable, &base_short);
2800 let full = build(Some(&block));
2801 let text = if has_context(&spec, &seat, sessions) {
2802 build(None)
2803 } else {
2804 full.clone()
2805 };
2806 let job = SeatJob {
2807 spec,
2808 seat: seat.clone(),
2809 prompt: text,
2810 cwd: root.join(format!("judge-{}", j + 1)),
2811 timeout,
2812 allow_write: false,
2813 sessions,
2814 artifacts: artifacts.clone(),
2815 stem: format!("delib-{round}-judge-{}", j + 1),
2816 handover: Some(full),
2817 };
2818 let cache = self.state.config.cache_dir();
2819 let ctx = WaveCtx {
2820 carry_seats: false,
2821 run: &run_id,
2822 node: "deliberate",
2823 prompts: &prompts,
2824 cache: cache.as_deref(),
2825 round: None,
2826 };
2827 let mut losses = Vec::new();
2831 let mut results = ask_wave_with::<String>(
2832 vec![job],
2833 Arc::clone(&self.sem),
2834 0,
2835 &self.roles.judge_roster,
2836 &ctx,
2837 &mut losses,
2838 &mut self.state,
2839 &|text: &str| {
2840 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2841 },
2842 )
2843 .await;
2844 self.state.quota.extend(losses);
2845 let (updated, res, _) = results.pop().expect("one job in, one result out");
2846 seat = updated;
2847 let agent_id = seat.agent.clone();
2848 self.state.seats.insert(seat.key.clone(), seat);
2849 let body = match res {
2850 Ok((body, _)) => body,
2851 Err(e) => {
2854 self.state
2855 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2856 continue;
2857 }
2858 };
2859 let tentative = verdict::extract_json::<Position>(&body)
2860 .ok()
2861 .and_then(|p| p.tentative)
2862 .and_then(|s| s.trim().chars().next())
2863 .map(|c| c.to_ascii_uppercase());
2864 self.state.event(
2865 "deliberate",
2866 format!(
2867 "round {round}: judge {} now favours {}",
2868 j + 1,
2869 tentative.map_or("—".to_owned(), |c| c.to_string())
2870 ),
2871 );
2872 turns.push(DeliberationTurn {
2873 judge: j + 1,
2874 agent: agent_id,
2875 body: blind::sanitize_prose(&body, &self.state.config.blind),
2876 tentative,
2877 });
2878 }
2879 self.state
2880 .deliberation
2881 .push(DeliberationRound { round, turns });
2882 self.state.save()?;
2883 }
2884
2885 self.state.status = RunStatus::Voting;
2886 self.state.save()?;
2887 Ok(())
2888 }
2889
2890 async fn vote(&mut self) -> Result<()> {
2893 let run_id = self.state.id.clone();
2898 let prompts = self.state.config.prompts.clone();
2899 if !self.state.votes.is_empty() {
2900 return Ok(());
2901 }
2902 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2903 if viable.len() == 1 {
2904 return Ok(());
2905 }
2906 self.state.status = RunStatus::Voting;
2907
2908 let language = self.state.config.graph.language.clone();
2909 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2910 let sessions = self.state.config.graph.sessions;
2911 let artifacts = agent::artifacts_dir(&self.state.dir());
2912 let root = self.state.worktree_root();
2913 let base_short = short(&self.state.base_commit);
2914 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2915
2916 let mut jobs = Vec::new();
2917 let mut seats_at = Vec::new();
2918 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2919 if self
2920 .state
2921 .judgements
2922 .get(j)
2923 .is_some_and(|r| r.failed.is_some())
2924 {
2925 continue;
2926 }
2927 let seat_key = format!("judge-{}", j + 1);
2928 let spec = self.occupant(&seat_key, spec);
2929 let seat = self.seat(&seat_key, &spec.id);
2930 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2931 let text = if has_context(&spec, &seat, sessions) {
2932 prompt::final_vote(&viable, &language)
2933 } else {
2934 full.clone()
2935 };
2936 jobs.push(SeatJob {
2937 spec,
2938 seat,
2939 prompt: text,
2940 cwd: root.join(format!("judge-{}", j + 1)),
2941 timeout,
2942 allow_write: false,
2943 sessions,
2944 artifacts: artifacts.clone(),
2945 stem: format!("vote-judge-{}", j + 1),
2946 handover: Some(full),
2947 });
2948 seats_at.push(j);
2949 }
2950
2951 self.state.event(
2952 "vote",
2953 format!(
2954 "collecting {} final votes one by one, privately",
2955 jobs.len()
2956 ),
2957 );
2958 let allowed = viable.clone();
2959 let mut quota_losses = Vec::new();
2960 let cache = self.state.config.cache_dir();
2961 let ctx = WaveCtx {
2962 carry_seats: false,
2963 run: &run_id,
2964 node: "vote",
2965 prompts: &prompts,
2966 cache: cache.as_deref(),
2967 round: None,
2968 };
2969 let results = ask_json_wave::<FinalVote>(
2970 jobs,
2971 Arc::clone(&self.sem),
2972 self.state.config.graph.retries,
2973 &self.roles.judge_roster,
2974 &ctx,
2975 &mut quota_losses,
2976 &mut self.state,
2977 &move |v: &FinalVote| match v.label() {
2978 Some(c) if allowed.contains(&c) => Ok(()),
2979 other => bail!("vote {other:?} is not one of {allowed:?}"),
2980 },
2981 )
2982 .await;
2983 self.state.quota.extend(quota_losses);
2984
2985 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2986 let agent_id = seat.agent.clone();
2987 self.state.seats.insert(seat.key.clone(), seat);
2988 let initial = self
2989 .state
2990 .judgements
2991 .get(j)
2992 .and_then(|r| r.ranking.first().copied());
2993 let mut record = VoteRecord {
2994 judge: j + 1,
2995 agent: agent_id,
2996 vote: None,
2997 reason: String::new(),
2998 changed: false,
2999 };
3000 match res {
3001 Ok((v, _)) => {
3002 record.vote = v.label();
3003 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3004 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3005 self.state.event(
3006 "vote",
3007 format!(
3008 "judge {} voted {}{}",
3009 j + 1,
3010 record.vote.unwrap_or('?'),
3011 if record.changed { " (changed)" } else { "" }
3012 ),
3013 );
3014 }
3015 Err(e) => {
3016 self.state
3017 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3018 }
3019 }
3020 self.state.votes.push(record);
3021 self.state.save()?;
3022 }
3023 Ok(())
3024 }
3025
3026 fn tally(&mut self) -> Result<()> {
3029 if self.state.tally.is_some() {
3030 return Ok(());
3031 }
3032 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3033 let tops: Vec<char> = self
3034 .state
3035 .judgements
3036 .iter()
3037 .filter_map(|j| j.ranking.first().copied())
3038 .collect();
3039 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3040
3041 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3044 let mut cast: Vec<char> = Vec::new();
3045 for (i, j) in self.state.judgements.iter().enumerate() {
3046 let vote = self
3047 .state
3048 .votes
3049 .iter()
3050 .find(|v| v.judge == i + 1)
3051 .and_then(|v| v.vote)
3052 .or_else(|| j.ranking.first().copied());
3053 if let Some(v) = vote {
3054 *first_choice.entry(v).or_insert(0) += 1;
3055 cast.push(v);
3056 }
3057 }
3058
3059 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3060 for j in &self.state.judgements {
3061 let n = j.ranking.len();
3062 for (pos, label) in j.ranking.iter().enumerate() {
3063 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3064 }
3065 }
3066
3067 let best = first_choice.values().copied().max().unwrap_or(0);
3068 let mut leaders: Vec<char> = first_choice
3069 .iter()
3070 .filter(|(_, v)| **v == best)
3071 .map(|(k, _)| *k)
3072 .collect();
3073 let mut tie_break = None;
3074 if leaders.len() > 1 {
3075 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3076 let borda_leaders: Vec<char> = leaders
3077 .iter()
3078 .copied()
3079 .filter(|l| borda[l] == top_borda)
3080 .collect();
3081 tie_break = Some(if borda_leaders.len() == 1 {
3082 format!(
3083 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3084 leaders.len()
3085 )
3086 } else {
3087 format!(
3088 "{} way tie on both first-choice votes and Borda points, broken by label order",
3089 leaders.len()
3090 )
3091 });
3092 leaders = borda_leaders;
3093 leaders.sort_unstable();
3094 }
3095 let winner = *leaders
3096 .first()
3097 .or(viable.first())
3098 .context("no candidate to declare a winner from")?;
3099
3100 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3101 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3102 let deliberated = !self.state.deliberation.is_empty();
3103
3104 let quota_seats: std::collections::BTreeSet<&str> =
3108 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3109 let mut present = 0usize;
3110 for (i, j) in self.state.judgements.iter().enumerate() {
3111 if quota_seats.contains(j.seat.as_str()) {
3112 continue;
3113 }
3114 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3115 let voted = self
3116 .state
3117 .votes
3118 .iter()
3119 .any(|v| v.judge == i + 1 && v.vote.is_some());
3120 if ranked || voted {
3121 present += 1;
3122 }
3123 }
3124 let needs_quorum = viable.len() > 1;
3130 let judges_total = if needs_quorum {
3131 self.roles.judges.len()
3132 } else {
3133 0
3134 };
3135 let quorum = if needs_quorum {
3136 judges_total / 2 + 1
3137 } else {
3138 0
3139 };
3140 let met_quorum = !needs_quorum || present >= quorum;
3141 let uncontested = (!needs_quorum).then(|| {
3142 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3143 });
3144
3145 self.state.event(
3146 "tally",
3147 match &uncontested {
3148 Some(reason) => format!("winner {winner} — {reason}"),
3149 None => format!(
3150 "winner {winner} — votes {} | initial {} | {} changed | \
3151 {present}/{judges_total} judges{}",
3152 first_choice
3153 .iter()
3154 .map(|(k, v)| format!("{k}:{v}"))
3155 .collect::<Vec<_>>()
3156 .join(" "),
3157 if unanimous_initial {
3158 "unanimous"
3159 } else {
3160 "split"
3161 },
3162 changed_votes,
3163 if met_quorum {
3164 String::new()
3165 } else {
3166 format!(" — below quorum ({quorum} required)")
3167 },
3168 ),
3169 },
3170 );
3171 if !met_quorum {
3172 self.state.event(
3173 "stall",
3174 format!(
3175 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3176 the run stops here, resumable"
3177 ),
3178 );
3179 }
3180 self.state.tally = Some(Tally {
3181 first_choice,
3182 borda,
3183 winner,
3184 rankings: tops.len(),
3185 unanimous_initial,
3186 deliberated,
3187 changed_votes,
3188 unanimous_final,
3189 tie_break,
3190 judges: judges_total,
3191 present,
3192 quorum,
3193 met_quorum,
3194 uncontested,
3195 });
3196 self.state.status = if met_quorum {
3197 RunStatus::Reviewing
3198 } else {
3199 RunStatus::Stalled
3200 };
3201 self.state.save()?;
3202 Ok(())
3203 }
3204
3205 #[allow(clippy::too_many_lines)]
3226 async fn recover_stall(&mut self) -> Result<bool> {
3227 let run_id = self.state.id.clone();
3232 let prompts = self.state.config.prompts.clone();
3233 let quota_seats: BTreeSet<&str> =
3238 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3239 let absent: Vec<String> = self
3240 .state
3241 .judgements
3242 .iter()
3243 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3244 .map(|j| j.seat.clone())
3245 .collect();
3246 if absent.is_empty() {
3247 return Ok(false);
3248 }
3249 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3250 if viable.len() <= 1 {
3251 return Ok(false);
3252 }
3253 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3254 let language = self.state.config.graph.language.clone();
3255 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3256 let sessions = self.state.config.graph.sessions;
3257 let artifacts = agent::artifacts_dir(&self.state.dir());
3258 let root = self.state.worktree_root();
3259 let base_short = short(&self.state.base_commit);
3260 let candidates: Vec<Candidate> = viable.clone();
3261
3262 let mut positions: Vec<usize> = absent
3264 .iter()
3265 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3266 .collect();
3267 if positions.is_empty() {
3268 return Ok(false);
3269 }
3270 positions.sort_unstable();
3271 positions.dedup();
3272
3273 let mut judge_jobs = Vec::new();
3275 for &j in &positions {
3276 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3277 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3278 let seat_key = format!("judge-{}", j + 1);
3279 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3280 let seat = self.seat(&seat_key, &spec.id);
3281 judge_jobs.push(SeatJob {
3282 spec,
3283 seat,
3284 prompt: prompt::judge(
3285 &self.state.instruction,
3286 &views,
3287 self.roles.judges.len(),
3288 &base_short,
3289 &language,
3290 ),
3291 cwd: root.join(seat_key),
3292 timeout,
3293 allow_write: false,
3294 sessions,
3295 artifacts: artifacts.clone(),
3296 stem: format!("judge-{}-recover", j + 1),
3297 handover: None,
3298 });
3299 }
3300
3301 let labels_for_check = labels.clone();
3302 let mut judge_losses = Vec::new();
3303 let retries = self.state.config.graph.retries;
3304 let cache = self.state.config.cache_dir();
3305 let ctx = WaveCtx {
3306 carry_seats: false,
3307 run: &run_id,
3308 node: "judge",
3309 prompts: &prompts,
3310 cache: cache.as_deref(),
3311 round: None,
3312 };
3313 let results = ask_json_wave::<Ranking>(
3314 judge_jobs,
3315 Arc::clone(&self.sem),
3316 retries,
3317 &self.roles.judge_roster,
3318 &ctx,
3319 &mut judge_losses,
3320 &mut self.state,
3321 &move |r: &Ranking| r.validate(&labels_for_check),
3322 )
3323 .await;
3324
3325 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3327 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3328 let agent_id = seat.agent.clone();
3329 self.state.seats.insert(seat.key.clone(), seat);
3330 let record = &mut self.state.judgements[j];
3331 match res {
3332 Ok((ranking, out)) => {
3333 record.agent = agent_id;
3334 record.ranking = ranking.normalized();
3335 record.reasons = ranking.reasons;
3336 record.strengths = ranking.strengths;
3337 record.confidence = ranking.confidence;
3338 record.failed = None;
3339 record.duration_ms = out.duration_ms;
3340 recovered.insert(j);
3341 self.state.event(
3342 "recover",
3343 format!("judge {} ranked again after the limit", j + 1),
3344 );
3345 }
3346 Err(e) => {
3347 self.state
3348 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3349 }
3350 }
3351 }
3352
3353 let mut vote_jobs = Vec::new();
3355 let mut vote_pos: Vec<usize> = Vec::new();
3356 for &j in &recovered {
3357 let seat_key = format!("judge-{}", j + 1);
3358 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3359 let seat = self.seat(&seat_key, &spec.id);
3360 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3361 let text = if has_context(&spec, &seat, sessions) {
3362 prompt::final_vote(&labels, &language)
3363 } else {
3364 full.clone()
3365 };
3366 vote_jobs.push(SeatJob {
3367 spec,
3368 seat,
3369 prompt: text,
3370 cwd: root.join(seat_key),
3371 timeout,
3372 allow_write: false,
3373 sessions,
3374 artifacts: artifacts.clone(),
3375 stem: format!("vote-judge-{}-recover", j + 1),
3376 handover: Some(full),
3377 });
3378 vote_pos.push(j);
3379 }
3380 let allowed = labels.clone();
3381 let mut vote_losses = Vec::new();
3382 let vote_retries = self.state.config.graph.retries;
3383 let vote_cache = self.state.config.cache_dir();
3384 let ctx = WaveCtx {
3385 carry_seats: false,
3386 run: &run_id,
3387 node: "vote",
3388 prompts: &prompts,
3389 cache: vote_cache.as_deref(),
3390 round: None,
3391 };
3392 let votes = ask_json_wave::<FinalVote>(
3393 vote_jobs,
3394 Arc::clone(&self.sem),
3395 vote_retries,
3396 &self.roles.judge_roster,
3397 &ctx,
3398 &mut vote_losses,
3399 &mut self.state,
3400 &move |v: &FinalVote| match v.label() {
3401 Some(c) if allowed.contains(&c) => Ok(()),
3402 other => bail!("vote {other:?} is not one of {allowed:?}"),
3403 },
3404 )
3405 .await;
3406 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3407 let agent_id = seat.agent.clone();
3408 self.state.seats.insert(seat.key.clone(), seat);
3409 match res {
3410 Ok((v, _)) => {
3411 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3412 rec.vote = v.label();
3413 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3414 } else {
3415 self.state.votes.push(VoteRecord {
3416 judge: j + 1,
3417 agent: agent_id,
3418 vote: v.label(),
3419 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3420 changed: false,
3421 });
3422 }
3423 self.state.event(
3424 "recover",
3425 format!("judge {} voted again after the limit", j + 1),
3426 );
3427 }
3428 Err(e) => {
3429 self.state
3430 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3431 }
3432 }
3433 }
3434
3435 let recovered_keys: BTreeSet<String> = recovered
3439 .iter()
3440 .map(|&j| format!("judge-{}", j + 1))
3441 .collect();
3442 self.state
3443 .quota
3444 .retain(|q| !recovered_keys.contains(&q.seat));
3445 for loss in judge_losses.into_iter().chain(vote_losses) {
3449 if recovered_keys.contains(&loss.seat) {
3450 continue;
3451 }
3452 self.state.quota.retain(|q| q.seat != loss.seat);
3453 self.state.quota.push(loss);
3454 }
3455
3456 self.state.tally = None;
3458 self.tally()?;
3459 Ok(self
3460 .state
3461 .tally
3462 .as_ref()
3463 .map(|t| t.met_quorum)
3464 .unwrap_or(false))
3465 }
3466
3467 async fn graft(&mut self) -> Result<()> {
3484 let Err(e) = self.graft_inner().await else {
3485 return Ok(());
3486 };
3487 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3488 self.state.status = RunStatus::Blocked;
3489 self.state.event("graft", format!("blocked: {e:#}"));
3490 self.state.save()?;
3491 return Err(e);
3492 }
3493 if let Some(rec) = self.state.graft.clone()
3498 && !rec.outcome.settled()
3499 {
3500 if let Some(winner) = self.state.winner().cloned() {
3501 if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3502 self.state.status = RunStatus::Blocked;
3503 self.state.event("graft", format!("blocked: {re:#}"));
3504 self.state.save()?;
3505 return Err(re);
3506 }
3507 }
3508 if let Some(g) = self.state.graft.as_mut() {
3509 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3510 }
3511 }
3512 self.state.event("graft", format!("skipped: {e:#}"));
3513 if let Err(e) = self.state.save() {
3514 tracing::warn!("save after graft: {e:#}");
3515 }
3516 Ok(())
3517 }
3518
3519 async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3522 let restored = git::restore_to(wt, pre).await;
3523 let tip = git::rev_parse(wt, "HEAD").await;
3524 match (restored, tip) {
3525 (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3526 (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3527 (Ok(()), Ok(tip)) => {
3528 Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3529 }
3530 (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3531 }
3532 }
3533
3534 fn graft_skipped(&mut self, why: &str) {
3536 let message = format!("skipped: {why}");
3537 if !self
3538 .state
3539 .events
3540 .iter()
3541 .any(|e| e.node == "graft" && e.message == message)
3542 {
3543 self.state.event("graft", message);
3544 }
3545 }
3546
3547 async fn graft_inner(&mut self) -> Result<()> {
3548 if let Some(rec) = self.state.graft.clone()
3551 && !rec.outcome.settled()
3552 {
3553 if self.state.reviews.is_empty()
3557 && let Some(winner) = self.state.winner().cloned()
3558 {
3559 Self::graft_restore(&winner.worktree, &rec.pre).await?;
3560 }
3561 if let Some(g) = self.state.graft.as_mut() {
3562 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3563 }
3564 self.state.event(
3565 "graft",
3566 "rolled back: the previous round was interrupted; the winner is untouched",
3567 );
3568 self.state.save()?;
3569 return Ok(());
3570 }
3571 let plan = match graft::plan(&self.state) {
3572 Ok(plan) => plan,
3573 Err(skip) => {
3574 if skip != graft::Skip::AlreadyDone {
3575 self.graft_skipped(skip.reason());
3576 self.state.save()?;
3577 }
3578 return Ok(());
3579 }
3580 };
3581 let Some(winner) = self.state.winner().cloned() else {
3582 return Ok(());
3583 };
3584 let wt = winner.worktree.clone();
3585 if !git::is_clean(&wt).await? {
3586 self.graft_skipped("the winner's worktree has uncommitted changes");
3587 self.state.save()?;
3588 return Ok(());
3589 }
3590 let pre = git::rev_parse(&wt, "HEAD").await?;
3591 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3592 else {
3593 self.graft_skipped("the winner's diff could not be measured");
3594 self.state.save()?;
3595 return Ok(());
3596 };
3597 let limit = graft::limit(winner_lines);
3598 self.state.graft = Some(GraftRecord {
3601 pre: pre.clone(),
3602 post: None,
3603 lines: None,
3604 limit: Some(limit),
3605 agent: None,
3606 outcome: GraftOutcome::Running,
3607 });
3608 self.state.event(
3609 "graft",
3610 format!(
3611 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3612 plan.strengths
3613 .iter()
3614 .map(|(l, _, _)| l.to_string())
3615 .collect::<Vec<_>>()
3616 .join(", "),
3617 plan.winner
3618 ),
3619 );
3620 self.state.save()?;
3621
3622 let fix_prompt = prompt::graft(
3623 &self.state.instruction,
3624 plan.winner,
3625 &plan.strengths,
3626 limit,
3627 &self.state.config.graph.language,
3628 );
3629 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3630 let sessions = self.state.config.graph.sessions;
3631 let artifacts = agent::artifacts_dir(&self.state.dir());
3632 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3633 let (_, seat, out) = self
3634 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3635 SeatJob {
3636 prompt: fix_prompt.clone(),
3637 spec,
3638 seat,
3639 cwd: wt.clone(),
3640 timeout,
3641 allow_write: true,
3642 sessions,
3643 artifacts: artifacts.clone(),
3644 stem: "graft".to_owned(),
3645 handover: None,
3646 }
3647 })
3648 .await;
3649 let mut why: Option<String> = None;
3650 match &out {
3651 AgentOutcome::Ok(o) if agent::output_advances(o) => {
3652 why = Some("the fixer produced nothing usable".to_owned());
3653 }
3654 AgentOutcome::Ok(_) => {}
3655 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3656 AgentOutcome::Quota(o) => {
3657 self.state.quota.push(QuotaLoss {
3658 seat: seat.key.clone(),
3659 node: "graft".to_owned(),
3660 at: Timestamp::now(),
3661 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3662 });
3663 why = Some("rate limited (quota)".to_owned());
3664 }
3665 AgentOutcome::Failed(e) => {
3666 why = Some(format!(
3667 "the fixer failed: {}",
3668 e.lines()
3669 .next()
3670 .unwrap_or("")
3671 .chars()
3672 .take(160)
3673 .collect::<String>()
3674 ));
3675 }
3676 }
3677 let agent_id = seat.agent.clone();
3678 self.state.seats.insert(seat.key.clone(), seat);
3679
3680 let mut post = None;
3681 let mut lines = None;
3682 if why.is_none() {
3683 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3684 if let Ok(r) = &rescued {
3685 self.state.note_withheld("graft", &r.withheld);
3686 }
3687 let head = git::rev_parse(&wt, "HEAD").await?;
3688 let dirty = match &rescued {
3692 Ok(r) => git::status_porcelain(&wt)
3693 .await
3694 .map(|st| {
3695 st.lines().any(|l| {
3696 !r.withheld
3697 .iter()
3698 .any(|w| l.trim_end().ends_with(w.path.as_str()))
3699 })
3700 })
3701 .unwrap_or(true),
3702 Err(_) => true,
3703 };
3704 if dirty {
3705 why = Some("the fixer left changes that could not be committed".to_owned());
3706 } else if head == pre {
3707 why = Some("the fixer changed nothing".to_owned());
3708 } else {
3709 lines = git::changed_lines(&wt, &pre, &head).await;
3710 if graft::within_budget(lines, limit) {
3711 post = Some(head);
3712 } else {
3713 why = Some(format!(
3714 "{} changed lines against a limit of {limit}",
3715 lines.map_or_else(
3716 || "an unmeasurable number of".to_owned(),
3717 |l| l.to_string()
3718 )
3719 ));
3720 }
3721 }
3722 }
3723 let outcome = match why {
3724 None => {
3725 self.state.event(
3726 "graft",
3727 format!(
3728 "applied: {} changed lines (limit {limit})",
3729 lines.unwrap_or(0)
3730 ),
3731 );
3732 GraftOutcome::Applied
3733 }
3734 Some(why) => {
3735 Self::graft_restore(&wt, &pre).await?;
3738 self.state.event(
3739 "graft",
3740 format!("rolled back: {why}; the winner is untouched"),
3741 );
3742 GraftOutcome::RolledBack(why)
3743 }
3744 };
3745 self.state.graft = Some(GraftRecord {
3746 pre,
3747 post,
3748 lines,
3749 limit: Some(limit),
3750 agent: Some(agent_id),
3751 outcome,
3752 });
3753 self.state.save()?;
3754 Ok(())
3755 }
3756
3757 async fn fold_losers(&mut self) -> Result<()> {
3760 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3761 return Ok(());
3762 };
3763 let repo = self.state.repo.clone();
3764 let mut folded = Vec::new();
3765 for i in 0..self.state.candidates.len() {
3766 let c = &self.state.candidates[i];
3767 if c.label == winner || c.folded {
3768 continue;
3769 }
3770 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3771 git::worktree_remove(&repo, &wt).await.ok();
3772 git::branch_delete(&repo, &branch).await.ok();
3773 self.state.candidates[i].folded = true;
3774 folded.push(label.to_string());
3775 }
3776 let root = self.state.worktree_root();
3778 for j in 1..=self.roles.judges.len() {
3779 let wt = root.join(format!("judge-{j}"));
3780 if wt.exists() {
3781 git::worktree_remove(&repo, &wt).await.ok();
3782 }
3783 }
3784 if self.state.config.graph.advise {
3787 for k in 1..=self.state.config.graph.advisors {
3788 let wt = root.join(format!("advisor-{k}"));
3789 if wt.exists() {
3790 git::worktree_remove(&repo, &wt).await.ok();
3791 }
3792 }
3793 }
3794 if !folded.is_empty() {
3795 self.state
3796 .event("fold", format!("folded candidates {}", folded.join(", ")));
3797 self.state.save()?;
3798 }
3799 Ok(())
3800 }
3801
3802 async fn sync_to_base(&mut self) -> Result<()> {
3842 if self.state.status == RunStatus::AlreadyInBase {
3843 return Ok(());
3844 }
3845 let conflicted = self
3846 .state
3847 .base_sync
3848 .as_ref()
3849 .is_some_and(|s| s.conflict.is_some());
3850 let Some(winner) = self.state.winner().cloned() else {
3851 return Ok(());
3852 };
3853
3854 let repo = self.state.repo.clone();
3855 let remote = self.state.config.merge.remote.clone();
3856 let base_branch = self.state.base_branch.clone();
3857 let tracking = format!("{remote}/{base_branch}");
3858
3859 git::fetch(&repo, &remote, &base_branch).await.ok();
3860 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3865 return Ok(());
3866 };
3867
3868 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3869 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3870 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3871
3872 if (behind > 0 || conflicted || head == tip)
3881 && self
3882 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3883 .await?
3884 {
3885 return Ok(());
3886 }
3887 if conflicted {
3888 return Ok(());
3889 }
3890
3891 if behind == 0 {
3892 if let Some(from) = self
3901 .state
3902 .rebase_fixes
3903 .iter()
3904 .rev()
3905 .find_map(|r| r.from.clone())
3906 && from != head
3907 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3908 .await
3909 .is_ok_and(|o| o.ok())
3910 {
3911 git::sync_to_head(&winner.worktree).await?;
3912 }
3913 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3919 if let Some(why) = &conflict {
3920 self.state.status = RunStatus::Blocked;
3921 self.state.event("land", why.clone());
3922 }
3923 self.state.base_sync = Some(BaseSync {
3924 tip,
3925 behind: 0,
3926 attempts,
3927 conflict,
3928 already_in: None,
3929 });
3930 self.state.save()?;
3931 return Ok(());
3932 }
3933
3934 if attempts >= BASE_SYNC_ROUNDS {
3935 let why = format!(
3936 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3937 rebase(s); rebasing again would only race it",
3938 winner.branch
3939 );
3940 self.state.status = RunStatus::Blocked;
3941 self.state.base_sync = Some(BaseSync {
3942 tip,
3943 behind,
3944 attempts,
3945 conflict: Some(why.clone()),
3946 already_in: None,
3947 });
3948 self.state.event("land", why);
3949 self.state.save()?;
3950 return Ok(());
3951 }
3952
3953 self.state.event(
3954 "land",
3955 format!(
3956 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3957 winner.branch
3958 ),
3959 );
3960 self.state.save()?;
3961
3962 let branch_tracking = format!("{remote}/{}", winner.branch);
3968 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3969 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3970 git::rev_parse(&repo, &branch_tracking).await.ok()
3971 } else {
3972 None
3973 };
3974 if let Some(theirs) = &remote_tip
3978 && !git::is_ancestor(&repo, theirs, &head).await
3979 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3980 .await
3981 .is_ok_and(|missing| missing.is_empty())
3982 {
3983 let why = format!(
3984 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3985 them",
3986 short(theirs),
3987 winner.branch
3988 );
3989 self.state.status = RunStatus::Blocked;
3990 self.state.base_sync = Some(BaseSync {
3991 tip,
3992 behind,
3993 attempts,
3994 conflict: Some(why.clone()),
3995 already_in: None,
3996 });
3997 self.state.event("land", why);
3998 self.state.save()?;
3999 return Ok(());
4000 }
4001
4002 let scratch = self.state.dir().join("base-sync");
4003 let rebased = match crate::rebase::rebase_with_fixer(
4004 &mut self.state,
4005 &scratch,
4006 &winner.branch,
4007 &tracking,
4008 )
4009 .await
4010 {
4011 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4012 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4013 Err(e) => Err(e),
4014 };
4015 let attempts = attempts + 1;
4016 match rebased {
4017 Ok(None) => {
4018 git::sync_to_head(&winner.worktree).await?;
4022 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4023 let mut conflict = None;
4024 if let Some(pinned) = &remote_tip {
4025 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4026 match pushed {
4027 Ok(o) if o.ok() => self.state.event(
4028 "land",
4029 format!("pushed rebased {} to {remote}", winner.branch),
4030 ),
4031 Ok(o) => {
4032 conflict = Some(format!(
4033 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4034 winner.branch,
4035 short(pinned),
4036 o.stderr.chars().take(600).collect::<String>()
4037 ));
4038 }
4039 Err(e) => {
4040 conflict = Some(format!(
4041 "rebased {} locally but could not push it: {e:#}",
4042 winner.branch
4043 ));
4044 }
4045 }
4046 }
4047 if let Some(why) = &conflict {
4048 self.state.status = RunStatus::Blocked;
4049 self.state.event("land", why.clone());
4050 }
4051 self.state.base_sync = Some(BaseSync {
4052 tip: tip.clone(),
4053 behind: 0,
4054 attempts,
4055 conflict,
4056 already_in: None,
4057 });
4058 self.state
4059 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4060 }
4061 Ok(Some(conflict)) => {
4062 let why = format!(
4063 "{} conflicts with {tracking} and did not rebase: {}",
4064 winner.branch,
4065 conflict.chars().take(600).collect::<String>()
4066 );
4067 self.state.status = RunStatus::Blocked;
4068 self.state.base_sync = Some(BaseSync {
4069 tip,
4070 behind,
4071 attempts,
4072 conflict: Some(why.clone()),
4073 already_in: None,
4074 });
4075 self.state.event("land", why);
4076 }
4077 Err(e) => {
4078 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4079 self.state.status = RunStatus::Blocked;
4080 self.state.base_sync = Some(BaseSync {
4081 tip,
4082 behind,
4083 attempts,
4084 conflict: Some(why.clone()),
4085 already_in: None,
4086 });
4087 self.state.event("land", why);
4088 }
4089 }
4090 self.state.save()?;
4091 Ok(())
4092 }
4093
4094 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4100 let repo = self.state.repo.clone();
4101 let remote = self.state.config.merge.remote.clone();
4102 let fetched = git::fetch(&repo, &remote, branch).await;
4103 if !matches!(&fetched, Ok(o) if o.ok()) {
4104 return None;
4105 }
4106 let branch_tracking = format!("{remote}/{branch}");
4107 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4108 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4109 return None;
4110 }
4111 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4112 .await
4113 .is_ok_and(|missing| missing.is_empty())
4114 {
4115 return Some(format!(
4116 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4117 them",
4118 short(&theirs)
4119 ));
4120 }
4121 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4122 Ok(o) if o.ok() => {
4123 self.state
4124 .event("land", format!("pushed rebased {branch} to {remote}"));
4125 None
4126 }
4127 Ok(o) => Some(format!(
4128 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4129 someone may have pushed): {}",
4130 short(&theirs),
4131 o.stderr.chars().take(600).collect::<String>()
4132 )),
4133 Err(e) => Some(format!(
4134 "{branch} is rebased locally but could not be pushed: {e:#}"
4135 )),
4136 }
4137 }
4138
4139 async fn settle_already_in(
4156 &mut self,
4157 branch: &str,
4158 tip: &str,
4159 head: &str,
4160 attempts: usize,
4161 behind: usize,
4162 ) -> Result<bool> {
4163 let repo = self.state.repo.clone();
4164 let remote = self.state.config.merge.remote.clone();
4165 let start = self.state.base_commit.clone();
4166 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4167 Ok(Some(e)) => e,
4168 Ok(None) => return Ok(false),
4169 Err(e) => {
4170 tracing::warn!("already-in-base check for {branch}: {e:#}");
4171 return Ok(false);
4172 }
4173 };
4174 let mut verified = vec![head.to_owned()];
4175 let fetched = git::fetch(&repo, &remote, branch).await;
4176 if matches!(&fetched, Ok(o) if o.ok())
4177 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
4178 && theirs != head
4179 {
4180 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4181 Ok(Some(_)) => verified.push(theirs),
4182 _ => return Ok(false),
4183 }
4184 }
4185 let base_branch = self.state.base_branch.clone();
4186 let message = format!(
4187 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4188 land",
4189 evidence.names(),
4190 evidence.proof.as_str()
4191 );
4192 let closed =
4193 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
4194 match closed {
4195 Ok(Ok(url)) => self
4196 .state
4197 .event("land", format!("closed {url}: superseded on {base_branch}")),
4198 Ok(Err(why)) => self
4199 .state
4200 .event("land", format!("did not close a pull request: {why}")),
4201 Err(e) => {
4202 let why = format!(
4203 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4204 not be closed ({e:#}); resume to retry"
4205 );
4206 self.state.status = RunStatus::Blocked;
4207 self.state.base_sync = Some(BaseSync {
4208 tip: tip.to_owned(),
4209 behind,
4210 attempts,
4211 conflict: Some(why.clone()),
4212 already_in: None,
4213 });
4214 self.state.event("land", why);
4215 self.state.save()?;
4216 return Ok(true);
4217 }
4218 }
4219 self.state.status = RunStatus::AlreadyInBase;
4220 self.state.base_sync = Some(BaseSync {
4221 tip: tip.to_owned(),
4222 behind,
4223 attempts,
4224 conflict: None,
4225 already_in: Some(evidence),
4226 });
4227 self.state.event("land", message);
4228 self.state.save()?;
4229 self.settle_questions();
4230 Ok(true)
4231 }
4232
4233 fn landing_base(&self) -> String {
4243 self.state
4244 .base_sync
4245 .as_ref()
4246 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4247 }
4248
4249 pub async fn fix_selected(
4282 &mut self,
4283 ids: &[String],
4284 reason: &str,
4285 allow_stale: bool,
4286 ) -> Result<()> {
4287 let reason = reason.trim();
4288 if reason.is_empty() {
4289 bail!("a fix request needs a reason — that is the operator's own record of why");
4290 }
4291 if ids.is_empty() {
4292 bail!("no finding id given");
4293 }
4294 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4295 bail!(
4296 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4297 has already concluded — can be given a targeted fix. A run still \
4298 in progress should simply be resumed; a `merged` run's branch has \
4299 already landed, so its answer is a fresh `magi review <branch>`, \
4300 not reopening this run's own record",
4301 self.state.id,
4302 self.state.status.as_str()
4303 );
4304 }
4305 let Some(winner) = self.state.winner().cloned() else {
4306 bail!("run {} has no winning candidate to fix", self.state.id);
4307 };
4308 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4309 bail!(
4310 "branch `{}` no longer exists; this run cannot be extended",
4311 winner.branch
4312 );
4313 }
4314 let home = crate::run::home();
4315 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4316 bail!(
4317 "run {} is currently being worked on by another magi process",
4318 self.state.id
4319 );
4320 }
4321 let _claim = FixClaim::acquire(&self.state.dir())?;
4327
4328 let mut seen = BTreeSet::new();
4332 let mut findings = Vec::new();
4333 let mut missing = Vec::new();
4334 for id in ids {
4335 if !seen.insert(id.clone()) {
4336 continue;
4337 }
4338 match self.state.finding(id) {
4339 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4340 id: f.id.clone(),
4341 severity: f.severity,
4342 reviewer_vote: rec.vote,
4343 round: round.round,
4344 round_head: round.head.clone(),
4345 reviewer: rec.reviewer,
4346 agent: rec.agent.clone(),
4347 file: f.file.clone(),
4348 line: f.line,
4349 title: f.title.clone(),
4350 detail: f.detail.clone(),
4351 outcome: OperatorFixOutcome::Pending,
4352 }),
4353 None => missing.push(id.clone()),
4354 }
4355 }
4356 if !missing.is_empty() {
4357 bail!(
4358 "unknown finding id(s): {}; nothing was changed",
4359 missing.join(", ")
4360 );
4361 }
4362
4363 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4364 let stale_details: Vec<(String, String)> = findings
4365 .iter()
4366 .filter(|f| f.round_head != head_at_request)
4367 .map(|f| (f.id.clone(), f.round_head.clone()))
4368 .collect();
4369 let stale = !stale_details.is_empty();
4370 if stale && !allow_stale {
4371 bail!(
4372 "the branch has moved since some finding(s) were raised — {} — now \
4373 at {}; pass --allow-stale to fix anyway, or re-run review first",
4374 stale_details
4375 .iter()
4376 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4377 .collect::<Vec<_>>()
4378 .join(", "),
4379 short(&head_at_request)
4380 );
4381 }
4382
4383 let request = OperatorFixRequest {
4384 requested_at: Timestamp::now(),
4385 reason: reason.to_owned(),
4386 findings,
4387 head_at_request: head_at_request.clone(),
4388 allow_stale,
4389 stale,
4390 fix: None,
4391 result_head: None,
4392 follow_up_review_run: None,
4393 };
4394 self.state.event(
4395 "fix",
4396 format!(
4397 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4398 request.findings.len(),
4399 request
4400 .findings
4401 .iter()
4402 .map(|f| f.id.as_str())
4403 .collect::<Vec<_>>()
4404 .join(", "),
4405 ),
4406 );
4407 self.state.operator_fixes.push(request);
4414 self.state.save()?;
4415 let request_index = self.state.operator_fixes.len() - 1;
4416
4417 if winner.worktree.exists() {
4426 let dirty = git::git(
4429 &winner.worktree,
4430 &["status", "--porcelain", "--untracked-files=all"],
4431 )
4432 .await?;
4433 let only_withheld = dirty.lines().all(|l| {
4434 l.strip_prefix("?? ")
4435 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4436 });
4437 if !only_withheld {
4438 bail!(
4439 "`{}` has uncommitted changes; refusing to touch it — commit or \
4440 discard them first",
4441 winner.worktree.display()
4442 );
4443 }
4444 git::worktree_remove(&self.state.repo, &winner.worktree)
4445 .await
4446 .ok();
4447 }
4448 let fix_worktree = self.state.worktree_root().join("operator-fix");
4449 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4450 git::git(
4451 &self.state.repo,
4452 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4453 )
4454 .await
4455 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4456 if !git::is_clean(&fix_worktree).await? {
4457 git::worktree_remove(&self.state.repo, &fix_worktree)
4458 .await
4459 .ok();
4460 bail!(
4461 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4462 winner.branch
4463 );
4464 }
4465 let repo_for_setup = self.state.repo.clone();
4466 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4467 Ok(()) => {
4468 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4469 }
4470 Err(e) => Err(e),
4471 };
4472 if let Err(e) = setup {
4473 git::worktree_remove(&self.state.repo, &fix_worktree)
4474 .await
4475 .ok();
4476 return Err(e);
4477 }
4478
4479 let run_id = self.state.id.clone();
4480 let prompts = self.state.config.prompts.clone();
4481 let language = self.state.config.graph.language.clone();
4482 let sessions = self.state.config.graph.sessions;
4483 let artifacts = agent::artifacts_dir(&self.state.dir());
4484 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4485 .findings
4486 .iter()
4487 .map(|f| Finding {
4488 id: f.id.clone(),
4489 severity: f.severity,
4490 file: f.file.clone(),
4491 line: f.line,
4492 title: f.title.clone(),
4493 detail: f.detail.clone(),
4494 })
4495 .collect();
4496 let fix_prompt = prompt::operator_fix(
4497 &self.state.instruction,
4498 &finding_list,
4499 reason,
4500 &stale_details,
4501 &head_at_request,
4502 &language,
4503 );
4504 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4505 let (job, seat, out) = self
4506 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4507 prompt: fix_prompt.clone(),
4508 spec,
4509 seat,
4510 cwd: fix_worktree.clone(),
4511 timeout,
4512 allow_write: true,
4513 sessions,
4514 artifacts: artifacts.clone(),
4515 stem: "operator-fix".to_owned(),
4516 handover: None,
4517 })
4518 .await;
4519 let agent_id = seat.agent.clone();
4520
4521 let mut fix = FixRecord {
4522 agent: agent_id,
4523 addressed: Vec::new(),
4524 rejected: Vec::new(),
4525 notes: String::new(),
4526 committed: false,
4527 failed: None,
4528 duration_ms: 0,
4529 continuation: None,
4530 };
4531 let mut final_seat = seat.clone();
4532 match out {
4533 AgentOutcome::Ok(o) => {
4534 fix.duration_ms = o.duration_ms;
4535 let parsed = verdict::extract_json::<FixReport>(&o.text);
4536 let incomplete_reason = match &parsed {
4537 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4538 "the reply parsed, but it reported a command whose own CLI \
4539 never confirmed an exit status"
4540 .to_owned(),
4541 ),
4542 Ok(_) => None,
4543 Err(e) => Some(e.to_string()),
4544 };
4545 match incomplete_reason {
4546 None => {
4547 let report = parsed.expect("checked Ok above");
4548 fix.addressed = report.addressed;
4549 fix.rejected = report.rejected;
4550 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4551 }
4552 Some(reason) => {
4553 let (resumed_seat, resolved, failure, cont) = self
4554 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4555 .await;
4556 fix.duration_ms += cont.cumulative_wait_ms;
4557 fix.continuation = Some(cont);
4558 final_seat = resumed_seat;
4559 match resolved {
4560 Some(report) => {
4561 fix.addressed = report.addressed;
4562 fix.rejected = report.rejected;
4563 fix.notes =
4564 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4565 }
4566 None => fix.failed = failure,
4567 }
4568 }
4569 }
4570 }
4571 AgentOutcome::Dropped(o) => {
4572 fix.duration_ms = o.duration_ms;
4573 let why = o
4574 .dropped
4575 .as_ref()
4576 .map(|d| d.why.as_str())
4577 .unwrap_or("the CLI ended the stream without delivering its answer");
4578 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4579 }
4580 AgentOutcome::Quota(o) => {
4581 self.state.quota.push(QuotaLoss {
4582 seat: final_seat.key.clone(),
4583 node: "fix".to_owned(),
4584 at: Timestamp::now(),
4585 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4586 });
4587 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4588 }
4589 AgentOutcome::Failed(e) => fix.failed = Some(e),
4590 }
4591 if fix.continuation.is_none() {
4592 fix.continuation = Some(ContinuationRecord::not_needed());
4593 }
4594 self.state.seats.insert(final_seat.key.clone(), final_seat);
4595
4596 let rescue_message = format!(
4597 "magi: operator-selected fix ({}) (uncommitted work)",
4598 self.state.operator_fixes[request_index]
4599 .findings
4600 .iter()
4601 .map(|f| f.id.as_str())
4602 .collect::<Vec<_>>()
4603 .join(", ")
4604 );
4605 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4606 self.state.note_withheld("fix", &r.withheld);
4607 }
4608 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4609 fix.committed = after != head_at_request;
4610 git::worktree_remove(&self.state.repo, &fix_worktree)
4611 .await
4612 .ok();
4613
4614 self.state.event(
4615 "fix",
4616 match &fix.failed {
4617 Some(reason) => format!(
4618 "operator fix: adoption report was lost ({reason}); {}",
4619 if fix.committed {
4620 "committed"
4621 } else {
4622 "NO new commit"
4623 }
4624 ),
4625 None => format!(
4626 "operator fix: {} addressed, {} rejected, {}",
4627 fix.addressed.len(),
4628 fix.rejected.len(),
4629 if fix.committed {
4630 "committed"
4631 } else {
4632 "NO new commit"
4633 }
4634 ),
4635 },
4636 );
4637
4638 for f in &mut self.state.operator_fixes[request_index].findings {
4645 f.outcome = if fix.failed.is_some() {
4646 OperatorFixOutcome::Unreported
4647 } else if fix.addressed.contains(&f.id) {
4648 OperatorFixOutcome::Addressed
4649 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4650 OperatorFixOutcome::Rejected { why: r.why.clone() }
4651 } else {
4652 OperatorFixOutcome::Unreported
4653 };
4654 }
4655
4656 let committed = fix.committed;
4657 if committed {
4658 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4659 }
4660 self.state.operator_fixes[request_index].fix = Some(fix);
4661 self.state.save()?;
4664
4665 if committed {
4666 self.state.event(
4667 "fix",
4668 format!(
4669 "operator fix committed {}; opening a follow-up review-only run",
4670 short(&after)
4671 ),
4672 );
4673 let origin =
4676 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4677 match Self::review(
4678 &self.state.repo,
4679 &winner.branch,
4680 self.state.config.clone(),
4681 origin,
4682 )
4683 .await
4684 {
4685 Ok(mut follow_up) => {
4686 follow_up.state.event(
4687 "start",
4688 format!(
4689 "requested by an operator fix on run {} for finding(s) {}",
4690 self.state.id,
4691 self.state.operator_fixes[request_index]
4692 .findings
4693 .iter()
4694 .map(|f| f.id.as_str())
4695 .collect::<Vec<_>>()
4696 .join(", "),
4697 ),
4698 );
4699 follow_up.state.save()?;
4700 let follow_up_id = follow_up.state.id.clone();
4701 let adopted = match crate::direct::adopt(
4704 &follow_up.state,
4705 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4706 ) {
4707 Ok(a) => a,
4708 Err(e) => {
4709 self.state.event(
4712 "fix",
4713 format!(
4714 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4715 ),
4716 );
4717 self.state.save()?;
4718 return Ok(());
4719 }
4720 };
4721 let executed = follow_up.execute().await;
4722 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4723 if let Some(a) = adopted {
4724 a.finish(&follow_up.state, executed);
4725 }
4726 if let Some(e) = failure {
4727 self.state.event(
4728 "fix",
4729 format!(
4730 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4731 ),
4732 );
4733 }
4734 self.state.operator_fixes[request_index].follow_up_review_run =
4735 Some(follow_up_id);
4736 }
4737 Err(e) => {
4738 self.state.event(
4739 "fix",
4740 format!("committed the fix but could not open a follow-up review: {e:#}"),
4741 );
4742 }
4743 }
4744 self.state.save()?;
4745 }
4746
4747 Ok(())
4748 }
4749
4750 async fn ask_fixer(
4770 &mut self,
4771 winner: &Candidate,
4772 node: &'static str,
4773 round: Option<usize>,
4774 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4775 ) -> (SeatJob, SeatState, AgentOutcome) {
4776 let attempts = fixer::attempts(&self.state, &self.roles, winner);
4777 self.ask_fixer_among(attempts, node, round, None, build)
4778 .await
4779 }
4780
4781 async fn ask_fixer_among(
4786 &mut self,
4787 attempts: Vec<(AgentSpec, String)>,
4788 node: &'static str,
4789 round: Option<usize>,
4790 restore: Option<(&Path, &str)>,
4791 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4792 ) -> (SeatJob, SeatState, AgentOutcome) {
4793 let run_id = self.state.id.clone();
4794 let prompts = self.state.config.prompts.clone();
4795 let cache = self.state.config.cache_dir();
4796 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4797 let mut last = None;
4798 for (i, (spec, key)) in attempts.into_iter().enumerate() {
4799 let seat = self.seat(&key, &spec.id);
4800 let mut job = build(spec.clone(), seat);
4801 if i > 0 {
4802 job.stem = format!("{}-{}", job.stem, spec.id);
4803 }
4804 let ctx = WaveCtx {
4805 carry_seats: false,
4806 run: &run_id,
4807 node,
4808 prompts: &prompts,
4809 cache: cache.as_deref(),
4810 round,
4811 };
4812 let (seat, out) =
4813 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4814 let advances = match &out {
4815 AgentOutcome::Ok(o) => agent::output_advances(o),
4816 _ => true,
4817 };
4818 if let Some(next) = ids.get(i + 1)
4819 && advances
4820 {
4821 self.state.seats.insert(seat.key.clone(), seat.clone());
4822 let class =
4823 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4824 let reason = match &out {
4825 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4826 other => fail_reason(other),
4827 };
4828 record_handover(
4829 &mut self.state,
4830 node,
4831 &seat.key,
4832 &spec.id,
4833 next,
4834 &class,
4835 &reason,
4836 );
4837 if let Some((wt, rev)) = restore
4841 && let Err(e) = git::restore_to(wt, rev).await
4842 {
4843 let why =
4844 format!("could not restore the worktree before the next fixer: {e:#}");
4845 tracing::warn!("{why}");
4846 last = Some((job, seat, AgentOutcome::Failed(why)));
4847 break;
4848 }
4849 continue;
4850 }
4851 last = Some((job, seat, out));
4852 break;
4853 }
4854 last.expect("the fixer chain always has an entry")
4855 }
4856
4857 async fn review_loop(&mut self) -> Result<()> {
4858 if self
4863 .state
4864 .base_sync
4865 .as_ref()
4866 .is_some_and(|s| s.conflict.is_some())
4867 {
4868 return Ok(());
4869 }
4870 let run_id = self.state.id.clone();
4875 let prompts = self.state.config.prompts.clone();
4876 let Some(winner) = self.state.winner().cloned() else {
4877 return Ok(());
4878 };
4879 let max_rounds = self.state.config.graph.review_rounds;
4880 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4890 if status == RunStatus::Gating {
4893 self.record_contested_handoff();
4894 }
4895 self.state.status = status;
4896 self.state.save()?;
4897 return Ok(());
4898 }
4899 self.state.status = RunStatus::Reviewing;
4900 if self
4910 .state
4911 .reviews
4912 .last()
4913 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4914 {
4915 let shell = self.state.config.shell();
4916 return self
4917 .stop_reviewing(
4918 "the last round's own verification never resolved",
4919 &shell,
4920 &winner.worktree,
4921 )
4922 .await;
4923 }
4924
4925 let repo = self.state.repo.clone();
4926 let root = self.state.worktree_root();
4927 let language = self.state.config.graph.language.clone();
4928 let sessions = self.state.config.graph.sessions;
4929 let artifacts = agent::artifacts_dir(&self.state.dir());
4930 let base = self.landing_base();
4931 let base_short = short(&base);
4932 let reviewers = self.roles.reviewers.clone();
4933 let shell = self.state.config.shell();
4934
4935 for round in (self.state.reviews.len() + 1)..=max_rounds {
4936 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4937 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4938 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4939 let prev_verification = self
4948 .state
4949 .reviews
4950 .last()
4951 .and_then(|r| r.verification_summary(&head));
4952
4953 let mut jobs = Vec::new();
4957 for (r, spec) in reviewers.iter().cloned().enumerate() {
4958 let wt = root.join(format!("review-{}", r + 1));
4959 if wt.exists() {
4962 git::reset_detached(&wt, &head).await?;
4963 } else {
4964 git::worktree_add_detached(&repo, &wt, &head).await?;
4965 }
4966 self.setup_seat_worktree(&repo, &wt, false).await?;
4967 let seat_key = format!("review-{}", r + 1);
4968 let spec = pick_start_spec(
4971 &self.roles.reviewer_roster,
4972 spec,
4973 self.state.seat_history.get(&seat_key),
4974 );
4975 let seat = self.seat(&seat_key, &spec.id);
4976 jobs.push(SeatJob {
4977 prompt: prompt::review(&prompt::ReviewCtx {
4978 instruction: &self.state.instruction,
4979 branch: &winner.branch,
4980 base_short: &base_short,
4981 stat: &stat,
4982 patch: &patch,
4983 verification: prev_verification.as_ref(),
4984 reviewers: reviewers.len(),
4985 round,
4986 rounds: max_rounds,
4987 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4990 lens: Lens::for_seat(r),
4991 language: &language,
4992 }),
4993 spec,
4994 seat,
4995 cwd: wt,
4996 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4997 allow_write: false,
4998 sessions,
4999 artifacts: artifacts.clone(),
5000 stem: format!("review-{round}-{}", r + 1),
5001 handover: None,
5002 });
5003 }
5004
5005 self.state.event(
5006 "review",
5007 format!(
5008 "round {round}: {} reviewers on {}",
5009 jobs.len(),
5010 short(&head)
5011 ),
5012 );
5013 let mut quota_losses = Vec::new();
5014 let review_retries = self.state.config.graph.retries;
5015 let review_cache = self.state.config.cache_dir();
5016 let ctx = WaveCtx {
5017 carry_seats: true,
5018 run: &run_id,
5019 node: "review",
5020 prompts: &prompts,
5021 cache: review_cache.as_deref(),
5022 round: Some(round),
5023 };
5024 let results = ask_json_wave::<Review>(
5025 jobs,
5026 Arc::clone(&self.sem),
5027 review_retries,
5028 &self.roles.reviewer_roster,
5029 &ctx,
5030 &mut quota_losses,
5031 &mut self.state,
5032 &|_: &Review| Ok(()),
5033 )
5034 .await;
5035 let round_quota_missing = quota_losses.len();
5039 self.state.quota.extend(quota_losses);
5040
5041 let mut records = Vec::new();
5042 let mut all_findings = Vec::new();
5043 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5044 let agent_id = seat.agent.clone();
5045 self.state.seats.insert(seat.key.clone(), seat);
5046 let mut record = ReviewRecord {
5047 reviewer: r + 1,
5048 agent: agent_id,
5049 summary: String::new(),
5050 findings: Vec::new(),
5051 vote: None,
5052 failed: None,
5053 duration_ms: 0,
5054 attempts,
5060 };
5061 match res {
5062 Ok((review, out)) => {
5063 record.summary =
5071 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5072 record.vote = Some(review.vote);
5073 record.duration_ms = out.duration_ms;
5074 for (n, mut f) in review.findings.into_iter().enumerate() {
5075 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5078 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5079 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5080 f.file = f
5086 .file
5087 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5088 all_findings.push(f.clone());
5089 record.findings.push(f);
5090 }
5091 self.state.event(
5092 "review",
5093 format!(
5094 "round {round}: reviewer {} voted {} with {} finding(s)",
5095 r + 1,
5096 review.vote.label(),
5097 record.findings.len()
5098 ),
5099 );
5100 }
5101 Err(e) => {
5102 record.failed = Some(e.to_string());
5103 self.state.event(
5104 "review",
5105 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5106 );
5107 }
5108 }
5109 records.push(record);
5110 }
5111
5112 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5119 let vote_split =
5120 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5121 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5122 if vote_split {
5123 self.state.event(
5124 "review",
5125 format!(
5126 "round {round}: votes split ({}) — one round of reconsideration",
5127 initial_votes
5128 .iter()
5129 .map(|v| v.label())
5130 .collect::<Vec<_>>()
5131 .join(", ")
5132 ),
5133 );
5134 let panel: Vec<ReviewSeatReport<'_>> = records
5137 .iter()
5138 .filter_map(|r| {
5139 r.vote.map(|vote| ReviewSeatReport {
5140 reviewer: r.reviewer,
5141 vote,
5142 summary: &r.summary,
5143 findings: &r.findings,
5144 })
5145 })
5146 .collect();
5147
5148 let mut jobs = Vec::new();
5149 let mut seats_at = Vec::new();
5150 for (r, spec) in reviewers.iter().cloned().enumerate() {
5151 if records[r].vote.is_none() {
5155 continue;
5156 }
5157 let wt = root.join(format!("review-{}", r + 1));
5158 let seat_key = format!("review-{}", r + 1);
5159 let spec = self.occupant(&seat_key, spec);
5160 let seat = self.seat(&seat_key, &spec.id);
5161 let build = |with_patch: bool| {
5169 prompt::review_reconsider(&ReviewReconsiderCtx {
5170 instruction: &self.state.instruction,
5171 reviewer: r + 1,
5172 lens: Lens::for_seat(r),
5173 panel: &panel,
5174 patch: with_patch.then_some(ReviewPatch {
5175 branch: &winner.branch,
5176 base_short: &base_short,
5177 stat: &stat,
5178 patch: &patch,
5179 }),
5180 round,
5181 rounds: max_rounds,
5182 language: &language,
5183 })
5184 };
5185 let full = build(true);
5186 let prompt = if has_context(&spec, &seat, sessions) {
5187 build(false)
5188 } else {
5189 full.clone()
5190 };
5191 jobs.push(SeatJob {
5192 prompt,
5193 spec,
5194 seat,
5195 cwd: wt,
5196 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5197 allow_write: false,
5198 sessions,
5199 artifacts: artifacts.clone(),
5200 stem: format!("review-{round}-reconsider-{}", r + 1),
5201 handover: Some(full),
5202 });
5203 seats_at.push(r);
5204 }
5205
5206 let mut recon_quota_losses = Vec::new();
5207 let recon_cache = self.state.config.cache_dir();
5208 let recon_ctx = WaveCtx {
5209 carry_seats: true,
5210 run: &run_id,
5211 node: "review",
5212 prompts: &prompts,
5213 cache: recon_cache.as_deref(),
5214 round: Some(round),
5215 };
5216 let recon_results = ask_json_wave::<ReviewRevote>(
5217 jobs,
5218 Arc::clone(&self.sem),
5219 review_retries,
5220 &self.roles.reviewer_roster,
5221 &recon_ctx,
5222 &mut recon_quota_losses,
5223 &mut self.state,
5224 &|_: &ReviewRevote| Ok(()),
5225 )
5226 .await;
5227 self.state.quota.extend(recon_quota_losses);
5228
5229 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5230 let agent_id = seat.agent.clone();
5231 self.state.seats.insert(seat.key.clone(), seat);
5232 let mut rec = ReviewRevoteRecord {
5233 reviewer: r + 1,
5234 agent: agent_id,
5235 vote: None,
5236 reason: String::new(),
5237 failed: None,
5238 };
5239 match res {
5240 Ok((rv, _)) => {
5241 rec.vote = Some(rv.vote);
5242 rec.reason =
5243 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5244 self.state.event(
5245 "review",
5246 format!(
5247 "round {round}: reviewer {} revoted {}",
5248 r + 1,
5249 rv.vote.label()
5250 ),
5251 );
5252 }
5253 Err(e) => {
5254 rec.failed = Some(e.to_string());
5255 self.state.event(
5256 "review",
5257 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5258 );
5259 }
5260 }
5261 reconsideration.push(rec);
5262 }
5263 } else if initial_votes.len() > 1 {
5264 self.state.event(
5265 "review",
5266 format!(
5267 "round {round}: votes agreed ({}) — no reconsideration",
5268 initial_votes[0].label()
5269 ),
5270 );
5271 }
5272
5273 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5274 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5275 let defer_e2e =
5286 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5287 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5288 let reason =
5289 format!("{blocking} blocking finding(s) already required a fix this round");
5290 self.state.event(
5291 "verify",
5292 format!(
5293 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5294 {}); it will run once a round has none left",
5295 short(&head)
5296 ),
5297 );
5298 (Vec::new(), false, true, Some(reason))
5299 } else {
5300 let e2e_commands = self.state.config.verify.e2e.clone();
5301 let cache_dir = self.state.config.cache_dir();
5302 let context = format!("round {round}");
5303 let (e2e, verify_retried) = with_cache_lease(
5304 &mut self.state,
5305 cache_dir.as_deref(),
5306 "e2e",
5307 "e2e",
5308 &winner.worktree,
5309 &head,
5310 verify_timeout,
5311 &context,
5312 |state, budget| {
5313 let shell = shell.clone();
5314 let e2e_commands = e2e_commands.clone();
5315 let worktree = winner.worktree.clone();
5316 let context = context.clone();
5317 async move {
5318 run_e2e_with_retry(
5319 state,
5320 &shell,
5321 &e2e_commands,
5322 &worktree,
5323 budget,
5324 &context,
5325 )
5326 .await
5327 }
5328 },
5329 )
5330 .await;
5331 (e2e, verify_retried, false, None)
5332 };
5333
5334 let expected = records.len();
5335 let answered = records.iter().filter(|r| r.failed.is_none()).count();
5336 let incomplete = answered < expected;
5337 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5338 let policy = self.state.config.graph.incomplete_review;
5339 let clean = round_is_clean(
5340 blocking,
5341 e2e_ok,
5342 answered,
5343 expected,
5344 round_quota_missing,
5345 policy,
5346 );
5347
5348 let mut round_record = ReviewRound {
5349 round,
5350 head: head.clone(),
5351 verified_head: None,
5352 verified_at: None,
5353 reviews: records,
5354 e2e,
5355 verify_retried,
5356 e2e_deferred,
5357 e2e_defer_reason,
5358 fix: None,
5359 blocking,
5360 answered,
5361 expected,
5362 clean,
5363 progressed: false,
5364 vote_split,
5365 reconsideration,
5366 verdict: None,
5367 };
5368 round_record.verdict = ReviewVote::worst(
5372 round_record
5373 .final_votes()
5374 .into_iter()
5375 .map(|(_, _, vote)| vote),
5376 );
5377 if !matches!(
5388 round_record.e2e_status(),
5389 E2eStatus::Deferred | E2eStatus::NotConfigured
5390 ) {
5391 round_record.verified_head = Some(head.clone());
5392 round_record.verified_at = Some(Timestamp::now());
5393 }
5394 let this_round_verification = round_record.verification_summary(&head);
5395
5396 if incomplete {
5397 let missing: Vec<String> = round_record
5398 .reviews
5399 .iter()
5400 .filter(|r| r.failed.is_some())
5401 .map(|r| format!("review-{}", r.reviewer))
5402 .collect();
5403 self.state.event(
5404 "review",
5405 format!(
5406 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5407 missing.join(", ")
5408 ),
5409 );
5410 }
5411
5412 if clean {
5413 self.state.event(
5414 "review",
5415 if incomplete && policy == IncompleteReviewPolicy::Warn {
5416 format!(
5417 "round {round}: clean (warn policy, incomplete panel) — no \
5418 blocking findings from the seats that answered, verification green"
5419 )
5420 } else if incomplete {
5421 format!(
5422 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5423 quorum) — no blocking findings from the seats that answered, \
5424 verification green",
5425 expected - answered
5426 )
5427 } else {
5428 format!("round {round}: clean — no blocking findings, verification green")
5429 },
5430 );
5431 self.state.reviews.push(round_record);
5432 self.state.status = RunStatus::Gating;
5433 self.state.save()?;
5434 return Ok(());
5435 }
5436
5437 if incomplete && blocking == 0 && e2e_ok {
5445 self.state.reviews.push(round_record);
5446 self.state.save()?;
5447 if round == max_rounds {
5448 self.state.status = RunStatus::Blocked;
5449 self.state.event(
5450 "review",
5451 format!(
5452 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5453 refusing to call it clean",
5454 expected - answered
5455 ),
5456 );
5457 return Ok(());
5458 }
5459 continue;
5460 }
5461
5462 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5474 self.state.reviews.push(round_record);
5475 return self
5476 .stop_reviewing(
5477 "the round's own verification could not run",
5478 &shell,
5479 &winner.worktree,
5480 )
5481 .await;
5482 }
5483
5484 if round == max_rounds {
5485 self.state.reviews.push(round_record);
5486 return self
5487 .stop_reviewing(
5488 &format!(
5489 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5490 ),
5491 &shell,
5492 &winner.worktree,
5493 )
5494 .await;
5495 }
5496
5497 let blocking_findings: Vec<_> = all_findings
5500 .iter()
5501 .filter(|f| f.severity.blocks())
5502 .cloned()
5503 .collect();
5504 let fix_prompt = prompt::fix(
5505 &self.state.instruction,
5506 &blocking_findings,
5507 this_round_verification.as_ref(),
5508 round,
5509 max_rounds,
5510 &language,
5511 );
5512 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5513 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5514 let (job, seat, out) = self
5515 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5516 prompt: fix_prompt.clone(),
5517 spec,
5518 seat,
5519 cwd: winner.worktree.clone(),
5520 timeout,
5521 allow_write: true,
5522 sessions,
5523 artifacts: artifacts.clone(),
5524 stem: format!("fix-{round}"),
5525 handover: None,
5526 })
5527 .await;
5528 let agent_id = seat.agent.clone();
5529
5530 let mut fix = FixRecord {
5531 agent: agent_id,
5532 addressed: Vec::new(),
5533 rejected: Vec::new(),
5534 notes: String::new(),
5535 committed: false,
5536 failed: None,
5537 duration_ms: 0,
5538 continuation: None,
5539 };
5540 let mut continuation = ContinuationRecord::not_needed();
5541 let mut final_seat = seat.clone();
5542 match out {
5543 AgentOutcome::Ok(o) => {
5544 fix.duration_ms = o.duration_ms;
5545 let parsed = verdict::extract_json::<FixReport>(&o.text);
5546 let incomplete_reason = match &parsed {
5553 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5554 "the reply parsed, but it reported a command whose own CLI never \
5555 confirmed an exit status"
5556 .to_owned(),
5557 ),
5558 Ok(_) => None,
5559 Err(e) => Some(e.to_string()),
5560 };
5561 match incomplete_reason {
5562 None => {
5563 let report = parsed.expect("checked Ok above");
5564 fix.addressed = report.addressed;
5565 fix.rejected = report.rejected;
5566 fix.notes =
5567 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5568 }
5569 Some(reason) => {
5570 let (resumed_seat, resolved, failure, cont) = self
5571 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5572 .await;
5573 fix.duration_ms += cont.cumulative_wait_ms;
5574 continuation = cont;
5575 final_seat = resumed_seat;
5576 match resolved {
5577 Some(report) => {
5578 fix.addressed = report.addressed;
5579 fix.rejected = report.rejected;
5580 fix.notes = blind::sanitize_prose(
5581 &report.notes,
5582 &self.state.config.blind,
5583 );
5584 }
5585 None => fix.failed = failure,
5586 }
5587 }
5588 }
5589 }
5590 AgentOutcome::Dropped(o) => {
5592 fix.duration_ms = o.duration_ms;
5593 let why = o
5594 .dropped
5595 .as_ref()
5596 .map(|d| d.why.as_str())
5597 .unwrap_or("the CLI ended the stream without delivering its answer");
5598 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5599 }
5600 AgentOutcome::Quota(o) => {
5601 self.state.quota.push(QuotaLoss {
5602 seat: final_seat.key.clone(),
5603 node: "fix".to_owned(),
5604 at: Timestamp::now(),
5605 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5606 });
5607 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5608 }
5609 AgentOutcome::Failed(e) => fix.failed = Some(e),
5610 }
5611 fix.continuation = Some(continuation);
5612 self.state.seats.insert(final_seat.key.clone(), final_seat);
5613 if let Ok(r) = git::rescue_commit(
5614 &winner.worktree,
5615 &format!("magi: review round {round} fixes (uncommitted work)"),
5616 )
5617 .await
5618 {
5619 self.state.note_withheld("fix", &r.withheld);
5620 }
5621 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5622 fix.committed = after != before;
5623 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5631 let progressed = diff_after != patch;
5632 let commit_note = if fix.committed {
5633 "committed"
5634 } else {
5635 "NO new commit"
5636 };
5637 let tree_note = if progressed {
5638 "changed vs base"
5639 } else {
5640 "unchanged vs base"
5641 };
5642 self.state.event(
5643 "fix",
5644 match &fix.failed {
5645 Some(reason) => {
5651 format!(
5652 "round {round}: fixer's adoption report was lost ({reason}); \
5653 {commit_note}, tree {tree_note}"
5654 )
5655 }
5656 None => format!(
5657 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5658 {tree_note}{}",
5659 fix.addressed.len(),
5660 fix.rejected.len(),
5661 if continuation.outcome == ContinuationOutcome::Resumed {
5662 format!(
5663 " (adoption report recovered after {} continuation(s))",
5664 continuation.attempts
5665 )
5666 } else {
5667 String::new()
5668 },
5669 ),
5670 },
5671 );
5672 round_record.fix = Some(fix);
5673 round_record.progressed = progressed;
5674 self.state.reviews.push(round_record);
5675 self.state.save()?;
5676
5677 if matches!(
5690 continuation.outcome,
5691 ContinuationOutcome::Exhausted
5692 | ContinuationOutcome::QuotaLost
5693 | ContinuationOutcome::NoSession
5694 ) {
5695 return self
5696 .stop_reviewing(
5697 "the fixer's adoption report never came back, even after resuming its \
5698 own seat; refusing to start another round against the same worktree \
5699 while that is unresolved",
5700 &shell,
5701 &winner.worktree,
5702 )
5703 .await;
5704 }
5705
5706 let streak = self
5707 .state
5708 .reviews
5709 .iter()
5710 .rev()
5711 .take_while(|r| !r.progressed)
5712 .count();
5713 if streak >= STAGNANT_LIMIT {
5714 return self
5715 .stop_reviewing(
5716 &format!(
5717 "the tree has not moved against base for {streak} round(s) in a row"
5718 ),
5719 &shell,
5720 &winner.worktree,
5721 )
5722 .await;
5723 }
5724 }
5725 Ok(())
5726 }
5727
5728 fn record_contested_handoff(&mut self) {
5762 if self.state.contested_handoff.is_some() {
5763 return;
5764 }
5765 let Some(contested) = self
5766 .state
5767 .reviews
5768 .last()
5769 .and_then(ReviewRound::contested_handoff)
5770 else {
5771 return;
5772 };
5773 self.state.event(
5774 "review",
5775 format!(
5776 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5777 wait for the owner's approval",
5778 contested.findings.len(),
5779 contested.rejecters.len()
5780 ),
5781 );
5782 self.state.contested_handoff = Some(contested);
5783 }
5784
5785 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5786 let round_idx = self.state.reviews.len() - 1;
5787 let needs_catchup_run = matches!(
5795 self.state.reviews[round_idx].e2e_status(),
5796 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5797 );
5798 if needs_catchup_run {
5799 let round = self.state.reviews[round_idx].round;
5800 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5801 let commands = self.state.config.verify.e2e.clone();
5802 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5803 let cache_dir = self.state.config.cache_dir();
5804 let context = format!(
5805 "round {round}: verification unresolved, catching up before the final decision"
5806 );
5807 let (outcomes, verify_retried) = with_cache_lease(
5808 &mut self.state,
5809 cache_dir.as_deref(),
5810 "e2e",
5811 "e2e",
5812 worktree,
5813 &attempted_head,
5814 timeout,
5815 &context,
5816 |state, budget| {
5817 let shell = shell.to_vec();
5818 let commands = commands.clone();
5819 let context = context.clone();
5820 async move {
5821 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5822 .await
5823 }
5824 },
5825 )
5826 .await;
5827 let last = &mut self.state.reviews[round_idx];
5828 last.e2e = outcomes;
5829 last.verify_retried = verify_retried;
5830 last.verified_head = Some(attempted_head);
5837 last.verified_at = Some(Timestamp::now());
5838 if verify_inconclusive(&last.e2e) {
5839 self.state.save()?;
5846 return Ok(());
5847 }
5848 last.e2e_deferred = false;
5849 }
5850 let last = &self.state.reviews[round_idx];
5851 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5852
5853 match last.e2e_status() {
5854 E2eStatus::Failed => {
5855 let red: Vec<String> = last
5856 .e2e
5857 .iter()
5858 .filter(|o| !o.ok())
5859 .map(|o| {
5860 format!(
5861 "`{}` -> {:?}\n{}",
5862 o.command,
5863 o.code,
5864 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5865 )
5866 })
5867 .collect();
5868 self.state
5869 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5870 self.state.status = RunStatus::Blocked;
5871 }
5872 E2eStatus::ResourceBlocked => {
5877 self.state.event(
5878 "review",
5879 format!(
5880 "{why}; e2e could not run (shared build cache unavailable); not \
5881 deciding yet"
5882 ),
5883 );
5884 }
5885 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5886 self.state.event(
5887 "review",
5888 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5889 );
5890 self.record_contested_handoff();
5891 self.state.status = RunStatus::Gating;
5892 }
5893 }
5894 self.state.save()?;
5895 Ok(())
5896 }
5897
5898 async fn gate(&mut self) -> Result<()> {
5901 if self.state.status == RunStatus::Failed
5913 || self
5914 .state
5915 .base_sync
5916 .as_ref()
5917 .is_some_and(|s| s.conflict.is_some())
5918 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5919 != Some(RunStatus::Gating)
5920 {
5921 return Ok(());
5922 }
5923 if self.state.gate_ran {
5924 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5935 self.state.status = RunStatus::Blocked;
5936 self.state.save()?;
5937 }
5938 return Ok(());
5939 }
5940 let Some(winner) = self.state.winner().cloned() else {
5941 return Ok(());
5942 };
5943 self.state.status = RunStatus::Gating;
5944 let mut outcomes = self.run_gate(&winner).await?;
5945 loop {
5946 if verify_inconclusive(&outcomes) {
5957 self.state.save()?;
5958 return Ok(());
5959 }
5960 if outcomes.iter().all(CommandOutcome::ok) {
5961 break;
5962 }
5963 match self.gate_fix_round(&winner, &outcomes).await? {
5964 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5965 GateFix::Stop => break,
5966 GateFix::Defer => {
5967 self.state.save()?;
5968 return Ok(());
5969 }
5970 }
5971 }
5972 let passed = outcomes.iter().all(CommandOutcome::ok);
5973 self.state.gate = outcomes;
5974 self.state.gate_ran = true;
5975 if !passed {
5976 self.state.status = RunStatus::Blocked;
5977 let spent = self.state.gate_fixes.len();
5978 self.state.event(
5979 "gate",
5980 if spent == 0 {
5981 "gate failed; not merging".to_owned()
5982 } else {
5983 format!("gate failed after {spent} gate-fix round(s); not merging")
5984 },
5985 );
5986 }
5987 self.state.save()?;
5988 Ok(())
5989 }
5990
5991 async fn run_pre_gate(&mut self, winner: &Candidate) {
6001 let commands = self.state.config.verify.pre_gate.clone();
6002 if commands.is_empty() {
6003 return;
6004 }
6005 let shell = self.state.config.shell();
6006 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6007 let (outcomes, _) = run_commands(
6008 &mut self.state,
6009 "pre_gate",
6010 "pre_gate",
6011 0,
6012 &shell,
6013 &commands,
6014 &winner.worktree,
6015 timeout,
6016 )
6017 .await;
6018 for o in &outcomes {
6019 if !o.ok() {
6020 tracing::warn!(
6021 "pre_gate `{}` failed ({:?}); the gate decides",
6022 o.command,
6023 o.code
6024 );
6025 }
6026 self.state.event(
6027 "pre_gate",
6028 format!(
6029 "`{}` -> {}",
6030 o.command,
6031 if o.ok() {
6032 "pass".to_owned()
6033 } else {
6034 format!(
6035 "FAIL ({:?})\n{}",
6036 o.code,
6037 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6038 )
6039 }
6040 ),
6041 );
6042 }
6043 self.state.pre_gate = outcomes;
6044 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6045 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6046 Ok(head) => {
6047 self.state
6048 .event("pre_gate", format!("committed mechanical fixes ({head})"));
6049 self.state.pre_gate_commit = Some(head);
6050 }
6051 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6052 },
6053 Ok(false) => {}
6054 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6055 }
6056 if let Err(e) = self.state.save() {
6057 tracing::warn!("could not persist the pre_gate record: {e:#}");
6058 }
6059 }
6060
6061 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6064 self.run_pre_gate(winner).await;
6065 let shell = self.state.config.shell();
6066 let gate_commands = self.state.config.verify.gate.clone();
6067 let outcomes = if gate_commands.is_empty() {
6076 Vec::new()
6077 } else {
6078 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6079 let cache_dir = self.state.config.cache_dir();
6080 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6081 let (outcomes, _) = with_cache_lease(
6082 &mut self.state,
6083 cache_dir.as_deref(),
6084 "gate",
6085 "gate",
6086 &winner.worktree,
6087 &head,
6088 timeout,
6089 "final gate",
6090 |state, budget| {
6091 let shell = shell.clone();
6092 let gate_commands = gate_commands.clone();
6093 let worktree = winner.worktree.clone();
6094 async move {
6095 let (outcomes, timed_out_pids) = run_commands(
6096 state,
6097 "gate",
6098 "gate",
6099 0,
6100 &shell,
6101 &gate_commands,
6102 &worktree,
6103 budget,
6104 )
6105 .await;
6106 (outcomes, false, timed_out_pids)
6107 }
6108 },
6109 )
6110 .await;
6111 outcomes
6112 };
6113 if outcomes.is_empty() {
6114 self.state.event(
6119 "gate",
6120 "no gate commands configured; nothing to check, passing",
6121 );
6122 }
6123 for o in &outcomes {
6124 self.state.event(
6125 "gate",
6126 format!(
6127 "`{}` -> {}",
6128 o.command,
6129 if o.ok() {
6130 "pass".to_owned()
6131 } else {
6132 format!(
6133 "FAIL ({:?})\n{}",
6134 o.code,
6135 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6136 )
6137 }
6138 ),
6139 );
6140 }
6141 Ok(outcomes)
6142 }
6143
6144 async fn gate_fix_round(
6157 &mut self,
6158 winner: &Candidate,
6159 outcomes: &[CommandOutcome],
6160 ) -> Result<GateFix> {
6161 let cap = self.state.config.graph.gate_fix_rounds;
6162 let spent = self.state.gate_fixes.len();
6163 if spent >= cap {
6164 if cap > 0 {
6165 self.state.event(
6166 "gate",
6167 format!("{spent} gate-fix round(s) spent and the gate still fails"),
6168 );
6169 }
6170 return Ok(GateFix::Stop);
6171 }
6172 if !gate_fixable(outcomes) {
6173 self.state.event(
6174 "gate",
6175 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6176 command or similar); not spending a fix round on it",
6177 );
6178 return Ok(GateFix::Stop);
6179 }
6180 let min_free = self.state.config.disk.min_free_bytes;
6181 if min_free > 0 {
6182 match crate::disk::free_bytes(&winner.worktree) {
6183 Ok(free) if crate::disk::enough_space(free, min_free) => {}
6184 Ok(free) => {
6185 self.state.event(
6186 "gate",
6187 format!(
6188 "only {free} bytes free ({min_free} required by `[disk] \
6189 min_free_bytes`); not spending a fix round on a failure the disk \
6190 may explain"
6191 ),
6192 );
6193 return Ok(GateFix::Stop);
6194 }
6195 Err(e) => {
6196 self.state.event(
6197 "gate",
6198 format!("free disk space could not be measured ({e:#}); no fix round"),
6199 );
6200 return Ok(GateFix::Stop);
6201 }
6202 }
6203 }
6204
6205 let attempt = spent + 1;
6206 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6207 let base = self.landing_base();
6208 let fix_prompt = prompt::gate_fix(
6209 &self.state.instruction,
6210 &failed,
6211 attempt,
6212 cap,
6213 &self.state.config.graph.language,
6214 );
6215 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6216 let sessions = self.state.config.graph.sessions;
6217 let artifacts = agent::artifacts_dir(&self.state.dir());
6218 self.state.event(
6219 "gate",
6220 format!("gate failed; gate-fix round {attempt} of {cap}"),
6221 );
6222 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6223 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6224 let (_, seat, out) = self
6225 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6226 prompt: fix_prompt.clone(),
6227 spec,
6228 seat,
6229 cwd: winner.worktree.clone(),
6230 timeout,
6231 allow_write: true,
6232 sessions,
6233 artifacts: artifacts.clone(),
6234 stem: format!("gate-fix-{attempt}"),
6235 handover: None,
6236 })
6237 .await;
6238 let mut record = GateFixRecord {
6239 agent: seat.agent.clone(),
6240 failed,
6241 notes: String::new(),
6242 committed: false,
6243 error: None,
6244 };
6245 match out {
6246 AgentOutcome::Ok(o) => {
6247 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6250 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6251 }
6252 }
6253 AgentOutcome::Dropped(_) => {
6254 record.error = Some("the CLI dropped the stream".to_owned());
6255 }
6256 AgentOutcome::Quota(o) => {
6257 self.state.quota.push(QuotaLoss {
6258 seat: seat.key.clone(),
6259 node: "gate-fix".to_owned(),
6260 at: Timestamp::now(),
6261 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6262 });
6263 record.error = Some("rate limited (quota); fixer could not run".to_owned());
6264 }
6265 AgentOutcome::Failed(e) => record.error = Some(e),
6266 }
6267 self.state.seats.insert(seat.key.clone(), seat);
6268 if let Ok(r) = git::rescue_commit(
6269 &winner.worktree,
6270 &format!("magi: gate fix {attempt} (uncommitted work)"),
6271 )
6272 .await
6273 {
6274 self.state.note_withheld("gate-fix", &r.withheld);
6275 }
6276 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6277 record.committed = after != before;
6278 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6279 let note = record.error.clone();
6280 self.state.gate_fixes.push(record);
6281 self.state.save()?;
6282 if !changed {
6283 self.state.event(
6284 "gate",
6285 match note {
6286 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6287 None => format!("gate-fix round {attempt}: the tree did not change"),
6288 },
6289 );
6290 return Ok(GateFix::Stop);
6291 }
6292 self.state.event(
6293 "gate",
6294 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6295 );
6296
6297 let commands = self.state.config.verify.e2e.clone();
6298 if !commands.is_empty() {
6299 let shell = self.state.config.shell();
6300 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6301 let cache_dir = self.state.config.cache_dir();
6302 let context = format!("gate-fix round {attempt}");
6303 let (e2e, _) = with_cache_lease(
6304 &mut self.state,
6305 cache_dir.as_deref(),
6306 "e2e",
6307 "e2e",
6308 &winner.worktree,
6309 &after,
6310 timeout,
6311 &context,
6312 |state, budget| {
6313 let shell = shell.clone();
6314 let commands = commands.clone();
6315 let context = context.clone();
6316 let worktree = winner.worktree.clone();
6317 async move {
6318 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6319 .await
6320 }
6321 },
6322 )
6323 .await;
6324 if verify_inconclusive(&e2e) {
6325 return Ok(GateFix::Defer);
6326 }
6327 if e2e.iter().any(|o| !o.ok()) {
6328 self.state.event(
6329 "gate",
6330 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6331 );
6332 return Ok(GateFix::Stop);
6333 }
6334 }
6335 Ok(GateFix::Retry)
6336 }
6337
6338 async fn guarded_pr_message(
6345 &mut self,
6346 winner: &Candidate,
6347 facts: Option<&BranchFacts>,
6348 posting: bool,
6349 ) -> Result<Option<PrMessage>> {
6350 let mut pr = pr_message_raw(&self.state, winner.label, facts);
6351 if !posting {
6352 let identity = crate::scrub::Identity::current();
6353 return Ok(Some(PrMessage {
6354 title: crate::scrub::scrub(&pr.title, &identity),
6355 body: crate::scrub::scrub(&pr.body, &identity),
6356 }));
6357 }
6358 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6361 if let Some(known) = self
6362 .state
6363 .github_text
6364 .clone()
6365 .filter(|g| g.fingerprint == raw_fp)
6366 {
6367 return self.settle_withheld(winner, &pr, known).await;
6368 }
6369 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6370 let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6371 let identity = crate::scrub::Identity::current();
6372 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6373 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6374 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6375 {
6376 violations.push(crate::github_text::Violation::SensitiveData);
6377 }
6378 let has_language = violations
6382 .iter()
6383 .any(|v| *v != crate::github_text::Violation::SensitiveData);
6384 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6385 self.state.event(
6386 "github-text",
6387 if has_language {
6388 format!("description rejected: {violations:?}; requesting one rewrite")
6389 } else {
6390 format!("description rejected: {violations:?}; asking the owner")
6391 },
6392 );
6393 let mut rewritten = false;
6394 if has_language
6395 && let Some(spec) = self
6396 .state
6397 .config
6398 .agents
6399 .iter()
6400 .find(|a| a.id == winner.agent)
6401 .cloned()
6402 {
6403 let key = format!("impl-{}", winner.label);
6404 let seat = self.seat(&key, &spec.id);
6405 let prompt = format!(
6406 "Rewrite only the following pull request description. The posting gate reported {violations:?}. Write English prose and remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the complete Markdown body. Preserve the magi run marker.\n\nTITLE: {}\n{}",
6407 pr.title, pr.body
6408 );
6409 let job = SeatJob {
6410 spec,
6411 seat,
6412 cwd: winner.worktree.clone(),
6413 prompt,
6414 timeout: retry_budget(
6415 Duration::from_secs(self.state.config.graph.timeout_implement),
6416 true,
6417 ),
6418 allow_write: false,
6419 sessions: self.state.config.graph.sessions,
6420 artifacts: agent::artifacts_dir(&self.state.dir()),
6421 stem: "github-text-rewrite".to_owned(),
6422 handover: None,
6423 };
6424 let prompts = self.state.config.prompts.clone();
6425 let cache = self.state.config.cache_dir();
6426 let run = self.state.id.clone();
6427 let ctx = WaveCtx {
6428 run: &run,
6429 node: "github-text",
6430 prompts: &prompts,
6431 cache: cache.as_deref(),
6432 round: None,
6433 carry_seats: false,
6434 };
6435 let (seat, outcome) =
6436 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6437 self.state.seats.insert(seat.key.clone(), seat);
6438 if let AgentOutcome::Ok(output) = outcome
6439 && let Some(title) = summary_title(&output.text)
6440 {
6441 let mut body = summary_without_title(&output.text);
6442 let rewrite = PrMessage {
6443 title: title.clone(),
6444 body: body.clone(),
6445 };
6446 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6447 if !body.trim().is_empty()
6448 && crate::github_text::check_with(&title, &body, redecision).is_empty()
6449 && crate::github_text::shareable(&title)
6450 && crate::github_text::shareable(&body)
6451 {
6452 decision = redecision;
6453 let marker = format!("magi:run/{}", self.state.id);
6454 if !body.contains(&marker) {
6455 body.push_str(&format!("\n\n{marker}"));
6456 }
6457 pr = PrMessage { title, body };
6458 rewritten = true;
6459 self.state
6460 .event("github-text", "description rewrite passed");
6461 }
6462 }
6463 }
6464 if !rewritten {
6465 self.state.event(
6466 "github-text",
6467 "description rewrite unavailable or rejected; asking the owner",
6468 );
6469 if let Some(w) =
6470 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6471 {
6472 self.retire_github_text_question("the withheld text changed");
6475 let record = crate::run::GithubTextAsk {
6476 fingerprint: raw_fp,
6477 title: w.title,
6478 body: w.body,
6479 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6480 question: None,
6481 asks: 0,
6482 resolved: false,
6483 chosen_title: None,
6484 choice: None,
6485 };
6486 return self.settle_withheld(winner, &pr, record).await;
6487 }
6488 }
6489 }
6490 let (title, body) =
6491 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6492 Ok(Some(PrMessage { title, body }))
6493 }
6494
6495 fn retire_github_text_question(&mut self, why: &str) {
6497 let Some(id) = self
6498 .state
6499 .github_text
6500 .as_ref()
6501 .and_then(|g| g.question.clone())
6502 else {
6503 return;
6504 };
6505 let _ = ask::Questions::open().update(&id, |q| {
6506 q.abandon(why);
6507 Ok(())
6508 });
6509 }
6510
6511 async fn settle_withheld(
6515 &mut self,
6516 winner: &Candidate,
6517 pr: &PrMessage,
6518 mut g: crate::run::GithubTextAsk,
6519 ) -> Result<Option<PrMessage>> {
6520 use crate::github_text as gt;
6521 let store = ask::Questions::open();
6522 let timeout = self.state.config.graph.answer_timeout;
6523 if !g.resolved {
6524 let mut standing = g
6525 .question
6526 .as_ref()
6527 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6528 if let Some(q) = standing.as_ref()
6529 && q.status.open()
6530 && gt::expired(q, timeout)
6531 {
6532 let why = format!("no answer within {}s of asking", timeout.max(1));
6533 standing = store
6534 .update(&q.id, |q| {
6535 q.abandon(&why);
6536 Ok(())
6537 })
6538 .ok()
6539 .map(|(q, ())| q);
6540 }
6541 let mut ask_again: Option<Option<Vec<String>>> = None;
6542 match standing {
6543 None if g.asks <= 1 => {
6546 g.asks = 0;
6547 ask_again = Some(None);
6548 }
6549 None => {
6550 g.resolved = true;
6551 g.chosen_title = None;
6552 g.choice = Some(GithubTextChoice::Fallback);
6553 }
6554 Some(q) if q.status.open() => {
6555 self.park_on_github_text(&g)?;
6556 return Ok(None);
6557 }
6558 Some(q) => match gt::read_reply(&q) {
6559 gt::Reply::Fallback => {
6560 g.resolved = true;
6561 g.chosen_title = None;
6562 g.choice = Some(GithubTextChoice::Fallback);
6563 }
6564 gt::Reply::Original => {
6565 g.resolved = true;
6566 g.chosen_title = None;
6567 g.choice = Some(GithubTextChoice::Original);
6568 }
6569 gt::Reply::Title(text) => {
6570 let first = text
6571 .lines()
6572 .map(str::trim)
6573 .find(|l| !l.is_empty())
6574 .unwrap_or("");
6575 let decision = if first.is_empty() {
6576 None
6577 } else {
6578 let candidate = PrMessage {
6579 title: first.to_owned(),
6580 body: String::new(),
6581 };
6582 self.judge_pr_language(&winner.worktree, &candidate).await
6583 };
6584 match gt::vet_title(&text, decision) {
6585 Ok(title) => {
6586 g.resolved = true;
6587 g.chosen_title = Some(title);
6588 g.choice = Some(GithubTextChoice::MyTitle);
6589 }
6590 Err(cats) if g.asks < 2 => {
6591 ask_again = Some(Some(
6592 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
6593 ))
6594 }
6595 Err(_) => {
6596 g.resolved = true;
6597 g.chosen_title = None;
6598 g.choice = Some(GithubTextChoice::Fallback);
6599 }
6600 }
6601 }
6602 },
6603 }
6604 if let Some(rejected) = ask_again {
6605 let w = gt::Withheld {
6608 title: g.title,
6609 body: g.body,
6610 categories: g.categories.clone(),
6611 };
6612 let artifact = match crate::run::write_artifact(
6615 &self.state,
6616 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
6617 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id),
6618 ) {
6619 Ok(p) => Some(p),
6620 Err(e) => {
6621 tracing::warn!("could not write the withheld-text artifact: {e:#}");
6622 None
6623 }
6624 };
6625 let mut q = ask::Question::new(
6626 self.state.id.clone(),
6627 gt::ASK_NODE.to_owned(),
6628 gt::ASK_SEAT.to_owned(),
6629 gt::question_summary(&self.state.config.graph.language, &w),
6630 gt::question_detail(
6631 &w,
6632 &pr.title,
6633 &pr.body,
6634 &self.state.id,
6635 rejected.as_deref(),
6636 artifact.as_deref(),
6637 ),
6638 gt::question_choices(&w),
6639 );
6640 q.answer_timeout = timeout;
6641 g.question = Some(q.id.clone());
6644 g.asks += 1;
6645 self.state.github_text = Some(g.clone());
6646 self.state.event(
6647 "github-text",
6648 format!("asking the owner about the withheld text ({})", q.short()),
6649 );
6650 self.state.save()?;
6651 store
6652 .put(&mut q)
6653 .context("file the posting-gate question")?;
6654 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6655 tracing::warn!(
6656 "could not notify about posting-gate question {}: {e:#}",
6657 q.short()
6658 );
6659 }
6660 self.park_on_github_text(&g)?;
6661 return Ok(None);
6662 }
6663 }
6664 let resolved = g.chosen_title.clone();
6665 let original = g.choice == Some(GithubTextChoice::Original);
6666 self.state.event(
6668 "github-text",
6669 if original {
6670 format!(
6671 "posting the original text for: {}",
6672 match (g.title, g.body) {
6673 (true, true) => "title, description",
6674 (true, false) => "title",
6675 _ => "description",
6676 }
6677 )
6678 } else if resolved.is_some() {
6679 "posting the owner's replacement title".to_owned()
6680 } else {
6681 "posting the neutral text for the withheld fields".to_owned()
6682 },
6683 );
6684 self.state.github_text = Some(g.clone());
6685 self.state.save()?;
6686 let w = gt::Withheld {
6687 title: g.title,
6688 body: g.body,
6689 categories: g.categories.clone(),
6690 };
6691 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
6692 if let Some(t) = resolved {
6693 title = t;
6694 }
6695 let english = gt::LanguageDecision {
6697 title_english: true,
6698 body_english: true,
6699 body_complete: true,
6700 };
6701 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6702 if original {
6703 if g.title {
6706 title = pr.title.clone();
6707 }
6708 if g.body {
6709 body = pr.body.clone();
6710 }
6711 }
6712 Ok(Some(PrMessage { title, body }))
6713 }
6714
6715 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6716 self.state.github_text = Some(g.clone());
6717 self.state.parked = true;
6718 self.state.event(
6719 "github-text",
6720 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6721 );
6722 self.state.save()
6723 }
6724
6725 async fn judge_pr_language(
6728 &mut self,
6729 cwd: &Path,
6730 pr: &PrMessage,
6731 ) -> Option<crate::github_text::LanguageDecision> {
6732 self.state.config.roles.language_judge.as_ref()?;
6733 let decision =
6734 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6735 self.state.event(
6736 "github-text",
6737 if decision.is_some() {
6738 "language decided by the language judge"
6739 } else {
6740 "language judge unavailable; using built-in heuristics"
6741 },
6742 );
6743 decision
6744 }
6745
6746 async fn merge(&mut self) -> Result<()> {
6747 if self
6762 .state
6763 .base_sync
6764 .as_ref()
6765 .is_some_and(|s| s.conflict.is_some())
6766 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6767 != Some(RunStatus::Gating)
6768 || !self.state.gate_status().ok()
6777 {
6778 return Ok(());
6779 }
6780 if self.state.merge.is_some() {
6789 return Ok(());
6790 }
6791 let Some(winner) = self.state.winner().cloned() else {
6792 return Ok(());
6793 };
6794 let repo = self.state.repo.clone();
6795 let base = self.state.base_branch.clone();
6796 let mode = self.state.config.merge.mode;
6797 let style = self.state.config.merge.style;
6798 let facts = if is_review_run(&self.state) {
6799 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6800 let start = review_base(
6801 &repo,
6802 &self.state.config.merge.remote,
6803 &base,
6804 &self.state.base_commit,
6805 &winner.branch,
6806 )
6807 .await;
6808 branch_facts(&repo, &start, &winner.branch).await
6809 } else {
6810 None
6811 };
6812 let leaked = if is_review_run(&self.state) {
6815 leaked_subjects(&self.state, &winner.branch).await
6816 } else {
6817 Some(Vec::new())
6818 };
6819 let Some(pr) = self
6823 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6824 .await?
6825 else {
6826 return Ok(());
6827 };
6828 let message = pr.commit_message();
6829
6830 let outcome = match mode {
6831 MergeMode::None => MergeOutcome {
6832 mode,
6833 ok: true,
6834 detail: manual_merge_command(style, &repo, &winner.branch, &message),
6835 empty: false,
6836 },
6837 MergeMode::Pr | MergeMode::Local
6838 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6839 {
6840 MergeOutcome {
6841 mode,
6842 ok: false,
6843 detail: empty_candidate_detail(&self.state, &base),
6844 empty: true,
6845 }
6846 }
6847 MergeMode::Local => {
6848 let on = git::current_branch(&repo).await?;
6849 if on.as_deref() != Some(base.as_str()) {
6850 MergeOutcome {
6851 mode,
6852 ok: false,
6853 detail: format!(
6854 "{} has {} checked out, not the base branch {base}",
6855 repo.display(),
6856 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6857 ),
6858 empty: false,
6859 }
6860 } else if !git::is_clean(&repo).await? {
6861 MergeOutcome {
6862 mode,
6863 ok: false,
6864 detail: format!("{} is dirty; refusing to merge", repo.display()),
6865 empty: false,
6866 }
6867 } else {
6868 let out = match style {
6869 MergeStyle::Merge => {
6870 git::merge_no_ff(&repo, &winner.branch, &message).await?
6871 }
6872 MergeStyle::Squash => {
6873 git::merge_squash(&repo, &winner.branch, &message).await?
6874 }
6875 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6876 };
6877 MergeOutcome {
6878 mode,
6879 ok: out.ok(),
6880 detail: if out.ok() { out.stdout } else { out.stderr },
6881 empty: false,
6882 }
6883 }
6884 }
6885 MergeMode::Pr => {
6886 let remote = self.state.config.merge.remote.clone();
6887 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6888 if !pushed.ok() {
6889 MergeOutcome {
6890 mode,
6891 ok: false,
6892 detail: pushed.stderr,
6893 empty: false,
6894 }
6895 } else {
6896 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6902 let out = match pr_merge_plan(found) {
6903 PrPlan::Create => {
6904 gh_pr_create(
6905 &winner.worktree,
6906 &base,
6907 &winner.branch,
6908 &pr.title,
6909 &pr.body,
6910 )
6911 .await
6912 }
6913 PrPlan::Adopt { url, title } => {
6914 self.state
6915 .event("merge", format!("Pr: adopted open pull request {url}"));
6916 if title != pr.title
6917 && (!is_review_run(&self.state)
6918 || leaked
6919 .as_deref()
6920 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6921 && let Err(e) =
6922 if self.state.github_text.as_ref().is_some_and(|g| {
6923 g.resolved
6924 && g.title
6925 && g.choice == Some(GithubTextChoice::Original)
6926 }) {
6927 land::set_pr_title_verbatim(
6929 &winner.worktree,
6930 &url,
6931 &pr.title,
6932 )
6933 .await
6934 } else {
6935 land::set_pr_title(
6936 &mut self.state,
6937 &winner.worktree,
6938 &url,
6939 &pr.title,
6940 )
6941 .await
6942 }
6943 {
6944 tracing::warn!("could not refresh title of {url}: {e:#}");
6945 self.state
6946 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6947 }
6948 Ok(url)
6949 }
6950 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6951 };
6952 match out {
6953 Ok(url) => MergeOutcome {
6954 mode,
6955 ok: true,
6956 detail: url,
6957 empty: false,
6958 },
6959 Err(e) => MergeOutcome {
6960 mode,
6961 ok: false,
6962 detail: e.to_string(),
6963 empty: false,
6964 },
6965 }
6966 }
6967 }
6968 };
6969
6970 self.state.status = match (mode, outcome.ok) {
6971 (MergeMode::None, _) => RunStatus::Ready,
6972 (_, true) => RunStatus::Merged,
6973 (_, false) => RunStatus::Blocked,
6974 };
6975 self.state.event(
6976 "merge",
6977 format!(
6978 "{:?}: {}",
6979 mode,
6980 outcome.detail.lines().next().unwrap_or("")
6981 ),
6982 );
6983 self.state.merge = Some(outcome);
6984 self.state.save()?;
6985
6986 if self.state.config.graph.land
6992 && mode == MergeMode::Pr
6993 && self.state.status == RunStatus::Merged
6994 {
6995 self.run_land().await?;
6996 }
6997 self.settle_questions();
7002 Ok(())
7003 }
7004
7005 async fn run_land(&mut self) -> Result<()> {
7016 let url = self
7017 .state
7018 .merge
7019 .as_ref()
7020 .map(|m| m.detail.clone())
7021 .unwrap_or_default();
7022 let url = url.lines().next().unwrap_or("").trim().to_owned();
7023 if !url.starts_with("http") {
7024 return Ok(());
7025 }
7026 match land::land(&mut self.state, &url).await {
7029 Ok(pr) if self.state.parked => {
7030 let _ = pr;
7034 }
7035 Ok(pr) => {
7036 self.state.status = match pr.state {
7037 land::PrLifecycle::Merged => RunStatus::Merged,
7038 _ => RunStatus::Blocked,
7039 };
7040 if bump::should_release_bump(self.state.status)
7047 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7048 {
7049 self.state
7055 .event("bump", format!("release bump skipped: {e:#}"));
7056 }
7057 if self.state.status == RunStatus::Merged {
7060 crate::followup::after_merge(&mut self.state, &pr.url).await;
7061 }
7062 self.state.save()?;
7063 }
7064 Err(e) => {
7065 self.state.status = RunStatus::Blocked;
7066 self.state.event("land", format!("gave up: {e}"));
7067 self.state.save()?;
7068 }
7069 }
7070 Ok(())
7071 }
7072
7073 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7077 if let Some(existing) = self.state.seats.get(key)
7078 && existing.agent == agent
7079 {
7080 return existing.clone();
7081 }
7082 let fresh = if self.state.seats.contains_key(key) {
7085 handover_seat(key, agent, self.state.next_seat_seed())
7086 } else {
7087 SeatState::new(key, agent, self.state.seed)
7088 };
7089 self.state.seats.insert(key.to_owned(), fresh.clone());
7090 fresh
7091 }
7092
7093 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7099 match self.state.seats.get(key) {
7100 Some(s) if s.agent != spec.id => {
7101 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7102 }
7103 _ => spec,
7104 }
7105 }
7106
7107 fn view(&self, c: &Candidate) -> CandidateView {
7109 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7110 .unwrap_or_default();
7111 let (patch, _) = blind::sanitize_patch(
7112 &format!("candidate {} patch", c.label),
7113 &raw,
7114 &self.state.config.blind,
7115 );
7116 CandidateView {
7117 label: c.label,
7118 branch: c.branch.clone(),
7119 summary: c.summary.clone(),
7120 stat: c.stat.clone(),
7121 patch,
7122 }
7123 }
7124
7125 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7127 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7128 prompt::judge(
7129 "(see above)",
7130 &views,
7131 self.roles.judges.len(),
7132 base_short,
7133 "en",
7134 )
7135 }
7136
7137 fn vote_prompt_full(
7143 &self,
7144 j: usize,
7145 labels: &[char],
7146 language: &str,
7147 candidates: &[Candidate],
7148 base_short: &str,
7149 ) -> String {
7150 let mut text = format!(
7151 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7152 prompt::final_vote(labels, language),
7153 self.state.instruction,
7154 self.candidate_block(candidates, base_short)
7155 );
7156 if let Some(own) = self
7157 .state
7158 .judgements
7159 .get(j)
7160 .filter(|r| !r.ranking.is_empty())
7161 {
7162 let reasons = own
7163 .reasons
7164 .iter()
7165 .map(|(k, v)| format!("- {k}: {v}"))
7166 .collect::<Vec<_>>()
7167 .join("\n");
7168 text.push_str(&format!(
7169 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
7170 own.ranking.iter().collect::<String>(),
7171 if reasons.is_empty() {
7172 ""
7173 } else {
7174 ", because:\n"
7175 }
7176 ));
7177 }
7178 if !self.state.deliberation.is_empty() {
7179 text.push_str("\n# What was argued before this vote\n");
7180 for t in self.transcript(&[], j) {
7181 text.push_str(&format!(
7182 "\n## {}{}\n\n{}\n",
7183 t.who,
7184 if t.is_self { " (you)" } else { "" },
7185 t.body.trim()
7186 ));
7187 }
7188 }
7189 text
7190 }
7191
7192 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
7199 let mut turns = Vec::new();
7200 for j in &self.state.judgements {
7201 if j.ranking.is_empty() {
7202 continue;
7203 }
7204 let reasons = j
7205 .reasons
7206 .iter()
7207 .map(|(k, v)| format!("- {k}: {v}"))
7208 .collect::<Vec<_>>()
7209 .join("\n");
7210 turns.push(Turn {
7211 who: format!("Judge {} (opening ranking)", j.judge),
7212 is_self: j.judge == self_idx + 1,
7213 body: format!(
7214 "Ranked {}{}{reasons}",
7215 j.ranking.iter().collect::<String>(),
7216 if reasons.is_empty() {
7217 ""
7218 } else {
7219 ", because:\n"
7220 }
7221 ),
7222 });
7223 }
7224 for t in self
7225 .state
7226 .deliberation
7227 .iter()
7228 .flat_map(|r| r.turns.iter())
7229 .chain(current)
7230 {
7231 turns.push(Turn {
7232 who: format!("Judge {}", t.judge),
7233 is_self: t.judge == self_idx + 1,
7234 body: t.body.clone(),
7235 });
7236 }
7237 turns
7238 }
7239}
7240
7241fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
7243 agent::has_session(spec.kind, seat, sessions)
7244}
7245
7246fn next_untried_in_roster<'a>(
7267 roster: &'a [AgentSpec],
7268 start: usize,
7269 tried: &BTreeSet<String>,
7270) -> Option<&'a AgentSpec> {
7271 roster
7272 .get(start + 1..)?
7273 .iter()
7274 .find(|s| !tried.contains(&s.id))
7275}
7276
7277fn pick_successor<'a>(
7298 roster: &'a [AgentSpec],
7299 start: usize,
7300 tried: &BTreeSet<String>,
7301 carried: Option<&BTreeSet<String>>,
7302 others: &BTreeSet<String>,
7303) -> Option<&'a AgentSpec> {
7304 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
7305 roster
7306 .get(start + 1..)
7307 .and_then(|tail| {
7308 tail.iter()
7309 .filter(free)
7310 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
7311 })
7312 .or_else(|| {
7313 carried
7314 .is_some()
7315 .then(|| roster.iter().find(free))
7316 .flatten()
7317 })
7318 .or_else(|| match carried {
7319 Some(c) => next_for_seat(roster, start, tried, c),
7320 None => next_untried_in_roster(roster, start, tried),
7321 })
7322}
7323
7324fn next_for_seat<'a>(
7334 roster: &'a [AgentSpec],
7335 start: usize,
7336 round_tried: &BTreeSet<String>,
7337 carried_failed: &BTreeSet<String>,
7338) -> Option<&'a AgentSpec> {
7339 roster
7340 .get(start + 1..)?
7341 .iter()
7342 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
7343 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
7344}
7345
7346fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
7352 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
7353 return spec;
7354 };
7355 let ok = |id: &str| !h.failed.contains(id);
7356 if let Some(last) = h.last_ok.as_deref()
7357 && ok(last)
7358 && let Some(s) = roster.iter().find(|s| s.id == last)
7359 {
7360 return s.clone();
7361 }
7362 if ok(&spec.id) {
7363 return spec;
7364 }
7365 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
7366 roster
7367 .get(from + 1..)
7368 .into_iter()
7369 .flatten()
7370 .chain(roster.iter())
7371 .find(|s| ok(&s.id))
7372 .cloned()
7373 .unwrap_or(spec)
7374}
7375
7376pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
7380 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
7381}
7382
7383const TIMED_OUT: &str = "timed out";
7386
7387impl FailClass {
7388 fn of(out: &AgentOutcome) -> Option<Self> {
7390 match out {
7391 AgentOutcome::Ok(_) => None,
7392 AgentOutcome::Quota(_) => Some(Self::Quota),
7393 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7394 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7395 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7396 }
7397 }
7398
7399 fn stem_word(&self) -> &'static str {
7401 match self {
7402 Self::Quota => "quota",
7403 _ => "handover",
7404 }
7405 }
7406}
7407
7408fn failure_signature(msg: &str) -> String {
7412 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7413 let mut out = Vec::new();
7414 for word in line.split_whitespace() {
7415 if word.contains('/') || word.contains('\\') {
7416 out.push("<path>".to_owned());
7417 continue;
7418 }
7419 let mut w = String::new();
7420 let mut in_digits = false;
7421 for c in word.chars() {
7422 if c.is_ascii_digit() {
7423 if !in_digits {
7424 w.push('#');
7425 }
7426 in_digits = true;
7427 } else {
7428 in_digits = false;
7429 w.push(c);
7430 }
7431 }
7432 out.push(w);
7433 }
7434 out.join(" ").chars().take(120).collect()
7435}
7436
7437fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7444 match cur {
7445 FailClass::Quota | FailClass::Timeout => true,
7446 FailClass::Other(_) => prev != Some(cur),
7447 }
7448}
7449
7450fn fail_reason(out: &AgentOutcome) -> String {
7452 match out {
7453 AgentOutcome::Ok(_) => String::new(),
7454 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7455 AgentOutcome::Dropped(o) => format!(
7456 "the CLI dropped the stream ({})",
7457 o.dropped
7458 .as_ref()
7459 .map(|d| d.why.as_str())
7460 .unwrap_or("it ended without delivering its answer")
7461 ),
7462 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7463 }
7464}
7465
7466pub(crate) fn record_handover(
7469 state: &mut RunState,
7470 node: &str,
7471 seat: &str,
7472 from: &str,
7473 to: &str,
7474 class: &FailClass,
7475 reason: &str,
7476) {
7477 let message = if *class == FailClass::Quota {
7478 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7479 } else {
7480 format!("{seat}: handed over {from} -> {to} ({reason})")
7481 };
7482 state.event(node, message);
7483 state.handovers.push(Handover {
7484 at: Timestamp::now(),
7485 node: node.to_owned(),
7486 seat: seat.to_owned(),
7487 from: from.to_owned(),
7488 to: to.to_owned(),
7489 reason: reason.to_owned(),
7490 });
7491}
7492
7493fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7507 commands.iter().any(|c| c.exit_code.is_none())
7508}
7509
7510fn verified_noop_claim(
7523 usable: bool,
7524 commands: &[agent::CommandEvidence],
7525 text: &str,
7526) -> Option<String> {
7527 (usable && !has_unconfirmed_command(commands))
7528 .then(|| verdict::verified_noop(text))
7529 .flatten()
7530}
7531
7532fn short(commit: &str) -> String {
7533 commit.chars().take(7).collect()
7534}
7535
7536fn make_executable(path: &Path) -> Result<()> {
7537 #[cfg(unix)]
7538 {
7539 use std::os::unix::fs::PermissionsExt as _;
7540 let mut perms = std::fs::metadata(path)?.permissions();
7541 perms.set_mode(0o755);
7542 std::fs::set_permissions(path, perms)?;
7543 }
7544 #[cfg(not(unix))]
7545 {
7546 let _ = path;
7547 }
7548 Ok(())
7549}
7550
7551struct WaveCtx<'a> {
7558 run: &'a str,
7561 node: &'a str,
7563 prompts: &'a Prompts,
7564 cache: Option<&'a Path>,
7566 round: Option<usize>,
7569 carry_seats: bool,
7573}
7574
7575async fn run_one(
7577 job: SeatJob,
7578 sem: Arc<Semaphore>,
7579 ctx: &WaveCtx<'_>,
7580 state: &mut RunState,
7581 attempt: usize,
7582) -> (SeatState, AgentOutcome) {
7583 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7584 .await
7585 .pop()
7586 .expect("one job in, one result out");
7587 (seat, out)
7588}
7589
7590async fn wave(
7596 jobs: Vec<SeatJob>,
7597 sem: Arc<Semaphore>,
7598 ctx: &WaveCtx<'_>,
7599 state: &mut RunState,
7600 attempt: usize,
7601) -> Vec<(usize, SeatState, AgentOutcome)> {
7602 let WaveCtx {
7603 run,
7604 node,
7605 prompts,
7606 cache,
7607 round,
7608 carry_seats: _,
7609 } = *ctx;
7610 for job in &jobs {
7611 state.seat_started(node, &job.seat.key, job.timeout, attempt);
7612 }
7613 if let Err(e) = state.save() {
7614 tracing::warn!("could not persist in-progress seats: {e:#}");
7619 }
7620 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7636 let wait_started = Instant::now();
7637 let cache_guard = if let Some(cache_dir) = cache {
7638 if jobs_had_a_writer {
7639 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7640 let budget = jobs
7641 .iter()
7642 .map(|j| j.timeout)
7643 .max()
7644 .unwrap_or(Duration::from_secs(60));
7645 acquire_cache_lease(state, cache_dir, &owner, budget, node)
7646 .await
7647 .ok()
7648 } else {
7649 None
7650 }
7651 } else {
7652 None
7653 };
7654 let waited_for_lease = wait_started.elapsed();
7661 let mut set = tokio::task::JoinSet::new();
7662 let overlay = prompts.overlay(node);
7663 for (i, mut job) in jobs.into_iter().enumerate() {
7664 job.timeout = job.timeout.saturating_sub(waited_for_lease);
7665 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7666 if cache.is_some() {
7667 job.prompt.push('\n');
7668 job.prompt
7669 .push_str(&prompt::build_cache_note(node, job.allow_write));
7670 }
7671 let sem = Arc::clone(&sem);
7672 let run = run.to_owned();
7673 let node = node.to_owned();
7674 let attachments = if node == "implement" {
7677 state.attachments.clone()
7678 } else {
7679 Vec::new()
7680 };
7681 let cache = cache
7692 .filter(|_| job.allow_write && cache_guard.is_some())
7693 .map(Path::to_path_buf);
7694 set.spawn(async move {
7695 let _permit = sem.acquire().await;
7696 let mut seat = job.seat;
7697 let out = agent::invoke(
7698 &job.spec,
7699 &mut seat,
7700 &Invocation {
7701 cwd: &job.cwd,
7702 prompt: &job.prompt,
7703 timeout: job.timeout,
7704 allow_write: job.allow_write,
7705 unsandboxed: false,
7706 sessions: job.sessions,
7707 artifacts: &job.artifacts,
7708 stem: &job.stem,
7709 run: &run,
7710 node: &node,
7711 cache_dir: cache.as_deref(),
7712 attachments: &attachments,
7713 writable: &[],
7714 },
7715 )
7716 .await;
7717 let out = match out {
7718 Ok(o) if o.usable() => AgentOutcome::Ok(o),
7719 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7720 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7728 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7729 Ok(o) => AgentOutcome::Failed(format!(
7730 "exited with {:?} and no usable output",
7731 o.exit_code
7732 )),
7733 Err(e) => AgentOutcome::Failed(e.to_string()),
7734 };
7735 (i, seat, out)
7736 });
7737 }
7738 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7739 while let Some(joined) = set.join_next().await {
7740 let (i, seat, out) = match joined {
7741 Ok(v) => v,
7742 Err(e) => {
7746 tracing::error!("agent task panicked: {e}");
7747 continue;
7748 }
7749 };
7750 state.seat_finished(&seat.key);
7751 record_jobs(state, node, round, &seat.key, &out);
7752 if let Err(e) = state.save() {
7753 tracing::warn!("could not persist a seat's completion: {e:#}");
7754 }
7755 if collected.len() <= i {
7756 collected.resize_with(i + 1, || None);
7757 }
7758 collected[i] = Some((i, seat, out));
7759 }
7760 if state
7766 .active
7767 .values()
7768 .any(|a| a.node == node && a.attempt == attempt)
7769 {
7770 state
7771 .active
7772 .retain(|_, a| !(a.node == node && a.attempt == attempt));
7773 if let Err(e) = state.save() {
7774 tracing::warn!("could not persist the end of a wave: {e:#}");
7775 }
7776 }
7777 if let Some(cache_dir) = cache
7784 && jobs_had_a_writer
7785 {
7786 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7787 }
7788 if let Some(guard) = cache_guard {
7789 guard.release();
7790 }
7791 collected.into_iter().flatten().collect()
7792}
7793
7794fn record_jobs(
7805 state: &mut RunState,
7806 node: &str,
7807 round: Option<usize>,
7808 seat: &str,
7809 out: &AgentOutcome,
7810) {
7811 let commands: &[agent::CommandEvidence] = match out {
7812 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7813 AgentOutcome::Failed(_) => &[],
7814 };
7815 let checked_at = Timestamp::now();
7816 for c in commands {
7817 state.jobs.push(JobRecord {
7818 node: node.to_owned(),
7819 round,
7820 seat: seat.to_owned(),
7821 id: c.id.clone(),
7822 description: c.description.clone(),
7823 checked_at,
7824 status: match c.exit_code {
7825 Some(0) => JobStatus::Completed,
7826 Some(_) => JobStatus::Failed,
7827 None => JobStatus::Unknown,
7828 },
7829 exit_code: c.exit_code,
7830 result_summary: c.result_summary.clone(),
7831 source: c.source.clone(),
7832 });
7833 }
7834}
7835
7836fn round_is_clean(
7857 blocking: usize,
7858 e2e_ok: bool,
7859 answered: usize,
7860 expected: usize,
7861 quota_missing: usize,
7862 policy: IncompleteReviewPolicy,
7863) -> bool {
7864 if blocking != 0 || !e2e_ok {
7865 return false;
7866 }
7867 if answered == expected || policy == IncompleteReviewPolicy::Warn {
7868 return true;
7869 }
7870 answered > 0 && expected - answered <= quota_missing
7871}
7872
7873fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7897 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7898 return Some(RunStatus::Gating);
7899 }
7900 let last = reviews.last()?;
7901 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7902 if reviews.len() < max_rounds && !stagnant {
7903 return None;
7904 }
7905 if last.incomplete() && last.blocking == 0 {
7906 return Some(RunStatus::Blocked);
7907 }
7908 if last.e2e_status() == E2eStatus::ResourceBlocked {
7909 return None;
7910 }
7911 Some(if last.e2e.iter().all(CommandOutcome::ok) {
7912 RunStatus::Gating
7913 } else {
7914 RunStatus::Blocked
7915 })
7916}
7917
7918pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7933 if nudged {
7934 (full / 4).max(Duration::from_secs(120)).min(full)
7935 } else {
7936 full
7937 }
7938}
7939
7940#[allow(clippy::too_many_arguments)]
7969async fn ask_json_wave<T>(
7970 jobs: Vec<SeatJob>,
7971 sem: Arc<Semaphore>,
7972 retries: usize,
7973 roster: &[AgentSpec],
7974 ctx: &WaveCtx<'_>,
7975 losses: &mut Vec<QuotaLoss>,
7976 state: &mut RunState,
7977 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7978) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7979where
7980 T: serde::de::DeserializeOwned + Send + 'static,
7981{
7982 ask_wave_with(
7983 jobs,
7984 sem,
7985 retries,
7986 roster,
7987 ctx,
7988 losses,
7989 state,
7990 &|text: &str| {
7991 let v = verdict::extract_json::<T>(text)?;
7992 validate(&v)?;
7993 Ok(v)
7994 },
7995 )
7996 .await
7997}
7998
7999#[allow(clippy::too_many_arguments)]
8008async fn ask_wave_with<T>(
8009 jobs: Vec<SeatJob>,
8010 sem: Arc<Semaphore>,
8011 retries: usize,
8012 roster: &[AgentSpec],
8013 ctx: &WaveCtx<'_>,
8014 losses: &mut Vec<QuotaLoss>,
8015 state: &mut RunState,
8016 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8017) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8018where
8019 T: Send + 'static,
8020{
8021 let n = jobs.len();
8022 let originals: Vec<SeatJob> = jobs;
8023 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8024 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8025 let mut nudges: Vec<usize> = vec![0; n];
8032 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8036 let mut tried: Vec<BTreeSet<String>> = specs
8037 .iter()
8038 .map(|s| BTreeSet::from([s.id.clone()]))
8039 .collect();
8040 let starts: Vec<usize> = specs
8041 .iter()
8042 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8043 .collect();
8044 let carry = ctx.carry_seats && !roster.is_empty();
8045 let carried: Vec<BTreeSet<String>> = originals
8048 .iter()
8049 .map(|j| {
8050 state
8051 .seat_history
8052 .get(&j.seat.key)
8053 .filter(|_| carry)
8054 .map(|h| h.failed.clone())
8055 .unwrap_or_default()
8056 })
8057 .collect();
8058 let mut prev: Vec<Option<FailClass>> = originals
8059 .iter()
8060 .map(|j| {
8061 state
8062 .seat_history
8063 .get(&j.seat.key)
8064 .filter(|_| carry)
8065 .and_then(|h| h.last_fail.clone())
8066 })
8067 .collect();
8068 let next_agent =
8069 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
8070 let others: BTreeSet<String> = specs
8071 .iter()
8072 .enumerate()
8073 .filter(|(j, _)| *j != i)
8074 .map(|(_, s)| s.id.clone())
8075 .collect();
8076 pick_successor(
8077 roster,
8078 starts[i],
8079 tried,
8080 carry.then(|| &carried[i]),
8081 &others,
8082 )
8083 .cloned()
8084 };
8085 let mut fresh: Vec<Option<String>> = vec![None; n];
8086 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
8087 let mut pending: Vec<usize> = (0..n).collect();
8088
8089 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
8094 for round in 0..max_rounds {
8095 if pending.is_empty() {
8096 break;
8097 }
8098 let mut batch = Vec::with_capacity(pending.len());
8099 let mut renudged: Vec<&str> = Vec::new();
8100 for &i in &pending {
8101 let src = &originals[i];
8102 let full: &str = match &src.handover {
8107 Some(h) if specs[i].id != src.spec.id => h,
8108 _ => &src.prompt,
8109 };
8110 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
8113 (
8114 full.to_owned(),
8115 src.timeout,
8116 format!("{}-{word}-{}", src.stem, specs[i].id),
8117 )
8118 } else if nudges[i] == 0 {
8119 (full.to_owned(), src.timeout, src.stem.clone())
8120 } else {
8121 renudged.push(src.seat.key.as_str());
8122 let why = done[i]
8123 .as_ref()
8124 .and_then(|r| r.as_ref().err().map(ToString::to_string))
8125 .unwrap_or_else(|| "no parsable answer".to_owned());
8126 let nudge = prompt::nudge(&why);
8127 let nudged = has_context(&specs[i], &seats[i], src.sessions);
8128 let prompt = if nudged {
8129 nudge
8130 } else {
8131 format!("{full}\n\n---\n\n{nudge}")
8132 };
8133 (
8134 prompt,
8135 retry_budget(src.timeout, nudged),
8136 format!("{}-retry{}", src.stem, nudges[i]),
8137 )
8138 };
8139 batch.push(SeatJob {
8140 spec: specs[i].clone(),
8141 seat: seats[i].clone(),
8142 cwd: src.cwd.clone(),
8143 prompt,
8144 timeout,
8145 allow_write: src.allow_write,
8146 sessions: src.sessions,
8147 artifacts: src.artifacts.clone(),
8148 stem,
8149 handover: None,
8150 });
8151 }
8152
8153 if !renudged.is_empty() {
8154 state.event(
8155 ctx.node,
8156 format!("retry {round}: re-asking {}", renudged.join(", ")),
8157 );
8158 }
8159 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
8160 let mut still = Vec::new();
8161 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
8162 seats[i] = seat;
8163 let class = FailClass::of(&out);
8164 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
8167 && nudges[i] < retries
8168 && !roster.is_empty();
8169 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
8170 let next = should_hand_over(prev[i].as_ref(), &cur)
8171 .then(|| next_agent(i, &tried[i], &specs))
8172 .flatten();
8173 if carry {
8174 let h = state
8175 .seat_history
8176 .entry(originals[i].seat.key.clone())
8177 .or_default();
8178 h.failed.insert(specs[i].id.clone());
8179 h.last_fail = Some(cur.clone());
8180 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
8181 h.last_ok = None;
8182 }
8183 if let Err(e) = state.save() {
8186 tracing::warn!("could not persist a seat's failure history: {e:#}");
8187 }
8188 }
8189 if let Some(next) = next {
8190 record_handover(
8191 state,
8192 ctx.node,
8193 &originals[i].seat.key,
8194 &specs[i].id,
8195 &next.id,
8196 &cur,
8197 &fail_reason(&out),
8198 );
8199 tried[i].insert(next.id.clone());
8200 prev[i] = Some(cur.clone());
8201 seats[i] =
8202 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
8203 specs[i] = next;
8204 fresh[i] = Some(cur.stem_word().to_owned());
8205 nudges[i] = 0;
8206 done[i] = Some(Err(anyhow::anyhow!(
8207 "handed over after: {}",
8208 fail_reason(&out)
8209 )));
8210 still.push(i);
8211 continue;
8212 }
8213 }
8214 if carry
8215 && !nudge_first
8216 && let Some(cur) = class.clone()
8217 {
8218 let h = state
8220 .seat_history
8221 .entry(originals[i].seat.key.clone())
8222 .or_default();
8223 h.failed.insert(specs[i].id.clone());
8224 h.last_fail = Some(cur);
8225 }
8226 let parsed = match out {
8227 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
8228 AgentOutcome::Quota(o) => {
8229 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
8230 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
8231 }
8232 AgentOutcome::Dropped(o) => {
8236 let why = o
8237 .dropped
8238 .as_ref()
8239 .map(|d| d.why.as_str())
8240 .unwrap_or("the CLI ended the stream without delivering its answer");
8241 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
8242 }
8243 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
8244 };
8245 let quota = class == Some(FailClass::Quota);
8246 let failed = parsed.is_err();
8247 done[i] = Some(parsed);
8248 if carry && !failed {
8249 let h = state
8250 .seat_history
8251 .entry(originals[i].seat.key.clone())
8252 .or_default();
8253 h.failed.remove(&specs[i].id);
8254 h.last_ok = Some(specs[i].id.clone());
8255 h.last_fail = None;
8256 }
8257 let agent_failure = class.is_some()
8265 && !nudge_first
8266 && !roster.is_empty()
8267 && next_agent(i, &tried[i], &specs).is_some();
8268 if failed && !quota && !agent_failure && nudges[i] < retries {
8269 nudges[i] += 1;
8270 still.push(i);
8271 }
8272 }
8273 pending = still;
8274 }
8275
8276 for (i, q) in last_quota.into_iter().enumerate() {
8279 if let Some(reset) = q {
8280 losses.push(QuotaLoss {
8281 seat: originals[i].seat.key.clone(),
8282 node: ctx.node.to_owned(),
8283 at: Timestamp::now(),
8284 reset,
8285 });
8286 }
8287 }
8288
8289 seats
8290 .into_iter()
8291 .zip(done)
8292 .zip(nudges)
8293 .map(|((seat, res), attempts)| {
8294 (
8295 seat,
8296 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
8297 attempts,
8298 )
8299 })
8300 .collect()
8301}
8302
8303async fn acquire_cache_lease(
8316 state: &mut RunState,
8317 cache_dir: &Path,
8318 owner: &crate::cache::Owner,
8319 budget: Duration,
8320 context: &str,
8321) -> Result<crate::cache::Guard> {
8322 let home = crate::run::home();
8323 let started = Instant::now();
8324 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
8325 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
8326 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
8327 Err(e) => {
8328 state.event(
8329 "verify",
8330 format!("{context}: could not check the shared build cache: {e:#}"),
8331 );
8332 if let Err(e2) = state.save() {
8333 tracing::warn!("could not persist a cache-check failure: {e2:#}");
8334 }
8335 return Err(e);
8336 }
8337 };
8338 state.event(
8339 "verify",
8340 format!(
8341 "{context}: waiting for the shared build cache at {} ({})",
8342 cache_dir.display(),
8343 busy.describe()
8344 ),
8345 );
8346 if let Err(e) = state.save() {
8347 tracing::warn!("could not persist a cache wait: {e:#}");
8348 }
8349 let remaining = budget.saturating_sub(started.elapsed());
8350 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
8351 Ok(g) => Ok(g),
8352 Err(e) => {
8353 state.event("verify", format!("{context}: {e:#}"));
8354 if let Err(e2) = state.save() {
8355 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
8356 }
8357 Err(e)
8358 }
8359 }
8360}
8361
8362#[allow(clippy::too_many_arguments)]
8383async fn with_cache_lease<'s, F, Fut>(
8384 state: &'s mut RunState,
8385 cache_dir: Option<&Path>,
8386 node: &str,
8387 seat: &str,
8388 worktree: &Path,
8389 head: &str,
8390 budget: Duration,
8391 context: &str,
8392 body: F,
8393) -> (Vec<CommandOutcome>, bool)
8394where
8395 F: FnOnce(&'s mut RunState, Duration) -> Fut,
8396 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8397{
8398 let Some(cache_dir) = cache_dir else {
8399 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8400 return (outcomes, retried);
8401 };
8402 let home = crate::run::home();
8403 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8404 let started = Instant::now();
8405 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8406 Ok(g) => g,
8407 Err(e) => {
8408 return (
8409 vec![CommandOutcome {
8410 command: "(waiting for the shared build cache)".to_owned(),
8411 code: None,
8412 output_tail: e.to_string(),
8413 duration_ms: started.elapsed().as_millis() as u64,
8414 resource_blocked: true,
8415 }],
8416 false,
8417 );
8418 }
8419 };
8420 let identity = crate::cache::Identity::new(worktree, head);
8421 if let Err(e) = crate::cache::ensure_fresh(
8422 &home,
8423 cache_dir,
8424 &identity,
8425 &state.config.verify.cargo_manifests(),
8426 ) {
8427 state.event(
8435 "verify",
8436 format!(
8437 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8438 worktree.display(),
8439 short(head)
8440 ),
8441 );
8442 guard.release();
8443 return (
8444 vec![CommandOutcome {
8445 command: "(confirming the shared build cache is fresh)".to_owned(),
8446 code: None,
8447 output_tail: e.to_string(),
8448 duration_ms: started.elapsed().as_millis() as u64,
8449 resource_blocked: true,
8450 }],
8451 false,
8452 );
8453 }
8454 let remaining = budget.saturating_sub(started.elapsed());
8455 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8456 if !timed_out_pids.is_empty() {
8461 wait_for_timed_out_children_to_die(&timed_out_pids).await;
8462 }
8463 guard.release();
8464 (outcomes, retried)
8465}
8466
8467async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8479 wait_for_pids_with(
8480 pids,
8481 crate::proc::pid_alive,
8482 LEASE_RELEASE_POLL,
8483 LEASE_RELEASE_MAX_WAIT,
8484 )
8485 .await;
8486}
8487
8488async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8494 pids: &[u32],
8495 alive: F,
8496 poll: Duration,
8497 max_wait: Duration,
8498) {
8499 let deadline = Instant::now() + max_wait;
8500 loop {
8501 if pids.iter().all(|&pid| !alive(pid)) {
8502 return;
8503 }
8504 if Instant::now() >= deadline {
8505 return;
8506 }
8507 tokio::time::sleep(poll).await;
8508 }
8509}
8510
8511fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8518 outcomes.iter().any(|o| o.resource_blocked)
8519}
8520
8521enum GateFix {
8523 Retry,
8525 Stop,
8528 Defer,
8531}
8532
8533fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8541 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8542 red.peek().is_some()
8543 && red.all(|o| {
8544 !o.resource_blocked
8545 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8546 && !o.output_tail.trim().is_empty()
8547 })
8548}
8549
8550fn e2e_outcome_label(o: &CommandOutcome) -> String {
8554 if o.ok() {
8555 return "pass".to_owned();
8556 }
8557 let reason = if o.build_failed() {
8558 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8559 } else {
8560 format!("FAIL ({:?})", o.code)
8561 };
8562 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8563}
8564
8565async fn run_e2e_with_retry(
8573 state: &mut RunState,
8574 shell: &[String],
8575 commands: &[String],
8576 worktree: &Path,
8577 timeout: Duration,
8578 context: &str,
8579) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8580 let (mut e2e, mut timed_out_pids) = run_commands(
8581 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8582 )
8583 .await;
8584 for o in &e2e {
8585 state.event(
8586 "verify",
8587 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8588 );
8589 }
8590 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8594 if verify_retried {
8595 state.event(
8596 "verify",
8597 format!(
8598 "{context}: verify could not build/link, not a test result — retrying once \
8599 before concluding"
8600 ),
8601 );
8602 let retried = run_commands(
8603 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8604 )
8605 .await;
8606 e2e = retried.0;
8607 timed_out_pids.extend(retried.1);
8610 for o in &e2e {
8611 state.event(
8612 "verify",
8613 format!(
8614 "{context}: retry `{}` -> {}",
8615 o.command,
8616 e2e_outcome_label(o)
8617 ),
8618 );
8619 }
8620 }
8621 (e2e, verify_retried, timed_out_pids)
8622}
8623
8624#[allow(clippy::too_many_arguments)]
8640async fn run_commands(
8641 state: &mut RunState,
8642 node: &str,
8643 task: &str,
8644 attempt: usize,
8645 shell: &[String],
8646 commands: &[String],
8647 cwd: &Path,
8648 timeout: Duration,
8649) -> (Vec<CommandOutcome>, Vec<u32>) {
8650 if commands.is_empty() {
8651 return (Vec::new(), Vec::new());
8656 }
8657 let mut out = Vec::new();
8658 let mut timed_out_pids = Vec::new();
8659 let total = commands.len();
8660 for (idx, command) in commands.iter().enumerate() {
8661 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8662 if let Err(e) = state.save() {
8663 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8664 }
8665 let started = Instant::now();
8666 let mut cmd = tokio::process::Command::new(&shell[0]);
8667 cmd.quiet();
8668 cmd.args(&shell[1..])
8669 .arg(command)
8670 .current_dir(cwd)
8671 .stdin(std::process::Stdio::null())
8672 .stdout(std::process::Stdio::piped())
8673 .stderr(std::process::Stdio::piped())
8674 .kill_on_drop(true);
8675 let spawned = cmd.spawn();
8676 let (code, body) = match spawned {
8677 Ok(child) => {
8678 let pid = child.id();
8683 match tokio::time::timeout(timeout, child.wait_with_output()).await {
8684 Ok(Ok(o)) => {
8685 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8686 body.push_str(&String::from_utf8_lossy(&o.stderr));
8687 (o.status.code(), body)
8688 }
8689 Ok(Err(e)) => (None, format!("failed to run: {e}")),
8690 Err(_) => {
8691 if let Some(pid) = pid {
8692 timed_out_pids.push(pid);
8693 }
8694 (None, format!("timed out after {}s", timeout.as_secs()))
8695 }
8696 }
8697 }
8698 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8699 };
8700 out.push(CommandOutcome {
8701 command: command.clone(),
8702 code,
8703 output_tail: tail(&body, OUTPUT_TAIL),
8704 duration_ms: started.elapsed().as_millis() as u64,
8705 resource_blocked: false,
8706 });
8707 }
8708 state.task_finished(task);
8709 if let Err(e) = state.save() {
8710 tracing::warn!("could not persist the end of {task}: {e:#}");
8711 }
8712 (out, timed_out_pids)
8713}
8714
8715fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8727 let repo = repo.display();
8728 match style {
8729 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8730 MergeStyle::Squash => {
8731 let subject = message
8734 .lines()
8735 .next()
8736 .unwrap_or(branch)
8737 .replace(['\\', '"', '$', '`'], "");
8738 format!(
8739 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8740 )
8741 }
8742 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8743 }
8744}
8745
8746const PR_TITLE_MAX: usize = 240;
8757
8758fn english_title(text: &str, max: usize) -> Option<String> {
8769 let line = queue::first_line(text)?;
8770 let chars: Vec<char> = line.chars().collect();
8771 let (kept, mark) = if chars.len() <= max {
8772 (line.to_owned(), "")
8773 } else if let Some(end) = sentence_end(&chars, max) {
8774 (chars[..end].iter().collect::<String>(), "")
8775 } else {
8776 let room = max.saturating_sub(3);
8777 let cut = if chars[room].is_whitespace() {
8780 room
8781 } else {
8782 chars[..room]
8783 .iter()
8784 .rposition(|c| c.is_whitespace())
8785 .unwrap_or(room)
8786 };
8787 let head: String = chars[..cut].iter().collect();
8788 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8789 (head.to_owned(), "...")
8790 };
8791 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8792 return None;
8793 }
8794 Some(format!("{kept}{mark}"))
8795}
8796
8797fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8801 const MIN: usize = 20;
8802 for i in MIN..max.min(chars.len()) {
8803 if !matches!(chars[i], '.' | '!' | '?') {
8804 continue;
8805 }
8806 let Some(&next) = chars.get(i + 1) else {
8807 continue;
8808 };
8809 if !next.is_whitespace() {
8810 continue;
8811 }
8812 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8813 if after.is_some_and(|c| c.is_ascii_lowercase()) {
8814 continue;
8815 }
8816 let word: String = chars[..i]
8817 .iter()
8818 .rev()
8819 .take_while(|c| !c.is_whitespace())
8820 .collect::<Vec<_>>()
8821 .into_iter()
8822 .rev()
8823 .collect();
8824 let word = word.to_ascii_lowercase();
8825 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8826 continue;
8827 }
8828 let end = chars[..i]
8829 .iter()
8830 .rposition(|c| !c.is_whitespace())
8831 .map_or(i, |p| p + 1);
8832 return Some(end);
8833 }
8834 None
8835}
8836
8837struct PrMessage {
8842 title: String,
8843 body: String,
8844}
8845
8846impl PrMessage {
8847 fn commit_message(&self) -> String {
8851 format!("{}\n\n{}", self.title, self.body)
8852 }
8853}
8854
8855fn title_marker(line: &str) -> Option<&str> {
8857 let line = line.trim();
8858 let head = line.get(..6)?;
8859 head.eq_ignore_ascii_case("title:")
8860 .then(|| line[6..].trim())
8861}
8862
8863fn summary_title(summary: &str) -> Option<String> {
8868 let first = summary.lines().find(|l| !l.trim().is_empty())?;
8869 let raw = title_marker(first)?;
8870 if raw.is_empty() {
8871 return None;
8872 }
8873 let title = queue::title_from(raw, PR_TITLE_MAX);
8874 let lower = title.to_ascii_lowercase();
8875 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8876 return None;
8877 }
8878 Some(title)
8879}
8880
8881const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8883
8884const EXISTING_BRANCH: &str = "(existing branch)";
8886
8887fn is_review_run(state: &RunState) -> bool {
8891 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8892}
8893
8894fn review_title(state: &RunState) -> Option<String> {
8899 english_subject(state.reviewed_commits.as_ref()?.first()?)
8900}
8901
8902fn english_subject(raw: &str) -> Option<String> {
8905 let raw = raw.trim();
8906 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8907 return None;
8908 }
8909 let title = queue::title_from(raw, PR_TITLE_MAX);
8910 let lower = title.to_ascii_lowercase();
8911 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8912 return None;
8913 }
8914 Some(title)
8915}
8916
8917#[derive(Debug, Clone, PartialEq, Eq)]
8920struct BranchFacts {
8921 commits: Vec<(String, String)>,
8923 stat: String,
8925}
8926
8927const STAT_FILE_LINES: usize = 25;
8929const COMMITS_MAX_CHARS: usize = 20_000;
8931
8932async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8935 let commits = git::commit_log(repo, base, branch).await.ok()?;
8936 if commits.is_empty() {
8937 return None;
8938 }
8939 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8940 let lines: Vec<&str> = stat.lines().collect();
8941 let stat = if lines.len() > STAT_FILE_LINES + 1 {
8942 let omitted = lines.len() - 1 - STAT_FILE_LINES;
8943 let more = format!(" ... {omitted} more file(s)");
8944 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8945 kept.push(&more);
8946 kept.push(lines[lines.len() - 1]);
8947 kept.join("\n")
8948 } else {
8949 lines.join("\n")
8950 };
8951 Some(BranchFacts { commits, stat })
8952}
8953
8954fn markdown_safe(text: &str) -> String {
8957 text.replace("</details>", "</details>")
8958 .replace("\x60\x60\x60", "~~~")
8959}
8960
8961fn neutral_title(state: &RunState, winner: char) -> String {
8962 format!(
8963 "chore: land candidate {} of run {}",
8964 winner.to_ascii_uppercase(),
8965 state.id
8966 )
8967}
8968
8969pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8975 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8976 ""
8977 } else {
8978 pr_title
8979 }
8980}
8981
8982pub fn landing_subject_source(state: &RunState) -> String {
8986 if is_review_run(state) {
8987 let winner = state.candidates.first().map_or('A', |c| c.label);
8988 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8989 }
8990 state.instruction.clone()
8991}
8992
8993fn summary_without_title(summary: &str) -> String {
8996 let mut lines = summary.trim().lines().peekable();
8997 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8998 lines.next();
8999 }
9000 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
9001}
9002
9003#[cfg(test)]
9017fn pr_message(state: &RunState, winner: char) -> PrMessage {
9018 pr_message_with(state, winner, None)
9019}
9020
9021#[cfg(test)]
9024fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9025 let raw = pr_message_raw(state, winner, facts);
9026 let id = crate::scrub::Identity::current();
9027 PrMessage {
9028 title: crate::scrub::scrub(&raw.title, &id),
9029 body: crate::scrub::scrub(&raw.body, &id),
9030 }
9031}
9032
9033fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9034 let summary = state
9035 .candidates
9036 .iter()
9037 .find(|c| c.label == winner)
9038 .map(|c| c.summary.as_str())
9039 .unwrap_or_default();
9040 let review = is_review_run(state);
9043 let title = if review {
9044 facts
9045 .and_then(|f| english_subject(&f.commits.first()?.0))
9046 .or_else(|| review_title(state))
9047 .or_else(|| {
9048 state
9049 .candidates
9050 .iter()
9051 .find(|c| c.label == winner)
9052 .filter(|c| !c.branch.starts_with("magi/"))
9053 .and_then(|c| english_subject(&c.branch))
9054 })
9055 .unwrap_or_else(|| neutral_title(state, winner))
9056 } else {
9057 summary_title(summary).unwrap_or_else(|| {
9058 english_title(&state.instruction, PR_TITLE_MAX)
9059 .unwrap_or_else(|| neutral_title(state, winner))
9060 })
9061 };
9062
9063 let mut body = String::new();
9064 let what = summary_without_title(summary);
9065 if !what.is_empty() {
9066 body.push_str("## Summary\n\n");
9067 body.push_str(&what);
9068 body.push_str("\n\n");
9069 }
9070
9071 let notes: Vec<(usize, &str)> = state
9074 .reviews
9075 .iter()
9076 .filter_map(|r| {
9077 let n = r.fix.as_ref()?.notes.trim();
9078 (!n.is_empty()).then_some((r.round, n))
9079 })
9080 .collect();
9081 if !notes.is_empty() {
9082 body.push_str("## Review fixes\n\n");
9083 if let [(_, only)] = notes.as_slice() {
9084 body.push_str(only);
9085 body.push_str("\n\n");
9086 } else {
9087 for (round, n) in ¬es {
9088 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
9089 }
9090 }
9091 }
9092 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
9093
9094 let open = state.open_findings();
9095 if !open.is_empty() {
9096 body.push_str("## Open review findings\n\n");
9097 for f in &open {
9098 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
9099 }
9100 body.push('\n');
9101 }
9102
9103 if let Some(fix) = fix
9104 && !fix.rejected.is_empty()
9105 {
9106 body.push_str("## Declined by the fixer\n\n");
9107 for r in &fix.rejected {
9108 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
9109 }
9110 body.push('\n');
9111 }
9112
9113 if review {
9114 body.push_str("## Commits under review\n\n");
9116 if let Some(facts) = facts {
9117 let mut left = COMMITS_MAX_CHARS;
9118 for (i, (subject, text)) in facts.commits.iter().enumerate() {
9119 let mut entry = format!("- {}\n", markdown_safe(subject));
9120 for l in markdown_safe(text).lines() {
9121 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
9122 }
9123 if left == 0 {
9124 body.push_str(&format!(
9125 "- ... {} more commit(s)\n",
9126 facts.commits.len() - i
9127 ));
9128 break;
9129 }
9130 if entry.len() > left {
9131 let mut end = left;
9134 while !entry.is_char_boundary(end) {
9135 end -= 1;
9136 }
9137 entry.truncate(end);
9138 entry.push_str("\n ... (truncated)\n");
9139 left = 0;
9140 } else {
9141 left -= entry.len();
9142 }
9143 body.push_str(&entry);
9144 }
9145 if !facts.stat.trim().is_empty() {
9146 body.push_str(&format!(
9147 "\n## Diff stat\n\n```\n{}\n```\n",
9148 markdown_safe(facts.stat.trim())
9149 ));
9150 }
9151 } else {
9152 match &state.reviewed_commits {
9153 Some(subjects) => {
9154 for s in subjects {
9155 body.push_str(&format!("- {}\n", s.trim()));
9156 }
9157 }
9158 None => {
9159 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
9162 body.push_str(rest.trim());
9163 body.push('\n');
9164 }
9165 }
9166 }
9167 } else {
9168 let task = state.instruction.trim();
9169 let task = if task.is_empty() {
9170 "(empty task)"
9171 } else {
9172 task
9173 };
9174 body.push_str(&format!(
9175 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
9176 task.replace("</details>", "</details>")
9177 ));
9178 }
9179
9180 body.push_str(&format!(
9181 "\n---\nmagi:run/{} magi:candidate-{}\n",
9182 state.id,
9183 winner.to_ascii_lowercase()
9184 ));
9185
9186 PrMessage { title, body }
9187}
9188
9189fn seeded_instruction(state: &RunState) -> String {
9193 match refs::describe(&state.seeds) {
9194 Some(facts) => format!(
9195 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
9196 Candidates start from the unmerged branch named above, when there \
9197 is one, and carry any unmerged commit named by sha as a \
9198 cherry-pick. Check that this is what the task meant before \
9199 building on it.",
9200 state.instruction
9201 ),
9202 None => state.instruction.clone(),
9203 }
9204}
9205
9206async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
9210 let base = &state.base_branch;
9211 let mut against = base.clone();
9212 if mode == MergeMode::Pr {
9213 let remote = &state.config.merge.remote;
9217 let tracking = format!("{remote}/{base}");
9218 let fetched = git::fetch(repo, remote, base).await;
9219 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
9220 against = tracking;
9221 } else {
9222 return false;
9223 }
9224 }
9225 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
9226}
9227
9228fn empty_candidate_detail(state: &RunState, base: &str) -> String {
9231 let mut detail = format!(
9232 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
9233 nothing to open a pull request for"
9234 );
9235 match refs::describe(&state.seeds) {
9236 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
9237 None => detail.push_str(
9238 "\nThe task names no existing branch or commit; if it means to land work \
9239 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
9240 ),
9241 }
9242 detail
9243}
9244
9245#[derive(Debug, PartialEq, Eq)]
9248enum PrPlan {
9249 Create,
9250 Adopt { url: String, title: String },
9251 Stop(String),
9252}
9253
9254fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
9257 match found {
9258 Ok(land::OpenPr::None) => PrPlan::Create,
9259 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
9260 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
9261 "several open pull requests exist for this branch, not picking one: {}",
9262 urls.join(" ")
9263 )),
9264 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
9265 }
9266}
9267
9268async fn gh_pr_create(
9270 cwd: &Path,
9271 base: &str,
9272 head: &str,
9273 title: &str,
9274 body: &str,
9275) -> Result<String> {
9276 let out = tokio::process::Command::new("gh")
9277 .args([
9278 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
9279 ])
9280 .current_dir(cwd)
9281 .quiet()
9282 .stdin(std::process::Stdio::null())
9283 .output()
9284 .await
9285 .context("spawn gh")?;
9286 if out.status.success() {
9287 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
9288 } else {
9289 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
9290 }
9291}
9292
9293pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
9302 let repo = state.repo.clone();
9303 let root = state.worktree_root();
9304 let winner = state.tally.as_ref().map(|t| t.winner);
9305 let mut removed = Vec::new();
9306
9307 for i in 0..state.candidates.len() {
9308 let c = state.candidates[i].clone();
9309 let is_winner = Some(c.label) == winner;
9310 if is_winner && !drop_winner {
9311 continue;
9312 }
9313 if c.worktree.exists() {
9314 git::worktree_remove(&repo, &c.worktree).await.ok();
9315 removed.push(c.worktree.to_string_lossy().into_owned());
9316 }
9317 let handed_over = state.released_branches.contains(&c.branch);
9320 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
9321 git::branch_delete(&repo, &c.branch).await.ok();
9322 removed.push(c.branch.clone());
9323 }
9324 state.candidates[i].folded = true;
9325 }
9326
9327 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
9328 let path = name.path();
9329 let keep = !drop_winner
9330 && winner.is_some_and(|w| {
9331 path.file_name()
9332 .is_some_and(|n| n == format!("cand-{w}").as_str())
9333 });
9334 if keep {
9335 continue;
9336 }
9337 git::worktree_remove(&repo, &path).await.ok();
9338 removed.push(path.to_string_lossy().into_owned());
9339 }
9340
9341 remove_if_empty(&root);
9350
9351 if state.enabled_worktree_config && drop_winner {
9352 git::release_worktree_config(&repo).await.ok();
9356 state.enabled_worktree_config = false;
9357 }
9358 state.save_under(home)?;
9359 Ok(removed)
9360}
9361
9362fn remove_if_empty(dir: &Path) {
9373 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
9374 std::fs::remove_dir(dir).ok();
9375 }
9376}
9377
9378pub fn worst_open(state: &RunState) -> Option<Severity> {
9380 state
9381 .reviews
9382 .last()?
9383 .reviews
9384 .iter()
9385 .flat_map(|r| r.findings.iter())
9386 .map(|f| f.severity)
9387 .max()
9388}
9389
9390async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9394 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9395 git::acquire_worktree_config(repo).await?;
9396 state.enabled_worktree_config = true;
9397 }
9398 Ok(())
9399}
9400
9401#[cfg(test)]
9402mod tests {
9403 #[test]
9404 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9405 let leaked = vec!["chore(deps): update a crate".to_owned()];
9406 let own = "fix(daemon): apply a chosen action";
9407 assert!(should_retitle("", own, &leaked));
9408 assert!(should_retitle(
9409 &format!("{REVIEW_PROMPT_OPENING} `x`"),
9410 own,
9411 &leaked
9412 ));
9413 assert!(should_retitle(
9414 "chore: land candidate A of run 1",
9415 own,
9416 &leaked
9417 ));
9418 assert!(should_retitle(
9419 "magi: candidate A (uncommitted work)",
9420 own,
9421 &leaked
9422 ));
9423 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9424 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9425 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9426 }
9427
9428 #[test]
9429 fn pr_merge_plan_creates_adopts_or_stops() {
9430 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9431 assert_eq!(
9432 pr_merge_plan(Ok(land::OpenPr::One {
9433 url: "u".into(),
9434 title: "t".into()
9435 })),
9436 PrPlan::Adopt {
9437 url: "u".into(),
9438 title: "t".into()
9439 }
9440 );
9441 let PrPlan::Stop(many) =
9442 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9443 else {
9444 panic!("many must stop");
9445 };
9446 assert!(many.contains('a') && many.contains('b'));
9447 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9448 panic!("a failed lookup must stop");
9449 };
9450 assert!(err.contains("bad token"));
9451 }
9452
9453 use super::*;
9454 use crate::run::GateStatus;
9455 use std::collections::BTreeMap;
9456 use std::time::Duration;
9457
9458 fn conductor() -> AgentSpec {
9459 AgentSpec {
9460 id: "conductor".to_owned(),
9461 kind: crate::config::AgentKind::Command,
9462 model: None,
9463 command: vec!["true".to_owned()],
9464 extra_args: Vec::new(),
9465 env: BTreeMap::new(),
9466 prompt_delivery: None,
9467 }
9468 }
9469
9470 fn spec(id: &str) -> AgentSpec {
9471 AgentSpec {
9472 id: id.to_owned(),
9473 kind: crate::config::AgentKind::Command,
9474 model: None,
9475 command: vec!["true".to_owned()],
9476 extra_args: Vec::new(),
9477 env: BTreeMap::new(),
9478 prompt_delivery: None,
9479 }
9480 }
9481
9482 fn ids(xs: &[&str]) -> BTreeSet<String> {
9483 xs.iter().map(|s| (*s).to_owned()).collect()
9484 }
9485
9486 #[test]
9487 fn pick_successor_skips_an_agent_another_seat_holds() {
9488 let roster = [spec("a"), spec("b"), spec("c")];
9490 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9491 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9492 }
9493
9494 #[test]
9495 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9496 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9498 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9499 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9500 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9501 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9502 }
9503
9504 #[test]
9505 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9506 let roster = [spec("a"), spec("b")];
9507 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9508 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9509 let carried = ids(&["b"]);
9510 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9511 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9512 }
9513
9514 #[test]
9515 fn pick_successor_returns_none_without_any_untried_successor() {
9516 let roster = [spec("a"), spec("b")];
9517 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9518 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9519 let carried = ids(&["a"]);
9520 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9521 }
9522
9523 #[test]
9524 fn pick_successor_rescue_avoids_another_seats_occupant() {
9525 let roster = [spec("a"), spec("b"), spec("c")];
9527 let carried = ids(&["b", "c"]);
9528 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9529 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9530 }
9531
9532 #[test]
9533 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9534 let roster = [spec("a"), spec("b"), spec("c")];
9535 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9536 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9537 }
9538
9539 #[test]
9540 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9541 let roster = [spec("a"), spec("b"), spec("c")];
9542 let failed = ids(&["a", "b", "c"]);
9543 let mut tried = ids(&["b"]);
9545 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9546 assert_eq!(first.id, "a");
9547 tried.insert(first.id.clone());
9548 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9549 assert_eq!(second.id, "c");
9550 tried.insert(second.id.clone());
9551 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9552 }
9553
9554 #[test]
9555 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9556 let roster = [spec("a"), spec("b")];
9557 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9558 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9559 }
9560
9561 #[test]
9562 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9563 let roster = [spec("a"), spec("b"), spec("c")];
9564 let h = SeatHistory {
9565 failed: ids(&["a"]),
9566 last_ok: Some("b".to_owned()),
9567 last_fail: None,
9568 };
9569 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9570 let gone = SeatHistory {
9572 last_ok: Some("zzz".to_owned()),
9573 ..h.clone()
9574 };
9575 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9576 let failed = SeatHistory {
9577 failed: ids(&["a", "b"]),
9578 last_ok: Some("b".to_owned()),
9579 last_fail: None,
9580 };
9581 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9582 }
9583
9584 #[test]
9585 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9586 let first = SeatState::new("review-1", "alpha", 7);
9587 let next = handover_seat("review-1", "gamma", 7);
9588 assert_ne!(first.claude_session, next.claude_session);
9589 }
9590
9591 #[test]
9592 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9593 let mut state = state_with_summary("x", "y");
9594 let a = handover_seat("review-1", "beta", state.next_seat_seed());
9595 let b = handover_seat("review-1", "beta", state.next_seat_seed());
9596 assert_ne!(a.claude_session, b.claude_session);
9597 }
9598
9599 #[test]
9600 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9601 let roster = [spec("a"), spec("b")];
9602 let h = SeatHistory {
9603 failed: ids(&["a", "b"]),
9604 ..SeatHistory::default()
9605 };
9606 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9607 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9608 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9609 }
9610
9611 #[test]
9618 fn failure_signature_ignores_numbers_and_paths() {
9619 assert_eq!(
9620 failure_signature("exited with Some(2) and no usable output"),
9621 failure_signature("exited with Some(137) and no usable output")
9622 );
9623 assert_eq!(
9624 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9625 failure_signature("cannot open /var/x.txt: denied")
9626 );
9627 assert_ne!(failure_signature("boom"), failure_signature("bang"));
9628 }
9629
9630 #[test]
9631 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9632 let other = FailClass::Other("x".into());
9633 assert!(should_hand_over(None, &FailClass::Quota));
9634 assert!(should_hand_over(Some(&other), &FailClass::Quota));
9635 assert!(should_hand_over(
9636 Some(&FailClass::Timeout),
9637 &FailClass::Timeout
9638 ));
9639 assert!(should_hand_over(None, &other));
9640 assert!(!should_hand_over(Some(&other), &other));
9641 assert!(should_hand_over(
9642 Some(&other),
9643 &FailClass::Other("y".into())
9644 ));
9645 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9647 assert!(should_hand_over(Some(&FailClass::Quota), &other));
9648 }
9649
9650 #[test]
9651 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9652 let a = SeatState::new("judge-1", "alpha", 7);
9653 let b = handover_seat("judge-1", "beta", 7);
9654 assert_ne!(a.claude_session, b.claude_session);
9655 assert_eq!(b.turns, 0);
9656 }
9657
9658 #[test]
9659 fn a_timeout_is_classified_apart_from_other_failures() {
9660 assert_eq!(
9661 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9662 Some(FailClass::Timeout)
9663 );
9664 assert!(matches!(
9665 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9666 Some(FailClass::Other(_))
9667 ));
9668 }
9669
9670 #[test]
9671 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9672 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9673 let tried = BTreeSet::from(["beta".to_owned()]);
9674 let next = next_untried_in_roster(&roster, 1, &tried);
9677 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9678 }
9679
9680 #[test]
9681 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9682 let roster = vec![spec("alpha"), spec("beta")];
9683 let tried = BTreeSet::from(["beta".to_owned()]);
9684 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9688 }
9689
9690 #[test]
9691 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9692 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9693 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9694 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9698 }
9699
9700 #[test]
9701 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9702 let roster = vec![spec("a"), spec("a"), spec("b")];
9703 let tried = BTreeSet::from(["a".to_owned()]);
9704 let next = next_untried_in_roster(&roster, 0, &tried);
9705 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9706 }
9707
9708 #[test]
9709 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9710 let roster = vec![spec("a"), spec("b")];
9711 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9712 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9713 }
9714
9715 #[test]
9716 fn remove_if_empty_only_ever_takes_a_bare_directory() {
9717 let dir = tempfile::tempdir().unwrap();
9718 let bay = dir.path().join("ffff");
9719
9720 remove_if_empty(&bay);
9722 assert!(!bay.exists());
9723
9724 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9727 remove_if_empty(&bay);
9728 assert!(bay.exists(), "non-empty directory must survive");
9729
9730 std::fs::remove_dir(bay.join("cand-A")).unwrap();
9732 remove_if_empty(&bay);
9733 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9734 }
9735
9736 #[test]
9745 fn a_full_panel_that_found_nothing_is_clean() {
9746 assert!(round_is_clean(
9747 0,
9748 true,
9749 2,
9750 2,
9751 0,
9752 IncompleteReviewPolicy::Block
9753 ));
9754 }
9755
9756 #[test]
9757 fn a_missing_seat_is_never_clean_under_the_default_policy() {
9758 assert!(!round_is_clean(
9759 0,
9760 true,
9761 1,
9762 2,
9763 0,
9764 IncompleteReviewPolicy::Block
9765 ));
9766 }
9767
9768 #[test]
9769 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9770 assert!(!round_is_clean(
9771 1,
9772 true,
9773 1,
9774 2,
9775 0,
9776 IncompleteReviewPolicy::Warn
9777 ));
9778 }
9779
9780 #[test]
9781 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9782 assert!(round_is_clean(
9783 0,
9784 true,
9785 1,
9786 2,
9787 0,
9788 IncompleteReviewPolicy::Warn
9789 ));
9790 }
9791
9792 #[test]
9793 fn a_full_panel_with_an_open_finding_is_not_clean() {
9794 assert!(!round_is_clean(
9795 1,
9796 true,
9797 2,
9798 2,
9799 0,
9800 IncompleteReviewPolicy::Block
9801 ));
9802 }
9803
9804 #[test]
9805 fn a_full_panel_with_a_red_e2e_is_not_clean() {
9806 assert!(!round_is_clean(
9807 0,
9808 false,
9809 2,
9810 2,
9811 0,
9812 IncompleteReviewPolicy::Block
9813 ));
9814 }
9815
9816 #[test]
9823 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9824 assert!(round_is_clean(
9827 0,
9828 true,
9829 1,
9830 2,
9831 1,
9832 IncompleteReviewPolicy::Block
9833 ));
9834 }
9835
9836 #[test]
9837 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9838 assert!(!round_is_clean(
9841 0,
9842 true,
9843 1,
9844 2,
9845 0,
9846 IncompleteReviewPolicy::Block
9847 ));
9848 }
9849
9850 #[test]
9851 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9852 assert!(!round_is_clean(
9853 1,
9854 true,
9855 1,
9856 2,
9857 1,
9858 IncompleteReviewPolicy::Block
9859 ));
9860 assert!(!round_is_clean(
9861 0,
9862 false,
9863 1,
9864 2,
9865 1,
9866 IncompleteReviewPolicy::Block
9867 ));
9868 }
9869
9870 #[test]
9871 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9872 assert!(!round_is_clean(
9876 0,
9877 true,
9878 0,
9879 2,
9880 2,
9881 IncompleteReviewPolicy::Block
9882 ));
9883 }
9884
9885 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9886 CommandOutcome {
9887 command: "test".to_owned(),
9888 code,
9889 output_tail: String::new(),
9890 duration_ms: 0,
9891 resource_blocked,
9892 }
9893 }
9894
9895 #[test]
9896 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9897 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9898 assert!(
9899 !verify_inconclusive(&[outcome(Some(1), false)]),
9900 "an ordinary failure is still evidence about the patch"
9901 );
9902 assert!(verify_inconclusive(&[outcome(None, true)]));
9903 assert!(
9904 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9905 "one inconclusive outcome taints the whole batch"
9906 );
9907 assert!(!verify_inconclusive(&[]));
9908 }
9909
9910 #[tokio::test]
9911 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9912 let calls = std::sync::atomic::AtomicUsize::new(0);
9916 let started = Instant::now();
9917 wait_for_pids_with(
9918 &[123],
9919 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9920 Duration::from_millis(5),
9921 Duration::from_secs(5),
9922 )
9923 .await;
9924 assert!(
9925 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9926 "must keep checking rather than deciding on the first answer"
9927 );
9928 assert!(
9929 started.elapsed() < Duration::from_secs(1),
9930 "must return the moment it is confirmed dead, not wait out the ceiling"
9931 );
9932 }
9933
9934 #[tokio::test]
9935 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9936 let started = Instant::now();
9937 wait_for_pids_with(
9938 &[123],
9939 |_| true, Duration::from_millis(5),
9941 Duration::from_millis(30),
9942 )
9943 .await;
9944 let elapsed = started.elapsed();
9945 assert!(
9946 elapsed >= Duration::from_millis(30),
9947 "must not give up before its own ceiling: {elapsed:?}"
9948 );
9949 assert!(
9950 elapsed < Duration::from_secs(1),
9951 "must not wait past its own ceiling either: {elapsed:?}"
9952 );
9953 }
9954
9955 #[tokio::test]
9956 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9957 let started = Instant::now();
9958 wait_for_pids_with(
9959 &[],
9960 |_| true,
9961 Duration::from_secs(5),
9962 Duration::from_secs(5),
9963 )
9964 .await;
9965 assert!(
9966 started.elapsed() < Duration::from_millis(200),
9967 "an empty pid list has nothing to confirm"
9968 );
9969 }
9970
9971 fn review_round(
9977 clean: bool,
9978 blocking: usize,
9979 answered: usize,
9980 expected: usize,
9981 progressed: bool,
9982 e2e_ok: bool,
9983 ) -> ReviewRound {
9984 ReviewRound {
9985 round: 1,
9986 head: "h".to_owned(),
9987 verified_head: None,
9988 verified_at: None,
9989 reviews: Vec::new(),
9990 e2e: vec![CommandOutcome {
9991 command: "test".to_owned(),
9992 code: Some(if e2e_ok { 0 } else { 1 }),
9993 output_tail: String::new(),
9994 duration_ms: 0,
9995 resource_blocked: false,
9996 }],
9997 verify_retried: false,
9998 e2e_deferred: false,
9999 e2e_defer_reason: None,
10000 fix: None,
10001 blocking,
10002 answered,
10003 expected,
10004 clean,
10005 progressed,
10006 vote_split: false,
10007 reconsideration: Vec::new(),
10008 verdict: None,
10009 }
10010 }
10011
10012 #[test]
10013 fn review_conclusion_is_none_when_nothing_has_run() {
10014 assert_eq!(review_conclusion(&[], 3), None);
10015 }
10016
10017 #[test]
10018 fn review_conclusion_is_none_while_rounds_remain() {
10019 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
10020 assert_eq!(review_conclusion(&rounds, 3), None);
10021 }
10022
10023 #[test]
10024 fn review_conclusion_is_gating_once_a_round_is_clean() {
10025 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
10026 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
10027 }
10028
10029 #[test]
10030 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
10031 let rounds = vec![
10032 review_round(false, 1, 2, 2, true, true),
10033 review_round(false, 1, 2, 2, true, true),
10034 ];
10035 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
10036 }
10037
10038 #[test]
10039 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
10040 let rounds = vec![
10041 review_round(false, 1, 2, 2, true, true),
10042 review_round(false, 1, 2, 2, true, false),
10043 ];
10044 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
10045 }
10046
10047 #[test]
10048 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
10049 let mut blocked = review_round(false, 1, 2, 2, true, false);
10056 blocked.e2e[0].resource_blocked = true;
10057 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
10058 assert_eq!(review_conclusion(&rounds, 2), None);
10059 }
10060
10061 #[test]
10062 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
10063 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
10065 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
10066 }
10067
10068 #[test]
10069 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
10070 let rounds = vec![
10071 review_round(false, 1, 2, 2, false, true),
10072 review_round(false, 1, 2, 2, false, true),
10073 ];
10074 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
10075 }
10076
10077 fn secs(n: u64) -> Duration {
10078 Duration::from_secs(n)
10079 }
10080
10081 fn init_repo(dir: &Path) {
10084 let run = |args: &[&str]| {
10085 let out = std::process::Command::new("git")
10086 .args(args)
10087 .current_dir(dir)
10088 .quiet()
10089 .output()
10090 .expect("spawn git");
10091 assert!(
10092 out.status.success(),
10093 "git {args:?} failed: {}",
10094 String::from_utf8_lossy(&out.stderr)
10095 );
10096 };
10097 run(&["init", "-b", "main"]);
10098 run(&["config", "user.name", "magi test"]);
10099 run(&["config", "user.email", "magi@example.com"]);
10100 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
10101 run(&["add", "-A"]);
10102 run(&["commit", "-m", "init"]);
10103 }
10104
10105 fn ask_test_home() {
10113 crate::run::pin_test_home();
10114 }
10115
10116 fn runner_at(status: RunStatus) -> Runner {
10119 let mut state = RunState::new(
10120 PathBuf::from("/nonexistent/repo"),
10121 "main".to_owned(),
10122 "deadbeef".to_owned(),
10123 "task".to_owned(),
10124 Config::default(),
10125 );
10126 state.status = status;
10127 Runner {
10128 state,
10129 roles: ResolvedRoles {
10130 implementers: Vec::new(),
10131 judges: Vec::new(),
10132 reviewers: Vec::new(),
10133 fixer: None,
10134 conductor: conductor(),
10135 implementer_roster: Vec::new(),
10136 judge_roster: Vec::new(),
10137 reviewer_roster: Vec::new(),
10138 },
10139 sem: Arc::new(Semaphore::new(1)),
10140 pause: Pause::new(),
10141 interrupt: Pause::new(),
10142 }
10143 }
10144
10145 #[test]
10149 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
10150 crate::run::pin_test_home();
10151 let mut runner = runner_at(RunStatus::Implementing);
10152 let interrupt = Pause::new();
10153 runner.watch_interrupt(interrupt.clone());
10154
10155 interrupt.park_because("task a1b2 asked to run first");
10156
10157 assert!(runner.park_here().expect("park_here"));
10158 assert!(runner.state.parked);
10159 let last = runner.state.events.last().expect("a park event");
10160 assert_eq!(last.node, "park");
10161 assert!(
10162 last.message.contains("task a1b2 asked to run first"),
10163 "expected the interrupt reason in {:?}",
10164 last.message
10165 );
10166 }
10167
10168 #[test]
10176 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
10177 crate::run::pin_test_home();
10178 let mut runner = runner_at(RunStatus::Implementing);
10179 let shutdown = Pause::new();
10180 runner.on_pause(shutdown.clone());
10181 let interrupt = Pause::new();
10182 runner.watch_interrupt(interrupt.clone());
10183
10184 assert!(!runner.park_here().expect("park_here"));
10186 assert!(!runner.state.parked);
10187
10188 interrupt.park_because("test");
10190 assert!(!shutdown.parked());
10191 assert!(runner.park_here().expect("park_here"));
10192 }
10193
10194 #[tokio::test]
10208 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
10209 crate::run::pin_test_home();
10210 let mut runner = runner_at(RunStatus::Implementing);
10211 let interrupt = Pause::new();
10212 runner.watch_interrupt(interrupt.clone());
10213
10214 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
10215 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
10216
10217 let node = async move {
10221 started_tx.send(()).expect("send started");
10222 finish_rx.await.expect("recv finish");
10223 "node finished"
10224 };
10225
10226 let interrupter = async move {
10227 started_rx.await.expect("recv started");
10228 interrupt.park_because("higher-priority task waiting");
10230 tokio::task::yield_now().await;
10234 finish_tx.send(()).expect("send finish");
10235 };
10236
10237 let (node_result, ()) = tokio::join!(node, interrupter);
10238 assert_eq!(
10239 node_result, "node finished",
10240 "the in-flight call ran to completion"
10241 );
10242
10243 assert!(runner.park_here().expect("park_here"));
10246 assert!(runner.state.parked);
10247 }
10248
10249 #[test]
10255 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
10256 crate::run::pin_test_home();
10257 let mut runner = runner_at(RunStatus::Judging);
10258 runner.state.config.agents = vec![conductor()];
10262 runner.state.candidates = vec![Candidate {
10263 index: 0,
10264 label: 'A',
10265 agent: "alpha".to_owned(),
10266 branch: "magi/x/A".to_owned(),
10267 worktree: PathBuf::from("/nonexistent/worktree"),
10268 summary: "did the thing".to_owned(),
10269 stat: "1 file changed".to_owned(),
10270 files: 1,
10271 commits: 1,
10272 empty: false,
10273 failed: None,
10274 verified_noop: None,
10275 duration_ms: 1234,
10276 folded: false,
10277 }];
10278 let run_id = runner.state.id.clone();
10279
10280 let interrupt = Pause::new();
10281 runner.watch_interrupt(interrupt.clone());
10282 interrupt.park_because("task c3d4 asked to run first");
10283 assert!(runner.park_here().expect("park_here"));
10284
10285 let resumed = Runner::resume(&run_id).expect("resume");
10286 assert_eq!(resumed.state.candidates.len(), 1);
10287 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
10288 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
10289 assert_eq!(resumed.state.status, runner.state.status);
10290 assert!(
10291 resumed.state.parked,
10292 "still parked until `execute` actually walks the graph again"
10293 );
10294 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
10295 }
10296
10297 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
10299 let mut q = ask::Question::new(
10300 run.to_owned(),
10301 "implement".to_owned(),
10302 "impl-A".to_owned(),
10303 "Which storage backend should the cache use?".to_owned(),
10304 String::new(),
10305 vec!["SQLite".to_owned(), "Redis".to_owned()],
10306 );
10307 store.put(&mut q).unwrap();
10308 q
10309 }
10310
10311 #[test]
10312 fn a_failed_runs_open_question_is_abandoned() {
10313 ask_test_home();
10314 let store = ask::Questions::open();
10315 let mut runner = runner_at(RunStatus::Failed);
10316 let run = runner.state.id.clone();
10317 let q = ask_open_question(&store, &run);
10318
10319 runner.settle_questions();
10320
10321 let back = store.get(&q.id).unwrap();
10322 assert!(
10323 !back.status.open(),
10324 "the seat that asked died with the run; nobody is left to read an answer"
10325 );
10326 assert!(
10327 back.detail.contains(&run) && back.detail.contains("failed"),
10328 "the reason names what the run became, not just that it is gone: {}",
10329 back.detail
10330 );
10331 }
10332
10333 #[test]
10334 fn a_merged_runs_open_question_is_abandoned_too() {
10335 ask_test_home();
10336 let store = ask::Questions::open();
10337 for status in [RunStatus::Merged, RunStatus::Ready] {
10340 let mut runner = runner_at(status);
10341 let run = runner.state.id.clone();
10342 let q = ask_open_question(&store, &run);
10343
10344 runner.settle_questions();
10345
10346 let back = store.get(&q.id).unwrap();
10347 assert!(
10348 !back.status.open(),
10349 "{status:?} run's question must not outlive the run"
10350 );
10351 }
10352 }
10353
10354 #[test]
10355 fn a_still_resumable_runs_open_question_is_left_alone() {
10356 ask_test_home();
10357 let store = ask::Questions::open();
10358 for status in [RunStatus::Blocked, RunStatus::Stalled] {
10364 let mut runner = runner_at(status);
10365 let run = runner.state.id.clone();
10366 let q = ask_open_question(&store, &run);
10367
10368 runner.settle_questions();
10369
10370 let back = store.get(&q.id).unwrap();
10371 assert!(
10372 back.status.open(),
10373 "{status:?} is still alive; the question must still be waiting"
10374 );
10375 }
10376 }
10377
10378 #[test]
10379 fn settle_questions_never_touches_an_already_answered_question() {
10380 ask_test_home();
10381 let store = ask::Questions::open();
10382 let mut runner = runner_at(RunStatus::Failed);
10383 let run = runner.state.id.clone();
10384 let mut q = ask_open_question(&store, &run);
10385 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
10386 .unwrap();
10387 store.put(&mut q).unwrap();
10388
10389 runner.settle_questions();
10394 runner.settle_questions();
10395
10396 let back = store.get(&q.id).unwrap();
10397 assert_eq!(
10398 back.status,
10399 ask::QuestionStatus::Answered,
10400 "a real answer is a decision on record, never overwritten by a sweep"
10401 );
10402 }
10403
10404 #[tokio::test]
10415 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10416 crate::run::pin_test_home();
10417 let tmp = tempfile::tempdir().expect("tempdir");
10418 let repo = tmp.path().join("repo");
10419 std::fs::create_dir_all(&repo).unwrap();
10420 init_repo(&repo);
10421
10422 let mut config = Config::default();
10423 config.graph.worktree_root = Some(tmp.path().join("wt"));
10424
10425 let mut state = RunState::new(
10426 repo.clone(),
10427 "main".to_owned(),
10428 "deadbeef".to_owned(),
10429 "task".to_owned(),
10430 config,
10431 );
10432 let root = state.worktree_root();
10433 let wt_a = root.join("cand-A");
10434 let wt_b = root.join("cand-B");
10435 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10436 .await
10437 .expect("worktree A");
10438 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10439 .await
10440 .expect("worktree B");
10441
10442 state.candidates = vec![
10443 Candidate {
10444 index: 0,
10445 label: 'A',
10446 agent: "alpha".to_owned(),
10447 branch: "magi/x/A".to_owned(),
10448 worktree: wt_a.clone(),
10449 summary: String::new(),
10450 stat: String::new(),
10451 files: 0,
10452 commits: 0,
10453 empty: false,
10454 failed: None,
10455 verified_noop: None,
10456 duration_ms: 0,
10457 folded: false,
10458 },
10459 Candidate {
10460 index: 1,
10461 label: 'B',
10462 agent: "beta".to_owned(),
10463 branch: "magi/x/B".to_owned(),
10464 worktree: wt_b.clone(),
10465 summary: String::new(),
10466 stat: String::new(),
10467 files: 0,
10468 commits: 0,
10469 empty: false,
10470 failed: None,
10471 verified_noop: None,
10472 duration_ms: 0,
10473 folded: false,
10474 },
10475 ];
10476 state.tally = Some(Tally {
10477 first_choice: BTreeMap::from([('A', 1)]),
10478 borda: BTreeMap::new(),
10479 winner: 'A',
10480 rankings: 1,
10481 unanimous_initial: true,
10482 deliberated: false,
10483 changed_votes: 0,
10484 unanimous_final: true,
10485 tie_break: None,
10486 judges: 1,
10487 present: 1,
10488 quorum: 1,
10489 met_quorum: true,
10490 uncontested: None,
10491 });
10492 state.status = RunStatus::Ready;
10493
10494 fold_run(&mut state, false, &crate::run::home())
10495 .await
10496 .expect("fold_run");
10497
10498 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10499 assert!(
10500 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10501 "the unmerged winner's branch survives"
10502 );
10503 assert!(
10504 !state.candidates[0].folded,
10505 "the winner is not marked folded"
10506 );
10507
10508 assert!(!wt_b.exists(), "the loser's worktree is removed");
10509 assert!(
10510 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10511 "the loser's branch is removed"
10512 );
10513 assert!(state.candidates[1].folded, "the loser is marked folded");
10514 }
10515
10516 #[tokio::test]
10519 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10520 let tmp = tempfile::tempdir().expect("tempdir");
10521 let repo = tmp.path().join("repo");
10522 std::fs::create_dir_all(&repo).unwrap();
10523 init_repo(&repo);
10524 let home = tmp.path().join("home");
10525
10526 let mut config = Config::default();
10527 config.graph.worktree_root = Some(tmp.path().join("wt"));
10528 let mut state = RunState::new(
10529 repo.clone(),
10530 "main".to_owned(),
10531 "deadbeef".to_owned(),
10532 "task".to_owned(),
10533 config,
10534 );
10535 git::git(&repo, &["branch", "magi/x/A", "main"])
10537 .await
10538 .expect("branch");
10539 state.candidates = vec![Candidate {
10540 index: 0,
10541 label: 'A',
10542 agent: "alpha".to_owned(),
10543 branch: "magi/x/A".to_owned(),
10544 worktree: state.worktree_root().join("cand-A"),
10545 summary: String::new(),
10546 stat: String::new(),
10547 files: 0,
10548 commits: 0,
10549 empty: false,
10550 failed: None,
10551 verified_noop: None,
10552 duration_ms: 0,
10553 folded: true,
10554 }];
10555 state.released_to = Some("20260901-000000-new1".to_owned());
10556 state.released_branches = vec!["magi/x/A".to_owned()];
10557
10558 fold_run(&mut state, true, &home).await.expect("fold_run");
10559
10560 assert!(
10561 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10562 "the handed-over branch survives a fold"
10563 );
10564 }
10565
10566 #[tokio::test]
10570 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10571 let tmp = tempfile::tempdir().expect("tempdir");
10572 let repo = tmp.path().join("repo");
10573 std::fs::create_dir_all(&repo).unwrap();
10574 init_repo(&repo);
10575 let run = |args: &[&str]| {
10576 let out = std::process::Command::new("git")
10577 .quiet()
10578 .args(args)
10579 .current_dir(&repo)
10580 .output()
10581 .expect("spawn git");
10582 assert!(out.status.success(), "git {args:?}");
10583 };
10584 run(&["branch", "magi/x/A"]);
10585 run(&["checkout", "-q", "-b", "magi/x/B"]);
10586 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10587 run(&["add", "-A"]);
10588 run(&["commit", "-q", "-m", "work"]);
10589 run(&["checkout", "-q", "main"]);
10590
10591 let probe = RunState::new(
10595 repo.clone(),
10596 "main".to_owned(),
10597 "deadbeef".to_owned(),
10598 "task".to_owned(),
10599 Config::default(),
10600 );
10601 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10602 let bare = tmp.path().join("origin.git");
10603 let out = std::process::Command::new("git")
10604 .quiet()
10605 .args(["init", "-q", "--bare"])
10606 .arg(&bare)
10607 .output()
10608 .expect("spawn git");
10609 assert!(out.status.success(), "git init --bare");
10610 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10611 run(&["push", "-q", "origin", "main"]);
10612
10613 let mut state = RunState::new(
10614 repo.clone(),
10615 "main".to_owned(),
10616 "deadbeef".to_owned(),
10617 "task".to_owned(),
10618 Config::default(),
10619 );
10620 state.seeds = vec![refs::Seed {
10621 token: "magi/27b2/A".to_owned(),
10622 kind: refs::SeedKind::Unresolved,
10623 sha: String::new(),
10624 branch: true,
10625 detail: "no branch or commit named magi/27b2/A".to_owned(),
10626 }];
10627
10628 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10629 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10630 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10631 let detail = empty_candidate_detail(&state, "main");
10632 assert!(detail.starts_with("empty candidate"), "{detail}");
10633 assert!(detail.contains("magi/27b2/A"), "{detail}");
10634 }
10635
10636 #[tokio::test]
10645 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10646 let tmp = tempfile::tempdir().expect("tempdir");
10647 let repo = tmp.path().join("repo");
10648 std::fs::create_dir_all(&repo).unwrap();
10649 init_repo(&repo);
10650
10651 let mut config = Config::default();
10652 config.merge.mode = MergeMode::Local;
10653
10654 let mut state = RunState::new(
10655 repo.clone(),
10656 "main".to_owned(),
10657 "deadbeef".to_owned(),
10658 "task".to_owned(),
10659 config,
10660 );
10661 state.candidates = vec![Candidate {
10662 index: 0,
10663 label: 'A',
10664 agent: "alpha".to_owned(),
10665 branch: "does-not-exist".to_owned(),
10666 worktree: repo.clone(),
10667 summary: String::new(),
10668 stat: String::new(),
10669 files: 0,
10670 commits: 0,
10671 empty: false,
10672 failed: None,
10673 verified_noop: None,
10674 duration_ms: 0,
10675 folded: false,
10676 }];
10677 state.tally = Some(Tally {
10678 first_choice: BTreeMap::from([('A', 1)]),
10679 borda: BTreeMap::new(),
10680 winner: 'A',
10681 rankings: 1,
10682 unanimous_initial: true,
10683 deliberated: false,
10684 changed_votes: 0,
10685 unanimous_final: true,
10686 tie_break: None,
10687 judges: 0,
10688 present: 0,
10689 quorum: 0,
10690 met_quorum: true,
10691 uncontested: Some("only candidate A produced a change".to_owned()),
10692 });
10693 state.reviews = vec![ReviewRound {
10694 round: 1,
10695 head: "deadbeef".to_owned(),
10696 verified_head: None,
10697 verified_at: None,
10698 reviews: Vec::new(),
10699 e2e: Vec::new(),
10700 fix: None,
10701 blocking: 0,
10702 answered: 0,
10703 expected: 0,
10704 clean: true,
10705 verify_retried: false,
10706 e2e_deferred: false,
10707 e2e_defer_reason: None,
10708 progressed: false,
10709 vote_split: false,
10710 reconsideration: Vec::new(),
10711 verdict: None,
10712 }];
10713 state.gate = vec![CommandOutcome {
10714 command: "test".to_owned(),
10715 code: Some(0),
10716 output_tail: String::new(),
10717 duration_ms: 0,
10718 resource_blocked: false,
10719 }];
10720 state.gate_ran = true;
10721 state.status = RunStatus::Ready;
10726 state.merge = Some(MergeOutcome {
10727 mode: MergeMode::Local,
10728 ok: false,
10729 detail: "already concluded".to_owned(),
10730 empty: false,
10731 });
10732
10733 let mut runner = Runner {
10734 state,
10735 roles: ResolvedRoles {
10736 implementers: Vec::new(),
10737 judges: Vec::new(),
10738 reviewers: Vec::new(),
10739 fixer: None,
10740 conductor: conductor(),
10741 implementer_roster: Vec::new(),
10742 judge_roster: Vec::new(),
10743 reviewer_roster: Vec::new(),
10744 },
10745 sem: Arc::new(Semaphore::new(1)),
10746 pause: Pause::new(),
10747 interrupt: Pause::new(),
10748 };
10749
10750 runner.merge().await.expect("merge");
10751
10752 assert_eq!(
10753 runner.state.status,
10754 RunStatus::Ready,
10755 "a concluded run's status must not change on reentry"
10756 );
10757 assert_eq!(
10758 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10759 Some("already concluded"),
10760 "merge must not run again once the node already recorded an outcome"
10761 );
10762 }
10763
10764 #[tokio::test]
10773 async fn merge_refuses_a_gate_that_has_not_actually_run() {
10774 let tmp = tempfile::tempdir().expect("tempdir");
10775 let repo = tmp.path().join("repo");
10776 std::fs::create_dir_all(&repo).unwrap();
10777 init_repo(&repo);
10778
10779 let mut config = Config::default();
10780 config.merge.mode = MergeMode::Local;
10781
10782 let mut state = RunState::new(
10783 repo.clone(),
10784 "main".to_owned(),
10785 "deadbeef".to_owned(),
10786 "task".to_owned(),
10787 config,
10788 );
10789 state.candidates = vec![Candidate {
10790 index: 0,
10791 label: 'A',
10792 agent: "alpha".to_owned(),
10793 branch: "does-not-exist".to_owned(),
10794 worktree: repo.clone(),
10795 summary: String::new(),
10796 stat: String::new(),
10797 files: 0,
10798 commits: 0,
10799 empty: false,
10800 failed: None,
10801 verified_noop: None,
10802 duration_ms: 0,
10803 folded: false,
10804 }];
10805 state.tally = Some(Tally {
10806 first_choice: BTreeMap::from([('A', 1)]),
10807 borda: BTreeMap::new(),
10808 winner: 'A',
10809 rankings: 1,
10810 unanimous_initial: true,
10811 deliberated: false,
10812 changed_votes: 0,
10813 unanimous_final: true,
10814 tie_break: None,
10815 judges: 0,
10816 present: 0,
10817 quorum: 0,
10818 met_quorum: true,
10819 uncontested: Some("only candidate A produced a change".to_owned()),
10820 });
10821 state.reviews = vec![ReviewRound {
10822 round: 1,
10823 head: "deadbeef".to_owned(),
10824 verified_head: None,
10825 verified_at: None,
10826 reviews: Vec::new(),
10827 e2e: Vec::new(),
10828 fix: None,
10829 blocking: 0,
10830 answered: 0,
10831 expected: 0,
10832 clean: true,
10833 verify_retried: false,
10834 e2e_deferred: false,
10835 e2e_defer_reason: None,
10836 progressed: false,
10837 vote_split: false,
10838 reconsideration: Vec::new(),
10839 verdict: None,
10840 }];
10841 state.gate = Vec::new();
10843 state.gate_ran = false;
10844 state.status = RunStatus::Gating;
10845
10846 let mut runner = Runner {
10847 state,
10848 roles: ResolvedRoles {
10849 implementers: Vec::new(),
10850 judges: Vec::new(),
10851 reviewers: Vec::new(),
10852 fixer: None,
10853 conductor: conductor(),
10854 implementer_roster: Vec::new(),
10855 judge_roster: Vec::new(),
10856 reviewer_roster: Vec::new(),
10857 },
10858 sem: Arc::new(Semaphore::new(1)),
10859 pause: Pause::new(),
10860 interrupt: Pause::new(),
10861 };
10862
10863 runner.merge().await.expect("merge");
10864
10865 assert!(
10866 runner.state.merge.is_none(),
10867 "an empty gate must never be read as a passing one: {:?}",
10868 runner.state.merge
10869 );
10870 }
10871
10872 #[tokio::test]
10879 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10880 let tmp = tempfile::tempdir().expect("tempdir");
10881 let repo = tmp.path().join("repo");
10882 std::fs::create_dir_all(&repo).unwrap();
10883 init_repo(&repo);
10884
10885 let config = Config::default();
10887
10888 let mut state = RunState::new(
10889 repo.clone(),
10890 "main".to_owned(),
10891 "deadbeef".to_owned(),
10892 "task".to_owned(),
10893 config,
10894 );
10895 state.candidates = vec![Candidate {
10896 index: 0,
10897 label: 'A',
10898 agent: "alpha".to_owned(),
10899 branch: "does-not-exist".to_owned(),
10900 worktree: repo.clone(),
10901 summary: String::new(),
10902 stat: String::new(),
10903 files: 0,
10904 commits: 0,
10905 empty: false,
10906 failed: None,
10907 verified_noop: None,
10908 duration_ms: 0,
10909 folded: false,
10910 }];
10911 state.tally = Some(Tally {
10912 first_choice: BTreeMap::from([('A', 1)]),
10913 borda: BTreeMap::new(),
10914 winner: 'A',
10915 rankings: 1,
10916 unanimous_initial: true,
10917 deliberated: false,
10918 changed_votes: 0,
10919 unanimous_final: true,
10920 tie_break: None,
10921 judges: 0,
10922 present: 0,
10923 quorum: 0,
10924 met_quorum: true,
10925 uncontested: Some("only candidate A produced a change".to_owned()),
10926 });
10927 state.reviews = vec![ReviewRound {
10928 round: 1,
10929 head: "deadbeef".to_owned(),
10930 verified_head: None,
10931 verified_at: None,
10932 reviews: Vec::new(),
10933 e2e: Vec::new(),
10934 fix: None,
10935 blocking: 0,
10936 answered: 0,
10937 expected: 0,
10938 clean: true,
10939 verify_retried: false,
10940 e2e_deferred: false,
10941 e2e_defer_reason: None,
10942 progressed: false,
10943 vote_split: false,
10944 reconsideration: Vec::new(),
10945 verdict: None,
10946 }];
10947
10948 let mut runner = Runner {
10949 state,
10950 roles: ResolvedRoles {
10951 implementers: Vec::new(),
10952 judges: Vec::new(),
10953 reviewers: Vec::new(),
10954 fixer: None,
10955 conductor: conductor(),
10956 implementer_roster: Vec::new(),
10957 judge_roster: Vec::new(),
10958 reviewer_roster: Vec::new(),
10959 },
10960 sem: Arc::new(Semaphore::new(1)),
10961 pause: Pause::new(),
10962 interrupt: Pause::new(),
10963 };
10964
10965 runner.gate().await.expect("gate");
10966 assert!(
10967 runner.state.gate_ran,
10968 "zero configured commands is still a real attempt, not an unrun gate"
10969 );
10970 assert!(runner.state.gate.is_empty());
10971 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10972 assert_ne!(
10973 runner.state.status,
10974 RunStatus::Blocked,
10975 "a gate with nothing to check must not read as failed"
10976 );
10977
10978 runner.merge().await.expect("merge");
10979 assert_eq!(
10980 runner.state.status,
10981 RunStatus::Ready,
10982 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10983 );
10984 }
10985
10986 #[tokio::test]
10997 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10998 crate::run::pin_test_home();
10999 let home = crate::run::home();
11000
11001 let tmp = tempfile::tempdir().expect("tempdir");
11002 let repo = tmp.path().join("repo");
11003 std::fs::create_dir_all(&repo).unwrap();
11004 init_repo(&repo);
11005 let cache_dir = tmp.path().join("target");
11008
11009 let mut config = Config::default();
11010 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
11011 config.graph.timeout_verify = Some(2);
11014
11015 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11016 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11017 .expect("no io error acquiring directly")
11018 {
11019 crate::cache::AcquireOutcome::Acquired(g) => g,
11020 crate::cache::AcquireOutcome::Busy(b) => {
11021 panic!("expected the direct acquire to win the lease first: {b:?}")
11022 }
11023 };
11024
11025 let mut state = RunState::new(
11026 repo.clone(),
11027 "main".to_owned(),
11028 "deadbeef".to_owned(),
11029 "task".to_owned(),
11030 config,
11031 );
11032 state.candidates = vec![Candidate {
11033 index: 0,
11034 label: 'A',
11035 agent: "alpha".to_owned(),
11036 branch: "does-not-exist".to_owned(),
11037 worktree: repo.clone(),
11038 summary: String::new(),
11039 stat: String::new(),
11040 files: 0,
11041 commits: 0,
11042 empty: false,
11043 failed: None,
11044 verified_noop: None,
11045 duration_ms: 0,
11046 folded: false,
11047 }];
11048 state.tally = Some(Tally {
11049 first_choice: BTreeMap::from([('A', 1)]),
11050 borda: BTreeMap::new(),
11051 winner: 'A',
11052 rankings: 1,
11053 unanimous_initial: true,
11054 deliberated: false,
11055 changed_votes: 0,
11056 unanimous_final: true,
11057 tie_break: None,
11058 judges: 0,
11059 present: 0,
11060 quorum: 0,
11061 met_quorum: true,
11062 uncontested: Some("only candidate A produced a change".to_owned()),
11063 });
11064 state.reviews = vec![ReviewRound {
11065 round: 1,
11066 head: "deadbeef".to_owned(),
11067 verified_head: None,
11068 verified_at: None,
11069 reviews: Vec::new(),
11070 e2e: Vec::new(),
11071 fix: None,
11072 blocking: 0,
11073 answered: 0,
11074 expected: 0,
11075 clean: true,
11076 verify_retried: false,
11077 e2e_deferred: false,
11078 e2e_defer_reason: None,
11079 progressed: false,
11080 vote_split: false,
11081 reconsideration: Vec::new(),
11082 verdict: None,
11083 }];
11084
11085 let mut runner = Runner {
11086 state,
11087 roles: ResolvedRoles {
11088 implementers: Vec::new(),
11089 judges: Vec::new(),
11090 reviewers: Vec::new(),
11091 fixer: None,
11092 conductor: conductor(),
11093 implementer_roster: Vec::new(),
11094 judge_roster: Vec::new(),
11095 reviewer_roster: Vec::new(),
11096 },
11097 sem: Arc::new(Semaphore::new(1)),
11098 pause: Pause::new(),
11099 interrupt: Pause::new(),
11100 };
11101
11102 let started = std::time::Instant::now();
11103 runner.gate().await.expect("gate");
11104 assert!(
11105 started.elapsed() < Duration::from_secs(1),
11106 "a gate with nothing to run must never wait on a lease it never needed"
11107 );
11108 assert!(
11109 runner.state.gate_ran,
11110 "zero commands is still a real, immediate attempt"
11111 );
11112 assert!(runner.state.gate.is_empty());
11113 assert_ne!(
11114 runner.state.status,
11115 RunStatus::Blocked,
11116 "must not read as resource-blocked on a lease it never asked for"
11117 );
11118 }
11119
11120 #[tokio::test]
11130 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
11131 crate::run::pin_test_home();
11132
11133 let tmp = tempfile::tempdir().expect("tempdir");
11134 let repo = tmp.path().join("repo");
11135 std::fs::create_dir_all(&repo).unwrap();
11136 init_repo(&repo);
11137
11138 let mut config = Config::default();
11139 config.verify.gate = vec![
11140 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
11141 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
11142 .to_owned(),
11143 ];
11144
11145 let mut state = RunState::new(
11146 repo.clone(),
11147 "main".to_owned(),
11148 "deadbeef".to_owned(),
11149 "task".to_owned(),
11150 config,
11151 );
11152 let run_id = state.id.clone();
11153 state.candidates = vec![Candidate {
11154 index: 0,
11155 label: 'A',
11156 agent: "alpha".to_owned(),
11157 branch: "does-not-exist".to_owned(),
11158 worktree: repo.clone(),
11159 summary: String::new(),
11160 stat: String::new(),
11161 files: 0,
11162 commits: 0,
11163 empty: false,
11164 failed: None,
11165 verified_noop: None,
11166 duration_ms: 0,
11167 folded: false,
11168 }];
11169 state.tally = Some(Tally {
11170 first_choice: BTreeMap::from([('A', 1)]),
11171 borda: BTreeMap::new(),
11172 winner: 'A',
11173 rankings: 1,
11174 unanimous_initial: true,
11175 deliberated: false,
11176 changed_votes: 0,
11177 unanimous_final: true,
11178 tie_break: None,
11179 judges: 0,
11180 present: 0,
11181 quorum: 0,
11182 met_quorum: true,
11183 uncontested: Some("only candidate A produced a change".to_owned()),
11184 });
11185 state.reviews = vec![ReviewRound {
11186 round: 1,
11187 head: "deadbeef".to_owned(),
11188 verified_head: None,
11189 verified_at: None,
11190 reviews: Vec::new(),
11191 e2e: Vec::new(),
11192 fix: None,
11193 blocking: 0,
11194 answered: 0,
11195 expected: 0,
11196 clean: true,
11197 verify_retried: false,
11198 e2e_deferred: false,
11199 e2e_defer_reason: None,
11200 progressed: false,
11201 vote_split: false,
11202 reconsideration: Vec::new(),
11203 verdict: None,
11204 }];
11205
11206 let mut runner = Runner {
11207 state,
11208 roles: ResolvedRoles {
11209 implementers: Vec::new(),
11210 judges: Vec::new(),
11211 reviewers: Vec::new(),
11212 fixer: None,
11213 conductor: conductor(),
11214 implementer_roster: Vec::new(),
11215 judge_roster: Vec::new(),
11216 reviewer_roster: Vec::new(),
11217 },
11218 sem: Arc::new(Semaphore::new(1)),
11219 pause: Pause::new(),
11220 interrupt: Pause::new(),
11221 };
11222
11223 let started_marker = repo.join("started.marker");
11224 let release_marker = repo.join("release.marker");
11225 let poller = tokio::spawn(async move {
11226 for _ in 0..100 {
11231 if started_marker.exists()
11232 && let Ok(s) = crate::run::RunState::load(&run_id)
11233 && let Some(a) = s.active.get("gate")
11234 {
11235 std::fs::write(&release_marker, b"go").expect("release marker");
11236 return Some(a.clone());
11237 }
11238 tokio::time::sleep(Duration::from_millis(50)).await;
11239 }
11240 None
11241 });
11242
11243 runner.gate().await.expect("gate");
11244 let captured = poller.await.expect("poller task");
11245 let captured = captured.expect(
11246 "the poller never saw a `gate` task entry in run.json while the command was \
11247 still blocked on its own release marker",
11248 );
11249
11250 assert_eq!(captured.task.as_deref(), Some("gate"));
11251 assert_eq!(captured.node, "gate");
11252 assert_eq!(captured.index, Some(1));
11253 assert_eq!(captured.total, Some(1));
11254 assert!(
11255 captured
11256 .command
11257 .as_deref()
11258 .is_some_and(|c| c.contains("started.marker")),
11259 "{captured:?}"
11260 );
11261
11262 assert!(
11263 runner.state.active.is_empty(),
11264 "the entry must be cleared once the command actually finished: {:?}",
11265 runner.state.active
11266 );
11267 assert!(runner.state.gate_ran);
11268 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
11269 }
11270
11271 #[tokio::test]
11274 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
11275 use crate::verdict::{Finding, ReviewVote, Severity};
11276 crate::run::pin_test_home();
11277 let tmp = tempfile::tempdir().expect("tempdir");
11278 let repo = tmp.path().join("repo");
11279 std::fs::create_dir_all(&repo).unwrap();
11280 init_repo(&repo);
11281
11282 for (severity, vote, expect) in [
11283 (Severity::Major, ReviewVote::Reject, true),
11284 (Severity::Minor, ReviewVote::Reject, false),
11285 (Severity::Major, ReviewVote::Approve, false),
11286 ] {
11287 let mut round = review_round(false, 1, 1, 1, false, true);
11288 round.reviews = vec![ReviewRecord {
11289 reviewer: 1,
11290 agent: "alpha".to_owned(),
11291 summary: String::new(),
11292 findings: vec![Finding {
11293 id: "R1-1-1".to_owned(),
11294 severity,
11295 file: None,
11296 line: None,
11297 title: "t".to_owned(),
11298 detail: String::new(),
11299 }],
11300 vote: Some(vote),
11301 failed: None,
11302 duration_ms: 0,
11303 attempts: 0,
11304 }];
11305 let mut state = RunState::new(
11306 repo.clone(),
11307 "main".to_owned(),
11308 "deadbeef".to_owned(),
11309 "task".to_owned(),
11310 Config::default(),
11311 );
11312 state.reviews = vec![round];
11313 let mut runner = Runner {
11314 state,
11315 roles: ResolvedRoles {
11316 implementers: Vec::new(),
11317 judges: Vec::new(),
11318 reviewers: Vec::new(),
11319 fixer: None,
11320 conductor: conductor(),
11321 implementer_roster: Vec::new(),
11322 judge_roster: Vec::new(),
11323 reviewer_roster: Vec::new(),
11324 },
11325 sem: Arc::new(Semaphore::new(1)),
11326 pause: Pause::new(),
11327 interrupt: Pause::new(),
11328 };
11329 let shell = runner.state.config.shell();
11330 runner
11331 .stop_reviewing("round budget spent", &shell, &repo)
11332 .await
11333 .expect("stop_reviewing");
11334 assert_eq!(runner.state.status, RunStatus::Gating);
11335 assert_eq!(
11336 runner.state.contested_handoff.is_some(),
11337 expect,
11338 "{severity:?} + {vote:?}"
11339 );
11340 }
11341 }
11342
11343 #[tokio::test]
11356 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
11357 crate::run::pin_test_home();
11358 let home = crate::run::home();
11359
11360 let tmp = tempfile::tempdir().expect("tempdir");
11361 let repo = tmp.path().join("repo");
11362 std::fs::create_dir_all(&repo).unwrap();
11363 init_repo(&repo);
11364 let head = crate::git::rev_parse(&repo, "HEAD")
11365 .await
11366 .expect("rev-parse");
11367 let cache_dir = tmp.path().join("target");
11370
11371 let mut config = Config::default();
11372 config.verify.e2e = vec![format!(
11373 "CARGO_TARGET_DIR='{}' test -f README.md",
11374 cache_dir.display()
11375 )];
11376 config.graph.review_rounds = 1;
11377 config.graph.timeout_verify = Some(2);
11380
11381 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11382 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11383 .expect("no io error acquiring directly")
11384 {
11385 crate::cache::AcquireOutcome::Acquired(g) => g,
11386 crate::cache::AcquireOutcome::Busy(b) => {
11387 panic!("expected the direct acquire to win the lease first: {b:?}")
11388 }
11389 };
11390
11391 let mut state = RunState::new(
11392 repo.clone(),
11393 "main".to_owned(),
11394 head.clone(),
11395 "task".to_owned(),
11396 config,
11397 );
11398 state.candidates = vec![Candidate {
11399 index: 0,
11400 label: 'A',
11401 agent: "alpha".to_owned(),
11402 branch: "does-not-exist".to_owned(),
11403 worktree: repo.clone(),
11404 summary: String::new(),
11405 stat: String::new(),
11406 files: 0,
11407 commits: 0,
11408 empty: false,
11409 failed: None,
11410 verified_noop: None,
11411 duration_ms: 0,
11412 folded: false,
11413 }];
11414 state.tally = Some(Tally {
11415 first_choice: BTreeMap::from([('A', 1)]),
11416 borda: BTreeMap::new(),
11417 winner: 'A',
11418 rankings: 1,
11419 unanimous_initial: true,
11420 deliberated: false,
11421 changed_votes: 0,
11422 unanimous_final: true,
11423 tie_break: None,
11424 judges: 0,
11425 present: 0,
11426 quorum: 0,
11427 met_quorum: true,
11428 uncontested: Some("only candidate A produced a change".to_owned()),
11429 });
11430 state.reviews = vec![ReviewRound {
11434 round: 1,
11435 head: head.clone(),
11436 verified_head: None,
11437 verified_at: None,
11438 reviews: Vec::new(),
11439 e2e: Vec::new(),
11440 fix: None,
11441 blocking: 1,
11442 answered: 1,
11443 expected: 1,
11444 clean: false,
11445 verify_retried: false,
11446 e2e_deferred: true,
11447 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11448 progressed: false,
11449 vote_split: false,
11450 reconsideration: Vec::new(),
11451 verdict: None,
11452 }];
11453
11454 let mut runner = Runner {
11455 state,
11456 roles: ResolvedRoles {
11457 implementers: Vec::new(),
11458 judges: Vec::new(),
11459 reviewers: Vec::new(),
11460 fixer: None,
11461 conductor: conductor(),
11462 implementer_roster: Vec::new(),
11463 judge_roster: Vec::new(),
11464 reviewer_roster: Vec::new(),
11465 },
11466 sem: Arc::new(Semaphore::new(1)),
11467 pause: Pause::new(),
11468 interrupt: Pause::new(),
11469 };
11470
11471 let shell = runner.state.config.shell();
11472 runner
11473 .stop_reviewing("round budget spent", &shell, &repo)
11474 .await
11475 .expect("stop_reviewing");
11476
11477 let last = runner.state.reviews.last().expect("round record");
11478 assert_eq!(
11479 last.e2e_status(),
11480 E2eStatus::ResourceBlocked,
11481 "the shared cache is still held; the attempt must read as blocked, not deferred or \
11482 failed: {last:?}"
11483 );
11484 assert_eq!(
11485 last.verified_head.as_deref(),
11486 Some(head.as_str()),
11487 "which commit this attempt targeted is known even though nothing finished checking \
11488 it"
11489 );
11490 let first_attempt_at = last
11491 .verified_at
11492 .expect("when this attempt ran is known too");
11493 assert_ne!(
11494 runner.state.status,
11495 RunStatus::Blocked,
11496 "contention is evidence about the machine, not the patch — it must not settle the \
11497 run as blocked: {:?}",
11498 runner.state.status
11499 );
11500 assert!(
11501 !runner
11502 .state
11503 .events
11504 .iter()
11505 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11506 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11507 runner.state.events
11508 );
11509
11510 runner
11515 .stop_reviewing("round budget spent", &shell, &repo)
11516 .await
11517 .expect("stop_reviewing retry");
11518 assert_eq!(
11519 runner.state.reviews.len(),
11520 1,
11521 "no new round was started: {:?}",
11522 runner.state.reviews
11523 );
11524 let last = runner.state.reviews.last().expect("round record");
11525 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11526 assert!(
11527 last.verified_at.expect("still known") > first_attempt_at,
11528 "a second reentry must be a fresh attempt, not a stale copy of the first"
11529 );
11530 assert_ne!(runner.state.status, RunStatus::Blocked);
11531
11532 held.release();
11533 }
11534
11535 #[tokio::test]
11547 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11548 crate::run::pin_test_home();
11549 let home = crate::run::home();
11550
11551 let tmp = tempfile::tempdir().expect("tempdir");
11552 let repo = tmp.path().join("repo");
11553 std::fs::create_dir_all(&repo).unwrap();
11554 init_repo(&repo);
11555 let head = crate::git::rev_parse(&repo, "HEAD")
11556 .await
11557 .expect("rev-parse");
11558 let cache_dir = tmp.path().join("target");
11559
11560 let mut config = Config::default();
11561 config.verify.e2e = vec![format!(
11562 "CARGO_TARGET_DIR='{}' test -f README.md",
11563 cache_dir.display()
11564 )];
11565 config.graph.review_rounds = 1;
11566 config.graph.timeout_verify = Some(2);
11567
11568 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11569 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11570 .expect("no io error acquiring directly")
11571 {
11572 crate::cache::AcquireOutcome::Acquired(g) => g,
11573 crate::cache::AcquireOutcome::Busy(b) => {
11574 panic!("expected the direct acquire to win the lease first: {b:?}")
11575 }
11576 };
11577
11578 let mut state = RunState::new(
11579 repo.clone(),
11580 "main".to_owned(),
11581 head.clone(),
11582 "task".to_owned(),
11583 config,
11584 );
11585 state.candidates = vec![Candidate {
11586 index: 0,
11587 label: 'A',
11588 agent: "alpha".to_owned(),
11589 branch: "does-not-exist".to_owned(),
11590 worktree: repo.clone(),
11591 summary: String::new(),
11592 stat: String::new(),
11593 files: 0,
11594 commits: 0,
11595 empty: false,
11596 failed: None,
11597 verified_noop: None,
11598 duration_ms: 0,
11599 folded: false,
11600 }];
11601 state.tally = Some(Tally {
11602 first_choice: BTreeMap::from([('A', 1)]),
11603 borda: BTreeMap::new(),
11604 winner: 'A',
11605 rankings: 1,
11606 unanimous_initial: true,
11607 deliberated: false,
11608 changed_votes: 0,
11609 unanimous_final: true,
11610 tie_break: None,
11611 judges: 0,
11612 present: 0,
11613 quorum: 0,
11614 met_quorum: true,
11615 uncontested: Some("only candidate A produced a change".to_owned()),
11616 });
11617 state.reviews = vec![ReviewRound {
11621 round: 1,
11622 head: head.clone(),
11623 verified_head: Some(head.clone()),
11624 verified_at: Some(jiff::Timestamp::now()),
11625 reviews: Vec::new(),
11626 e2e: vec![CommandOutcome {
11627 command: format!(
11628 "CARGO_TARGET_DIR='{}' test -f README.md",
11629 cache_dir.display()
11630 ),
11631 code: None,
11632 output_tail: "waiting for the shared build cache".to_owned(),
11633 duration_ms: 0,
11634 resource_blocked: true,
11635 }],
11636 fix: None,
11637 blocking: 1,
11638 answered: 1,
11639 expected: 1,
11640 clean: false,
11641 verify_retried: false,
11642 e2e_deferred: false,
11643 e2e_defer_reason: None,
11644 progressed: false,
11645 vote_split: false,
11646 reconsideration: Vec::new(),
11647 verdict: None,
11648 }];
11649
11650 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11651 let mut runner = Runner {
11652 state,
11653 roles: ResolvedRoles {
11654 implementers: Vec::new(),
11655 judges: Vec::new(),
11656 reviewers: Vec::new(),
11657 fixer: None,
11658 conductor: conductor(),
11659 implementer_roster: Vec::new(),
11660 judge_roster: Vec::new(),
11661 reviewer_roster: Vec::new(),
11662 },
11663 sem: Arc::new(Semaphore::new(1)),
11664 pause: Pause::new(),
11665 interrupt: Pause::new(),
11666 };
11667
11668 runner.review_loop().await.expect("review_loop");
11673
11674 assert_eq!(
11675 runner.state.reviews.len(),
11676 1,
11677 "no new round was started on top of the unresolved one: {:?}",
11678 runner.state.reviews
11679 );
11680 let last = &runner.state.reviews[0];
11681 assert_eq!(
11682 last.e2e_status(),
11683 E2eStatus::ResourceBlocked,
11684 "still contended: {last:?}"
11685 );
11686 assert!(
11687 last.verified_at.expect("still known") > first_attempt_at,
11688 "review_loop must have actually retried the check, not left it exactly as found"
11689 );
11690 assert_ne!(
11691 runner.state.status,
11692 RunStatus::Blocked,
11693 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11694 runner.state.status
11695 );
11696
11697 held.release();
11698 }
11699
11700 #[tokio::test]
11701 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11702 crate::run::pin_test_home();
11703 let tmp = tempfile::tempdir().expect("tempdir");
11704 let repo = tmp.path().join("repo");
11705 std::fs::create_dir_all(&repo).unwrap();
11706 init_repo(&repo);
11707
11708 let mut config = Config::default();
11709 config.merge.mode = MergeMode::Pr;
11710 config.graph.land = true;
11711 config.graph.land_approval = false;
11712
11713 let mut state = RunState::new(
11714 repo.clone(),
11715 "main".to_owned(),
11716 "deadbeef".to_owned(),
11717 "task".to_owned(),
11718 config,
11719 );
11720 state.candidates = vec![Candidate {
11721 index: 0,
11722 label: 'A',
11723 agent: "alpha".to_owned(),
11724 branch: "does-not-exist".to_owned(),
11725 worktree: repo.clone(),
11726 summary: String::new(),
11727 stat: String::new(),
11728 files: 0,
11729 commits: 0,
11730 empty: false,
11731 failed: None,
11732 verified_noop: None,
11733 duration_ms: 0,
11734 folded: false,
11735 }];
11736 state.tally = Some(Tally {
11737 first_choice: BTreeMap::from([('A', 1)]),
11738 borda: BTreeMap::new(),
11739 winner: 'A',
11740 rankings: 1,
11741 unanimous_initial: true,
11742 deliberated: false,
11743 changed_votes: 0,
11744 unanimous_final: true,
11745 tie_break: None,
11746 judges: 0,
11747 present: 0,
11748 quorum: 0,
11749 met_quorum: true,
11750 uncontested: Some("only candidate A produced a change".to_owned()),
11751 });
11752 state.reviews = vec![ReviewRound {
11753 round: 1,
11754 head: "deadbeef".to_owned(),
11755 verified_head: None,
11756 verified_at: None,
11757 reviews: Vec::new(),
11758 e2e: Vec::new(),
11759 fix: None,
11760 blocking: 0,
11761 answered: 0,
11762 expected: 0,
11763 clean: true,
11764 verify_retried: false,
11765 e2e_deferred: false,
11766 e2e_defer_reason: None,
11767 progressed: false,
11768 vote_split: false,
11769 reconsideration: Vec::new(),
11770 verdict: None,
11771 }];
11772 state.gate = vec![CommandOutcome {
11773 command: "test".to_owned(),
11774 code: Some(0),
11775 output_tail: String::new(),
11776 duration_ms: 0,
11777 resource_blocked: false,
11778 }];
11779 state.gate_ran = true;
11780 state.status = RunStatus::Landing;
11784 state.merge = Some(MergeOutcome {
11785 mode: MergeMode::Pr,
11786 ok: true,
11787 detail: "https://example.invalid/x/y/pull/1".to_owned(),
11788 empty: false,
11789 });
11790
11791 ask_test_home();
11795 let store = ask::Questions::open();
11796 let q = ask_open_question(&store, &state.id);
11797
11798 let mut runner = Runner {
11799 state,
11800 roles: ResolvedRoles {
11801 implementers: Vec::new(),
11802 judges: Vec::new(),
11803 reviewers: Vec::new(),
11804 fixer: None,
11805 conductor: conductor(),
11806 implementer_roster: Vec::new(),
11807 judge_roster: Vec::new(),
11808 reviewer_roster: Vec::new(),
11809 },
11810 sem: Arc::new(Semaphore::new(1)),
11811 pause: Pause::new(),
11812 interrupt: Pause::new(),
11813 };
11814
11815 runner.execute().await.expect("execute");
11820
11821 assert_eq!(
11822 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11823 Some("https://example.invalid/x/y/pull/1"),
11824 "reentry must not push again or open a second pull request over the \
11825 one `land` is already watching"
11826 );
11827 assert_ne!(
11828 runner.state.status,
11829 RunStatus::Landing,
11830 "land could not actually reach the fake pull request, so it must \
11831 have given up rather than left the run silently parked forever"
11832 );
11833 assert_eq!(runner.state.status, RunStatus::Blocked);
11837 assert!(
11838 store.get(&q.id).unwrap().status.open(),
11839 "Blocked is still alive; settle_questions must have been a no-op here"
11840 );
11841 }
11842
11843 fn state_with_round(round: ReviewRound) -> RunState {
11844 let mut s = RunState::new(
11845 PathBuf::from("/repo"),
11846 "main".to_owned(),
11847 "abc1234".to_owned(),
11848 "add retries".to_owned(),
11849 Config::default(),
11850 );
11851 s.reviews = vec![round];
11852 s
11853 }
11854
11855 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11856 crate::verdict::Finding {
11857 id: id.to_owned(),
11858 severity,
11859 file: None,
11860 line: None,
11861 title: title.to_owned(),
11862 detail: String::new(),
11863 }
11864 }
11865
11866 #[test]
11867 fn pr_body_names_open_findings_and_declined_ones() {
11868 let round = ReviewRound {
11869 round: 2,
11870 head: "deadbee".to_owned(),
11871 verified_head: None,
11872 verified_at: None,
11873 reviews: vec![ReviewRecord {
11874 attempts: 0,
11875 reviewer: 1,
11876 agent: "alpha".to_owned(),
11877 summary: String::new(),
11878 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11879 vote: None,
11880 failed: None,
11881 duration_ms: 0,
11882 }],
11883 e2e: vec![CommandOutcome {
11884 command: "cargo test".to_owned(),
11885 code: Some(0),
11886 output_tail: String::new(),
11887 duration_ms: 0,
11888 resource_blocked: false,
11889 }],
11890 verify_retried: false,
11891 e2e_deferred: false,
11892 e2e_defer_reason: None,
11893 fix: Some(FixRecord {
11894 agent: "alpha".to_owned(),
11895 addressed: Vec::new(),
11896 rejected: vec![crate::verdict::Rejection {
11897 id: "R1-1-1".to_owned(),
11898 why: "not reachable from any caller".to_owned(),
11899 }],
11900 notes: String::new(),
11901 committed: true,
11902 failed: None,
11903 duration_ms: 0,
11904 continuation: None,
11905 }),
11906 blocking: 0,
11907 answered: 1,
11908 expected: 1,
11909 clean: false,
11910 progressed: true,
11911 vote_split: false,
11912 reconsideration: Vec::new(),
11913 verdict: None,
11914 };
11915 let state = state_with_round(round);
11916 let body = pr_message(&state, 'A').body;
11917
11918 assert!(body.contains("add retries"), "the task must still be there");
11919 assert!(body.contains("R2-1-1"), "{body}");
11920 assert!(body.contains("unused import"), "{body}");
11921 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11922 assert!(
11923 body.contains("not reachable from any caller"),
11924 "the reason it was declined: {body}"
11925 );
11926 }
11927
11928 #[test]
11929 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11930 let round = ReviewRound {
11931 round: 1,
11932 head: "deadbee".to_owned(),
11933 verified_head: None,
11934 verified_at: None,
11935 reviews: vec![ReviewRecord {
11936 attempts: 0,
11937 reviewer: 1,
11938 agent: "alpha".to_owned(),
11939 summary: String::new(),
11940 findings: Vec::new(),
11941 vote: None,
11942 failed: None,
11943 duration_ms: 0,
11944 }],
11945 e2e: Vec::new(),
11946 verify_retried: false,
11947 e2e_deferred: false,
11948 e2e_defer_reason: None,
11949 fix: None,
11950 blocking: 0,
11951 answered: 1,
11952 expected: 1,
11953 clean: true,
11954 progressed: false,
11955 vote_split: false,
11956 reconsideration: Vec::new(),
11957 verdict: None,
11958 };
11959 let state = state_with_round(round);
11960 let body = pr_message(&state, 'A').body;
11961 assert!(!body.contains("Open review findings"), "{body}");
11962 assert!(!body.contains("Declined"), "{body}");
11963 }
11964
11965 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11966 let mut state = RunState::new(
11967 PathBuf::from("/repo"),
11968 "main".to_owned(),
11969 "abc1234".to_owned(),
11970 instruction.to_owned(),
11971 Config::default(),
11972 );
11973 state.candidates.push(Candidate {
11974 index: 0,
11975 label: 'A',
11976 agent: "alpha".to_owned(),
11977 branch: "magi/x/A".to_owned(),
11978 worktree: PathBuf::from("/wt"),
11979 summary: summary.to_owned(),
11980 stat: String::new(),
11981 files: 1,
11982 commits: 1,
11983 empty: false,
11984 failed: None,
11985 verified_noop: None,
11986 folded: false,
11987 duration_ms: 0,
11988 });
11989 state
11990 }
11991
11992 fn review_state(subjects: &[&str]) -> RunState {
11993 let mut state = state_with_summary(
11994 "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",
11995 "",
11996 );
11997 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11998 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11999 state
12000 }
12001
12002 #[tokio::test]
12005 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
12006 ask_test_home();
12007 let tmp = tempfile::tempdir().unwrap();
12008 let repo = tmp.path().join("repo");
12009 std::fs::create_dir_all(&repo).unwrap();
12010 init_repo(&repo);
12011 let origin = tmp.path().join("origin.git");
12012 let g = |dir: &Path, args: &[&str]| {
12013 let out = std::process::Command::new("git")
12014 .args(args)
12015 .current_dir(dir)
12016 .quiet()
12017 .output()
12018 .expect("spawn git");
12019 assert!(
12020 out.status.success(),
12021 "git {args:?}: {}",
12022 String::from_utf8_lossy(&out.stderr)
12023 );
12024 };
12025 g(
12026 tmp.path(),
12027 &[
12028 "clone",
12029 "--bare",
12030 "-q",
12031 repo.to_str().unwrap(),
12032 origin.to_str().unwrap(),
12033 ],
12034 );
12035 g(
12036 &repo,
12037 &["remote", "add", "origin", origin.to_str().unwrap()],
12038 );
12039 let c1 = git::rev_parse(&repo, "main").await.unwrap();
12040
12041 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
12043 g(&repo, &["add", "-A"]);
12044 g(
12045 &repo,
12046 &["commit", "-q", "-m", "chore(deps): update a crate"],
12047 );
12048 g(&repo, &["push", "-q", "origin", "main"]);
12049 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
12050 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
12051 g(&repo, &["add", "-A"]);
12052 g(
12053 &repo,
12054 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
12055 );
12056 g(&repo, &["checkout", "-q", "main"]);
12057
12058 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
12059 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
12060 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
12062 assert_eq!(fallback, c1);
12063
12064 let mut state = review_state(&[
12065 "chore(deps): update a crate",
12066 "fix(daemon): apply a chosen action",
12067 ]);
12068 state.repo = repo.clone();
12069 state.base_branch = "main".to_owned();
12070 state.base_commit = c1;
12071 refresh_reviewed_commits(&mut state, "feat/own").await;
12072 assert_eq!(
12073 state.reviewed_commits,
12074 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
12075 );
12076 assert_eq!(
12077 review_title(&state).as_deref(),
12078 Some("fix(daemon): apply a chosen action")
12079 );
12080 assert_eq!(
12081 leaked_subjects(&state, "feat/own").await,
12082 Some(vec!["chore(deps): update a crate".to_owned()])
12083 );
12084 state.config.merge.remote = "nowhere".to_owned();
12087 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
12088 }
12089
12090 #[test]
12091 fn pr_message_review_single_commit_uses_its_subject() {
12092 let state = review_state(&["feat(nats): per-role user"]);
12093 let m = pr_message(&state, 'A');
12094 assert_eq!(m.title, "feat(nats): per-role user");
12095 assert!(!m.body.contains("Review the work already"), "{}", m.body);
12096 assert!(m.body.contains("## Commits under review"), "{}", m.body);
12097 }
12098
12099 #[test]
12100 fn pr_message_review_multi_commit_takes_the_oldest() {
12101 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
12102 let m = pr_message(&state, 'A');
12103 assert_eq!(m.title, "feat: the change");
12104 for s in ["feat: the change", "fix: typo", "fix: again"] {
12105 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
12106 }
12107 }
12108
12109 #[test]
12110 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
12111 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
12112 let mut state = review_state(&[first, "fix: later fixup"]);
12113 state.candidates[0].branch = "機能/ログイン".to_owned();
12114 let m = pr_message(&state, 'A');
12115 assert!(
12116 m.title.starts_with("chore: land candidate A of run"),
12117 "{}",
12118 m.title
12119 );
12120 }
12121 }
12122
12123 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
12124 BranchFacts {
12125 commits: commits
12126 .iter()
12127 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
12128 .collect(),
12129 stat: stat.to_owned(),
12130 }
12131 }
12132
12133 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
12134 let mut r = review_round(true, 0, 1, 1, true, true);
12135 r.round = round;
12136 r.fix = notes.map(|n| FixRecord {
12137 agent: "fixer".to_owned(),
12138 addressed: Vec::new(),
12139 rejected: Vec::new(),
12140 notes: n.to_owned(),
12141 committed: true,
12142 failed: None,
12143 duration_ms: 0,
12144 continuation: None,
12145 });
12146 r
12147 }
12148
12149 #[test]
12150 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
12151 let state = review_state(&["ignored"]);
12152 let f = facts(
12153 &[
12154 (
12155 "fix(login): resolve PATH on macOS",
12156 "Login shells skip rc files.",
12157 ),
12158 ("fix: address review", ""),
12159 ],
12160 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
12161 );
12162 let m = pr_message_with(&state, 'A', Some(&f));
12163 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
12164 assert!(
12165 m.body
12166 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
12167 "{}",
12168 m.body
12169 );
12170 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
12171 assert!(m.body.contains("## Diff stat"), "{}", m.body);
12172 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
12173 for banned in [
12174 "Review the work already",
12175 "no task statement",
12176 "Original task",
12177 ] {
12178 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
12179 }
12180 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
12181 }
12182
12183 #[test]
12184 fn pr_message_review_truncates_a_huge_first_commit_body() {
12185 let state = review_state(&["ignored"]);
12186 let f = facts(
12187 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
12188 "s",
12189 );
12190 let m = pr_message_with(&state, 'A', Some(&f));
12191 assert!(m.body.len() < 30_000, "{}", m.body.len());
12192 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
12193 assert!(
12194 m.body.contains("- ... 1 more commit(s)"),
12195 "{}",
12196 m.body.len()
12197 );
12198 assert!(m.body.ends_with("magi:candidate-a\n"));
12199 }
12200
12201 #[test]
12202 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
12203 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
12204 assert_eq!(m.title, "feat: x");
12205 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
12206 assert!(!m.body.contains("Diff stat"), "{}", m.body);
12207 assert!(!m.body.contains("no task statement"), "{}", m.body);
12208 }
12209
12210 #[test]
12211 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
12212 let mut state = review_state(&["日本語の件名"]);
12213 state.candidates[0].branch = "fix/macos-login-path".to_owned();
12214 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
12215 state.candidates[0].branch = "機能/ログイン".to_owned();
12216 assert!(
12217 pr_message(&state, 'A')
12218 .title
12219 .starts_with("chore: land candidate A")
12220 );
12221 }
12222
12223 #[test]
12224 fn pr_message_review_fixes_survive_a_clean_final_round() {
12225 let mut state = review_state(&["feat: x"]);
12226 state.reviews = vec![
12227 round_with_notes(1, Some("handled the PATH case")),
12228 round_with_notes(2, None),
12229 ];
12230 let body = pr_message(&state, 'A').body;
12231 assert!(
12232 body.contains("## Review fixes\n\nhandled the PATH case\n"),
12233 "{body}"
12234 );
12235 assert!(!body.contains("### Round"), "{body}");
12236
12237 state.reviews = vec![
12238 round_with_notes(1, Some("first fix")),
12239 round_with_notes(2, Some("")),
12240 round_with_notes(3, Some("second fix")),
12241 round_with_notes(4, None),
12242 ];
12243 let body = pr_message(&state, 'A').body;
12244 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
12245 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
12246 assert!(!body.contains("### Round 2"), "{body}");
12247 }
12248
12249 #[test]
12250 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
12251 let state = state_with_summary(
12252 "add retries to the client",
12253 "TITLE: feat: retries\n\nDid it.",
12254 );
12255 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
12256 assert_eq!(m.title, "feat: retries");
12257 assert!(
12258 m.body.contains("<summary>Original task</summary>"),
12259 "{}",
12260 m.body
12261 );
12262 assert!(!m.body.contains("Commits under review"), "{}", m.body);
12263 assert!(
12264 m.body
12265 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
12266 "{}",
12267 m.body
12268 );
12269 }
12270
12271 #[test]
12272 fn pr_message_review_bounds_a_long_english_subject() {
12273 let long = format!("feat: {}", "word ".repeat(100));
12274 let m = pr_message(&review_state(&[&long]), 'A');
12275 assert!(m.title.starts_with("feat: word"), "{}", m.title);
12276 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12277 }
12278
12279 #[test]
12280 fn pr_message_implementation_run_is_unchanged_by_review_support() {
12281 let state = state_with_summary("add retries\n\ndetails", "- did some things");
12282 let m = pr_message(&state, 'A');
12283 assert_eq!(m.title, "add retries");
12284 assert!(m.body.contains("<summary>Original task</summary>"));
12285 assert!(!m.body.contains("Commits under review"));
12286 assert_eq!(landing_subject_source(&state), state.instruction);
12287 }
12288
12289 #[test]
12290 fn review_run_squash_subject_is_the_change_not_the_prompt() {
12291 let state = review_state(&["feat: the change", "fix: typo"]);
12292 let source = landing_subject_source(&state);
12293 assert_eq!(land::merge_subject("", &source), "feat: the change");
12294 assert_eq!(
12295 land::merge_subject("magi: candidate A (uncommitted work)", &source),
12296 "feat: the change"
12297 );
12298 assert_eq!(
12300 land::merge_subject("feat: renamed by hand", &source),
12301 "feat: renamed by hand"
12302 );
12303 let blank = review_state(&["日本語"]);
12304 assert!(
12305 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
12306 );
12307 }
12308
12309 #[test]
12310 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
12311 let state = review_state(&["feat: the change"]);
12312 let source = landing_subject_source(&state);
12313 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
12314 assert_eq!(
12315 land::merge_subject(landing_title(&state, old), &source),
12316 "feat: the change"
12317 );
12318 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
12319 let task = state_with_summary("add retries", "");
12320 assert_eq!(landing_title(&task, old), old);
12321 }
12322
12323 #[test]
12324 fn pr_message_describes_the_change_not_the_task() {
12325 let state = state_with_summary(
12326 "今回やってほしいこと: results projector を直す",
12327 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
12328 );
12329 let m = pr_message(&state, 'A');
12330 assert_eq!(m.title, "fix(web): batch the runs list reads");
12331 assert!(
12332 m.body.starts_with("## Summary\n\n- reads run.json once"),
12333 "{}",
12334 m.body
12335 );
12336 assert!(!m.body.contains("TITLE:"), "{}", m.body);
12337 let task_at = m.body.find("今回やってほしいこと").unwrap();
12338 let details_at = m.body.find("<details>").unwrap();
12339 assert!(
12340 details_at < task_at,
12341 "the task lives inside <details>: {}",
12342 m.body
12343 );
12344 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
12345 assert!(m.body.contains("magi:candidate-a"));
12346 }
12347
12348 #[test]
12349 fn pr_message_falls_back_to_the_task_without_a_title_line() {
12350 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
12351 let m = pr_message(&state, 'A');
12352 assert_eq!(m.title, "add retries");
12353 assert!(
12354 m.body.contains("## Summary\n\n- did some things"),
12355 "{}",
12356 m.body
12357 );
12358
12359 let none = RunState::new(
12360 PathBuf::from("/repo"),
12361 "main".to_owned(),
12362 "abc1234".to_owned(),
12363 "add retries".to_owned(),
12364 Config::default(),
12365 );
12366 let m = pr_message(&none, 'A');
12367 assert_eq!(m.title, "add retries");
12368 assert!(!m.body.contains("## Summary"), "{}", m.body);
12369 }
12370
12371 #[test]
12372 fn pr_message_refuses_the_candidate_commit_subject() {
12373 for bad in [
12374 "TITLE: magi: candidate A (uncommitted work)",
12375 "TITLE: chore: stuff (uncommitted work)",
12376 "TITLE: ",
12377 ] {
12378 let state = state_with_summary("add retries", bad);
12379 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
12380 }
12381 }
12382
12383 #[test]
12384 fn pr_message_bounds_a_very_long_task_and_title() {
12385 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
12386 let state = state_with_summary(&long, "- nothing");
12387 let m = pr_message(&state, 'A');
12388 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12389 assert!(!m.title.contains('\n'));
12390
12391 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12392 let m = pr_message(&state, 'A');
12393 assert!(m.title.starts_with("feat: "));
12394 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12395 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12396 }
12397
12398 fn long_title_of(instruction: &str) -> String {
12399 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12400 }
12401
12402 #[test]
12403 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12404 let first = "Make the landing path keep a readable title for long tasks";
12405 let line = format!(
12406 "{first}. {}",
12407 "And then keep going with more words ".repeat(20)
12408 );
12409 let t = long_title_of(&line);
12410 assert_eq!(t, first);
12411 assert!(!t.starts_with("chore: land"));
12412 }
12413
12414 #[test]
12415 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12416 let line = "word ".repeat(200);
12417 let t = long_title_of(&line);
12418 assert!(t.ends_with("word..."), "{t}");
12419 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12420 }
12421
12422 #[test]
12423 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12424 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12425 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12426 }
12427 }
12428
12429 #[test]
12430 fn pr_message_title_limit_is_exact() {
12431 let at = "a".repeat(PR_TITLE_MAX);
12432 assert_eq!(long_title_of(&at), at);
12433 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12434 assert!(over.ends_with("..."), "{over}");
12435 assert_eq!(over.chars().count(), PR_TITLE_MAX);
12436 }
12437
12438 #[test]
12439 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12440 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12441 let t = long_title_of(&line);
12442 assert!(t.ends_with("..."), "{t}");
12443 assert!(t.is_ascii(), "{t}");
12444 }
12445
12446 #[test]
12447 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12448 let line = format!(
12449 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12450 "plus more filler words ".repeat(20)
12451 );
12452 let t = long_title_of(&line);
12453 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12454 }
12455
12456 #[test]
12457 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12458 let line = format!("\n\n# {}", "title words ".repeat(40));
12459 let state = state_with_summary(&line, "- nothing");
12460 let m = pr_message(&state, 'A');
12461 assert!(m.title.starts_with("title words"), "{}", m.title);
12462 assert_eq!(
12463 land::merge_subject(&m.title, &landing_subject_source(&state)),
12464 m.title
12465 );
12466 assert_eq!(
12468 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12469 "feat: renamed by hand"
12470 );
12471 }
12472
12473 #[tokio::test]
12474 async fn github_text_rewrite_is_bounded_and_falls_back() {
12475 crate::run::pin_test_home();
12476 for (reply, accepted) in [
12477 (
12478 "TITLE: fix: retries\nAdd retries for failed requests.",
12479 true,
12480 ),
12481 (
12482 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12483 false,
12484 ),
12485 ("TITLE: fix: retries\nUse token=secret", false),
12486 ] {
12487 let dir = tempfile::tempdir().unwrap();
12488 let mut runner = runner_at(RunStatus::Gating);
12489 runner.state = state_with_summary(
12490 "add retries",
12491 "TITLE: fix: retries\n日本語の説明を書きます。",
12492 );
12493 runner.state.repo = dir.path().to_owned();
12494 runner.state.candidates[0].worktree = dir.path().to_owned();
12495 let mut author = spec("alpha");
12496 author.command = vec![
12497 "sh".into(),
12498 "-c".into(),
12499 "printf '%s' \"$REWRITE_REPLY\"".into(),
12500 ];
12501 author.env.insert("REWRITE_REPLY".into(), reply.into());
12502 runner.state.config.agents = vec![author];
12503 let winner = runner.state.candidates[0].clone();
12504 let first = runner
12505 .guarded_pr_message(&winner, None, true)
12506 .await
12507 .unwrap();
12508 let message = if accepted {
12509 first.expect("a passing rewrite never asks")
12510 } else {
12511 assert!(first.is_none());
12513 assert!(runner.state.parked);
12514 let store = ask::Questions::open();
12515 let asked: Vec<_> = store
12516 .list()
12517 .into_iter()
12518 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12519 .collect();
12520 assert_eq!(asked.len(), 1);
12521 assert!(!asked[0].detail.contains("secret"));
12522 store
12525 .update(&asked[0].id, |q| {
12526 q.abandon("test");
12527 Ok(())
12528 })
12529 .unwrap();
12530 let again = runner
12531 .guarded_pr_message(&winner, None, true)
12532 .await
12533 .unwrap()
12534 .expect("an abandoned question falls back");
12535 let n = store
12536 .list()
12537 .into_iter()
12538 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12539 .count();
12540 assert_eq!(n, 1, "asked once per rejected text");
12541 again
12542 };
12543 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12544 assert_eq!(
12545 message.body.contains("Add retries for failed requests."),
12546 accepted
12547 );
12548 assert_eq!(
12549 runner.state.seats["impl-A"].turns, 1,
12550 "only one rewrite invocation"
12551 );
12552 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12553 if !accepted {
12554 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12555 assert!(
12556 message
12557 .body
12558 .contains(&format!("magi:run/{}", runner.state.id))
12559 );
12560 }
12561 }
12562 }
12563
12564 #[tokio::test]
12565 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12566 crate::run::pin_test_home();
12567 let dir = tempfile::tempdir().unwrap();
12568 let mut runner = runner_at(RunStatus::Gating);
12569 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12570 runner.state.repo = dir.path().to_owned();
12571 runner.state.candidates[0].worktree = dir.path().to_owned();
12572 let winner = runner.state.candidates[0].clone();
12573 assert!(
12574 runner
12575 .guarded_pr_message(&winner, None, true)
12576 .await
12577 .unwrap()
12578 .is_none()
12579 );
12580 let store = ask::Questions::open();
12581 let find = |run: &str| {
12582 store
12583 .list()
12584 .into_iter()
12585 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12586 .unwrap()
12587 };
12588 let run = runner.state.id.clone();
12589 let q = find(&run);
12590 let fp = runner
12591 .state
12592 .github_text
12593 .as_ref()
12594 .unwrap()
12595 .fingerprint
12596 .clone();
12597 let name = crate::github_text::artifact_name(&fp);
12598 let path = crate::run::artifact_path(&runner.state, &name);
12599 assert!(q.detail.contains(&path.display().to_string()));
12600 assert!(q.detail.contains("title-language: title"));
12601 let saved = std::fs::read_to_string(&path).expect("artifact written");
12602 assert!(saved.contains("日本語のタイトル"));
12603 assert!(q.cwd.is_none());
12604 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
12605 let say = |id: &str, text: &str, choice: &str| {
12607 store
12608 .update(id, |q| {
12609 q.thread.push(ask::Turn {
12610 who: ask::Who::Operator,
12611 body: text.to_owned(),
12612 at: jiff::Timestamp::now(),
12613 note: None,
12614 });
12615 q.answer(ask::Answer::Choice(choice.to_owned()))
12616 })
12617 .unwrap();
12618 };
12619 say(&q.id, "まだ日本語", "use my text");
12620 assert!(
12621 runner
12622 .guarded_pr_message(&winner, None, true)
12623 .await
12624 .unwrap()
12625 .is_none()
12626 );
12627 let second = find(&run);
12628 assert_ne!(second.id, q.id);
12629 assert!(second.detail.contains("did not pass"));
12630 assert!(second.detail.contains("日本語のタイトル"));
12633 assert!(!second.detail.contains("まだ日本語"));
12634 assert!(second.detail.contains(&path.display().to_string()));
12635 say(&second.id, "fix: retry failed requests", "use my text");
12636 let message = runner
12637 .guarded_pr_message(&winner, None, true)
12638 .await
12639 .unwrap()
12640 .expect("a vetted replacement is posted");
12641 assert_eq!(message.title, "fix: retry failed requests");
12642 assert_eq!(
12643 runner
12644 .state
12645 .github_text
12646 .as_ref()
12647 .unwrap()
12648 .chosen_title
12649 .as_deref(),
12650 Some("fix: retry failed requests")
12651 );
12652 assert!(runner.state.github_text.as_ref().unwrap().resolved);
12653 let again = runner
12655 .guarded_pr_message(&winner, None, true)
12656 .await
12657 .unwrap()
12658 .unwrap();
12659 assert_eq!(again.title, message.title);
12660 assert_eq!(
12661 store
12662 .list()
12663 .into_iter()
12664 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12665 .count(),
12666 2
12667 );
12668 }
12669
12670 #[test]
12671 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12672 let mut state = state_with_summary(
12676 "add retries",
12677 "TITLE: fix(web): batch reads\n- reads run.json once",
12678 );
12679 state.config.graph.language = "ja".to_owned();
12680 let m = pr_message(&state, 'A');
12681 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
12682 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
12683
12684 let task = "今回やってほしいこと: results projector を直す";
12687 let mut state = state_with_summary(task, "- no title line");
12688 state.config.graph.language = "ja".to_owned();
12689 let m = pr_message(&state, 'A');
12690 assert_eq!(
12691 m.title,
12692 format!("chore: land candidate A of run {}", state.id)
12693 );
12694 assert!(
12695 m.body.contains(&format!(
12696 "<summary>Original task</summary>\n\n{task}\n\n</details>"
12697 )),
12698 "{}",
12699 m.body
12700 );
12701 }
12702
12703 #[test]
12704 fn pr_message_scrubs_home_paths_and_addresses() {
12705 let state = state_with_summary(
12706 "fix it in /Users/someone/src/x",
12707 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
12708 );
12709 let m = pr_message(&state, 'A');
12710 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
12711 assert!(!m.body.contains(leak), "{}", m.body);
12712 }
12713 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
12714 }
12715
12716 #[test]
12717 fn pr_message_survives_a_task_that_closes_details() {
12718 let state = state_with_summary("a </details> b", "TITLE: fix: x");
12719 let m = pr_message(&state, 'A');
12720 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
12721 }
12722
12723 #[test]
12724 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
12725 let cmd = manual_merge_command(
12726 MergeStyle::Squash,
12727 Path::new("/repo"),
12728 "b",
12729 "fix: \"quoted\" $(x) `y`\n\nbody",
12730 );
12731 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
12732 }
12733
12734 #[test]
12735 fn manual_merge_command_matches_the_configured_style() {
12736 let repo = Path::new("/repo");
12737 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
12738
12739 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
12740 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
12741
12742 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
12743 assert_eq!(
12744 squash,
12745 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
12746 \"Merge magi run 0832 (candidate A)\""
12747 );
12748
12749 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
12750 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
12751 }
12752
12753 #[test]
12754 fn a_nudge_gets_a_quarter_of_the_budget() {
12755 assert_eq!(retry_budget(secs(1200), true), secs(300));
12757 assert_eq!(retry_budget(secs(3600), true), secs(900));
12758 }
12759
12760 #[test]
12761 fn a_resent_prompt_keeps_the_whole_budget() {
12762 assert_eq!(retry_budget(secs(1200), false), secs(1200));
12765 assert_eq!(retry_budget(secs(60), false), secs(60));
12766 }
12767
12768 #[test]
12769 fn the_floor_never_exceeds_the_original_budget() {
12770 assert_eq!(retry_budget(secs(60), true), secs(60));
12774 assert_eq!(retry_budget(secs(480), true), secs(120));
12775 assert_eq!(retry_budget(secs(0), true), secs(0));
12776 }
12777
12778 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12779 agent::CommandEvidence {
12780 id: "item1".to_owned(),
12781 description: "cargo test".to_owned(),
12782 exit_code,
12783 result_summary: String::new(),
12784 source: "codex".to_owned(),
12785 }
12786 }
12787
12788 #[test]
12789 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12790 assert!(!has_unconfirmed_command(&[]));
12794 }
12795
12796 #[test]
12797 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12798 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12802 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12803 assert!(!has_unconfirmed_command(&[
12804 evidence(Some(0)),
12805 evidence(Some(101))
12806 ]));
12807 }
12808
12809 #[test]
12810 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12811 assert!(has_unconfirmed_command(&[
12812 evidence(Some(0)),
12813 evidence(None)
12814 ]));
12815 }
12816
12817 #[test]
12818 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12819 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12820 assert_eq!(
12821 verified_noop_claim(true, &[], text).as_deref(),
12822 Some("already fixed by b32cfc4, on main.")
12823 );
12824 }
12825
12826 #[test]
12827 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12828 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12831 assert!(verified_noop_claim(false, &[], text).is_none());
12832 }
12833
12834 #[test]
12835 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12836 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12837 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12838 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12840 }
12841
12842 #[test]
12843 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12844 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12845 }
12846
12847 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12850 runner.state.candidates = shape
12851 .iter()
12852 .enumerate()
12853 .map(|(i, &(empty, verified))| Candidate {
12854 index: i,
12855 label: (b'A' + i as u8) as char,
12856 agent: "sonnet".to_owned(),
12857 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12858 worktree: PathBuf::from(format!("/wt/{i}")),
12859 summary: String::new(),
12860 stat: String::new(),
12861 files: 0,
12862 commits: 0,
12863 empty,
12864 failed: None,
12865 verified_noop: verified.map(str::to_owned),
12866 duration_ms: 0,
12867 folded: false,
12868 })
12869 .collect();
12870 }
12871
12872 #[test]
12873 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12874 ask_test_home();
12875 let mut runner = runner_at(RunStatus::Implementing);
12876 set_candidates(
12877 &mut runner,
12878 &[
12879 (true, Some("already on main at b32cfc4")),
12880 (true, Some("same fix, see the existing test")),
12881 ],
12882 );
12883
12884 runner
12885 .after_implement()
12886 .expect("a verified no-op is not an error");
12887
12888 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12889 }
12890
12891 #[test]
12892 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12893 ask_test_home();
12894 let mut runner = runner_at(RunStatus::Implementing);
12895 set_candidates(
12899 &mut runner,
12900 &[(true, Some("already on main at b32cfc4")), (true, None)],
12901 );
12902
12903 let err = runner
12904 .after_implement()
12905 .expect_err("an unverified empty candidate must still fail the run");
12906
12907 assert!(
12908 err.to_string().contains("no candidate produced a change"),
12909 "{err}"
12910 );
12911 assert_eq!(runner.state.status, RunStatus::Failed);
12912 }
12913
12914 #[test]
12915 fn after_implement_still_fails_an_ordinary_all_empty_run() {
12916 ask_test_home();
12917 let mut runner = runner_at(RunStatus::Implementing);
12918 set_candidates(&mut runner, &[(true, None), (true, None)]);
12919
12920 let err = runner
12921 .after_implement()
12922 .expect_err("no candidate declared anything; this is an ordinary failure");
12923
12924 assert!(
12925 err.to_string().contains("no candidate produced a change"),
12926 "{err}"
12927 );
12928 assert_eq!(runner.state.status, RunStatus::Failed);
12929 }
12930}
12931
12932#[derive(Debug)]
12935struct GraftRestoreFailed(String);
12936
12937impl std::fmt::Display for GraftRestoreFailed {
12938 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12939 write!(
12940 f,
12941 "could not restore the winner to its pre-graft commit: {}",
12942 self.0
12943 )
12944 }
12945}
12946
12947impl std::error::Error for GraftRestoreFailed {}