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
362pub(crate) fn 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 for id in takeover.earlier.iter().rev() {
729 if let Ok(old) = RunState::load_under(id, &takeover.home)
730 && let Some(head) = old.pr_head.clone()
731 && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
732 {
733 state.pr_head = Some(head);
734 break;
735 }
736 }
737 let opened =
738 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
739 if opened.is_err()
740 && let Some(released) = &released
741 {
742 released.restore(&repo, branch).await;
743 }
744 opened
745 }
746
747 async fn open_review(
750 repo: &Path,
751 branch: &str,
752 mut state: RunState,
753 roles: ResolvedRoles,
754 max_parallel: usize,
755 base_commit: String,
756 ) -> Result<Self> {
757 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
758 let start = review_base(
761 repo,
762 &state.config.merge.remote,
763 &state.base_branch,
764 &base_commit,
765 branch,
766 )
767 .await;
768 let log = git::log_oneline(repo, &start, branch)
769 .await
770 .unwrap_or_default();
771 let instruction = format!(
772 "Review the work already on branch `{branch}`. There is no task \
773 statement: what the change claims to do is whatever its commits \
774 say.\n\n{}",
775 if log.trim().is_empty() {
776 "(no commit messages)"
777 } else {
778 log.trim()
779 }
780 );
781 state.instruction = instruction;
782 state.reviewed_commits = Some(
783 git::subjects(repo, &start, branch)
784 .await
785 .unwrap_or_default(),
786 );
787
788 let worktree = state.worktree_root().join("under-review");
791 if let Some(parent) = worktree.parent() {
792 tokio::fs::create_dir_all(parent).await.ok();
793 }
794 let path = worktree.to_string_lossy().to_string();
795 git::git(repo, &["worktree", "add", &path, branch])
796 .await
797 .with_context(|| {
798 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
799 })?;
800
801 let setup = match ensure_setup_config(&mut state, repo).await {
802 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
803 Err(e) => Err(e),
804 };
805 if let Err(e) = setup {
806 git::worktree_remove(repo, &worktree).await.ok();
807 return Err(e);
808 }
809 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
810 .await
811 .unwrap_or(0);
812 if commits == 0 {
813 git::worktree_remove(repo, &worktree).await.ok();
814 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
815 }
816 let files = git::changed_files(&worktree, &base_commit, "HEAD")
817 .await
818 .map(|f| f.len())
819 .unwrap_or(0);
820 if files == 0
821 && let (Ok(head_tree), Ok(base_tree)) = (
822 git::tree_of(&worktree, "HEAD").await,
823 git::tree_of(&worktree, &base_commit).await,
824 )
825 && head_tree == base_tree
826 {
827 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
828 git::worktree_remove(repo, &worktree).await.ok();
829 bail!(
830 "`{branch}` at {} has a tree identical to base {}; this usually means \
831 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
832 against `{}/{branch}`) rather than an empty change",
833 short(&head),
834 short(&base_commit),
835 state.config.merge.remote
836 );
837 }
838 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
839 .await
840 .unwrap_or_default();
841
842 state.candidates.push(Candidate {
843 index: 0,
844 label: 'A',
845 agent: EXISTING_BRANCH.to_owned(),
848 branch: branch.to_owned(),
849 worktree,
850 summary: String::new(),
851 stat,
852 files,
853 commits,
854 empty: false,
855 failed: None,
856 verified_noop: None,
857 duration_ms: 0,
858 folded: false,
859 });
860 state.tally = Some(Tally {
861 first_choice: BTreeMap::from([('A', 0)]),
862 borda: BTreeMap::new(),
863 winner: 'A',
864 rankings: 0,
865 unanimous_initial: false,
866 deliberated: false,
867 changed_votes: 0,
868 unanimous_final: false,
869 tie_break: None,
870 judges: 0,
874 present: 0,
875 quorum: 0,
876 met_quorum: true,
877 uncontested: Some("review-only run: nothing competed".to_owned()),
878 });
879 state.status = RunStatus::Reviewing;
880 state.event(
881 "start",
882 format!(
883 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
884 state.id
885 ),
886 );
887 state.save()?;
888 Ok(Self {
889 state,
890 roles,
891 sem: Arc::new(Semaphore::new(max_parallel)),
892 pause: Pause::new(),
893 interrupt: Pause::new(),
894 })
895 }
896
897 pub fn resume(id: &str) -> Result<Self> {
899 let state = RunState::load(id)?;
900 if let Some(to) = &state.released_to {
901 bail!(
902 "run {} cannot be resumed: its worktree was released to run {}",
903 state.short(),
904 crate::run::short_of(to)
905 );
906 }
907 let roles = state.config.resolve_roles()?;
908 let max_parallel = state.config.graph.max_parallel.max(1);
909 Ok(Self {
910 state,
911 roles,
912 sem: Arc::new(Semaphore::new(max_parallel)),
913 pause: Pause::new(),
914 interrupt: Pause::new(),
915 })
916 }
917
918 pub async fn execute(&mut self) -> Result<()> {
925 let result = self.execute_graph().await;
926 self.mark_driver_exited();
927 let ended = if result.is_err() {
928 Some(crate::notices::run_stopped(&self.state.id, &self.state))
929 } else {
930 crate::notices::run_ended(&self.state)
931 };
932 if let Some(notice) = ended {
933 crate::notices::raise(notice);
934 }
935 result
936 }
937
938 fn mark_driver_exited(&mut self) {
947 self.state.driver_exited = true;
948 let pid = std::process::id();
949 let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
950 return;
951 };
952 let Ok(mut disk) = RunState::load(&self.state.id) else {
953 return;
954 };
955 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
956 return;
957 }
958 disk.driver_exited = true;
959 if let Err(e) = disk.save() {
960 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
961 }
962 }
963
964 fn claim_driver(&mut self) -> Result<()> {
966 let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
967 let disk = RunState::load(&self.state.id)?;
968 crate::pr_head::ensure_owner(&disk)?;
969 self.state.parked = false;
974 self.state.clear_active();
981 if let Some(to) = &disk.released_to {
999 bail!(
1000 "run {} cannot continue: its worktree was released to run {}",
1001 self.state.short(),
1002 crate::run::short_of(to)
1003 );
1004 }
1005 let pid = std::process::id();
1006 self.state.driver_pid = Some(pid);
1007 self.state.driver_started_at = crate::proc::process_started_at(pid);
1008 self.state.driver_exited = false;
1009 self.state.save()?;
1010 Ok(())
1011 }
1012
1013 async fn execute_graph(&mut self) -> Result<()> {
1014 self.claim_driver()?;
1015 if self.state.pr_publication_pending {
1018 self.merge().await?;
1019 self.state.save()?;
1020 self.settle_questions();
1021 return Ok(());
1022 }
1023 if self.state.status == RunStatus::Stalled {
1036 if self.recover_stall().await? {
1037 self.finish_after_tally().await?;
1038 } else {
1039 self.state.save()?;
1041 }
1042 return Ok(());
1043 }
1044 if self.state.status == RunStatus::Landing {
1054 self.run_land().await?;
1055 self.settle_questions();
1060 return Ok(());
1061 }
1062 if self.state.github_text.is_some()
1067 && self.state.merge.is_none()
1068 && self.state.status == RunStatus::Gating
1069 {
1070 self.merge().await?;
1071 self.state.save()?;
1072 return Ok(());
1073 }
1074 self.prep().await?;
1075 if self.park_here()? {
1076 return Ok(());
1077 }
1078 self.advise().await?;
1079 if self.park_here()? {
1080 return Ok(());
1081 }
1082 self.implement().await?;
1083 if self.park_here()? {
1084 return Ok(());
1085 }
1086 if self.state.status == RunStatus::VerifiedNoop {
1090 return Ok(());
1091 }
1092 self.judge().await?;
1093 if self.park_here()? {
1094 return Ok(());
1095 }
1096 self.deliberate().await?;
1097 if self.park_here()? {
1098 return Ok(());
1099 }
1100 self.vote().await?;
1101 if self.park_here()? {
1102 return Ok(());
1103 }
1104 self.tally()?;
1105 if self.state.status == RunStatus::Stalled {
1110 self.state.save()?;
1114 return Ok(());
1115 }
1116 self.finish_after_tally().await?;
1117 Ok(())
1118 }
1119
1120 fn park_here(&mut self) -> Result<bool> {
1127 if !self.pause.parked() && !self.interrupt.parked() {
1132 return Ok(false);
1133 }
1134 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1135 Some(reason) => format!(
1136 "parked after `{}` ({reason}) — resume to carry on from here",
1137 self.state.status.as_str()
1138 ),
1139 None => format!(
1140 "parked after `{}` — resume to carry on from here",
1141 self.state.status.as_str()
1142 ),
1143 };
1144 self.state.event("park", why);
1145 self.state.parked = true;
1146 self.state.save()?;
1147 Ok(true)
1148 }
1149
1150 pub fn on_pause(&mut self, pause: Pause) {
1152 self.pause = pause;
1153 }
1154
1155 pub fn watch_interrupt(&mut self, pause: Pause) {
1161 self.interrupt = pause;
1162 }
1163
1164 fn settle_questions(&mut self) {
1184 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1185 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1186 }
1187 }
1188
1189 async fn finish_after_tally(&mut self) -> Result<()> {
1192 self.graft().await?;
1195 self.fold_losers().await?;
1196 self.sync_to_base().await?;
1201 if self.state.status == RunStatus::AlreadyInBase {
1202 return Ok(());
1203 }
1204 self.review_loop().await?;
1205 self.sync_to_base().await?;
1206 if self.state.status == RunStatus::AlreadyInBase {
1207 return Ok(());
1208 }
1209 self.gate().await?;
1210 self.merge().await?;
1211 self.state.save()?;
1212 Ok(())
1213 }
1214
1215 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1222 let result = match ensure_setup_config(&mut self.state, repo).await {
1223 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1224 Err(e) => Err(e),
1225 };
1226 if let Err(e) = result {
1227 git::worktree_remove(repo, wt).await.ok();
1228 if in_prep {
1229 self.rollback_prep(repo).await;
1230 }
1231 self.state.status = RunStatus::Blocked;
1232 self.state.event(
1233 "setup",
1234 format!("worktree setup failed in {}: {e:#}", wt.display()),
1235 );
1236 self.state.save()?;
1237 return Err(e);
1238 }
1239 Ok(())
1240 }
1241
1242 async fn rollback_prep(&mut self, repo: &Path) {
1244 let root = self.state.worktree_root();
1245 for c in std::mem::take(&mut self.state.candidates) {
1246 git::worktree_remove(repo, &c.worktree).await.ok();
1247 git::branch_delete(repo, &c.branch).await.ok();
1248 }
1249 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1250 for j in 1..=seats {
1251 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1252 .await
1253 .ok();
1254 }
1255 for k in 1..=self.state.config.graph.advisors {
1256 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1257 .await
1258 .ok();
1259 }
1260 }
1261
1262 async fn prep(&mut self) -> Result<()> {
1263 if !self.state.candidates.is_empty() {
1264 return Ok(());
1265 }
1266 self.state.status = RunStatus::Prep;
1267 let repo = self.state.repo.clone();
1268 let base = self.state.base_commit.clone();
1269 let plan = refs::plan(&repo, &self.state.seeds).await?;
1270 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1271 let root = self.state.worktree_root();
1272 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1273
1274 let hooks_dir = self.state.dir().join("hooks");
1277 if self.state.config.blind.commit_msg_hook {
1278 std::fs::create_dir_all(&hooks_dir)
1279 .with_context(|| format!("create {}", hooks_dir.display()))?;
1280 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1281 let path = hooks_dir.join("commit-msg");
1282 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1283 make_executable(&path)?;
1284 git::acquire_worktree_config(&repo).await?;
1292 self.state.enabled_worktree_config = true;
1293 }
1294
1295 ensure_setup_config(&mut self.state, &repo).await?;
1298
1299 for (index, (spec, label)) in self
1300 .roles
1301 .implementers
1302 .clone()
1303 .into_iter()
1304 .zip(labels)
1305 .enumerate()
1306 {
1307 let branch = self.state.branch_for(label);
1308 let worktree = root.join(format!("cand-{label}"));
1309 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1310 if self.state.config.blind.commit_msg_hook {
1311 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1312 }
1313 git::local_exclude(&worktree, "/.magi/").await?;
1314 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1315 git::worktree_remove(&repo, &worktree).await.ok();
1318 git::branch_delete(&repo, &branch).await.ok();
1319 self.rollback_prep(&repo).await;
1320 self.state.status = RunStatus::Blocked;
1321 self.state
1322 .event("prep", format!("worktree setup failed: {e:#}"));
1323 self.state.save()?;
1324 return Err(e);
1325 }
1326 for pick in &plan.picks {
1327 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1328 self.state.status = RunStatus::Blocked;
1329 self.state
1330 .event("prep", format!("cannot apply referenced commit: {e}"));
1331 self.state.save()?;
1332 return Err(e);
1333 }
1334 }
1335 self.state.candidates.push(Candidate {
1336 index,
1337 label,
1338 agent: spec.id.clone(),
1339 branch,
1340 worktree,
1341 summary: String::new(),
1342 stat: String::new(),
1343 files: 0,
1344 commits: 0,
1345 empty: false,
1346 failed: None,
1347 verified_noop: None,
1348 duration_ms: 0,
1349 folded: false,
1350 });
1351 }
1352
1353 for j in 1..=self.roles.judges.len() {
1354 let wt = root.join(format!("judge-{j}"));
1355 if !wt.exists() {
1356 git::worktree_add_detached(&repo, &wt, &base).await?;
1357 self.setup_seat_worktree(&repo, &wt, true).await?;
1358 }
1359 }
1360
1361 if self.state.config.graph.advise {
1369 for k in 1..=self.state.config.graph.advisors {
1370 let wt = root.join(format!("advisor-{k}"));
1371 if !wt.exists() {
1372 git::worktree_add_detached(&repo, &wt, &base).await?;
1373 self.setup_seat_worktree(&repo, &wt, true).await?;
1374 }
1375 }
1376 }
1377
1378 let authors: Vec<&str> = self
1383 .roles
1384 .implementers
1385 .iter()
1386 .map(|a| a.id.as_str())
1387 .collect();
1388 let overlap: Vec<String> = self
1389 .roles
1390 .judges
1391 .iter()
1392 .enumerate()
1393 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1394 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1395 .collect();
1396 if !overlap.is_empty() {
1397 let note = format!(
1398 "{} also authored a candidate; blind, but the panel is less \
1399 independent than {} distinct agents would be",
1400 overlap.join(", "),
1401 self.roles.judges.len()
1402 );
1403 self.state.event("prep", note);
1404 }
1405
1406 self.state.event(
1407 "prep",
1408 format!(
1409 "{} candidates, {} judges, base {} ({})",
1410 self.state.candidates.len(),
1411 self.roles.judges.len(),
1412 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1413 self.state.base_branch
1414 ),
1415 );
1416 self.state.status = RunStatus::Implementing;
1417 self.state.save()?;
1418 Ok(())
1419 }
1420
1421 async fn advise(&mut self) -> Result<()> {
1454 let implement_untouched = self
1455 .state
1456 .candidates
1457 .iter()
1458 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1459 if !self.state.config.graph.advise || self.state.advise_attempted {
1460 return Ok(());
1461 }
1462 if !implement_untouched {
1463 self.state.event(
1464 "advise",
1465 "skipping the design-deliberation stage: at least one \
1466 candidate already shows implementation progress, so this \
1467 run is past the point the stage exists to run before"
1468 .to_owned(),
1469 );
1470 self.state.advise_attempted = true;
1471 self.state.save()?;
1472 return Ok(());
1473 }
1474 let run_id = self.state.id.clone();
1475 let prompts = self.state.config.prompts.clone();
1476 let instruction = self.state.instruction.clone();
1477 let language = self.state.config.graph.language.clone();
1478 let root = self.state.worktree_root();
1479 let n = self.state.config.graph.advisors;
1480 let where_recorded = self.state.dir().join("run.json");
1481
1482 let seats = match self.state.config.advisors() {
1483 Ok(seats) if !seats.is_empty() => seats,
1484 Ok(_) => {
1485 self.state.event(
1486 "advise",
1487 format!(
1488 "[graph] advisors is 0; skipping the design-deliberation \
1489 stage and continuing without a synthesis brief (see {})",
1490 where_recorded.display()
1491 ),
1492 );
1493 self.state.advise_attempted = true;
1494 self.state.save()?;
1495 return Ok(());
1496 }
1497 Err(e) => {
1498 self.state.event(
1499 "advise",
1500 format!(
1501 "could not resolve advisor seats ({e:#}); continuing \
1502 without a design-deliberation brief (see {})",
1503 where_recorded.display()
1504 ),
1505 );
1506 self.state.advise_attempted = true;
1507 self.state.save()?;
1508 return Ok(());
1509 }
1510 };
1511
1512 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1513 let artifacts = agent::artifacts_dir(&self.state.dir());
1514 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1515
1516 let mut jobs = Vec::new();
1517 for (i, spec) in seats.iter().cloned().enumerate() {
1518 let seat_key = format!("advisor-{}", i + 1);
1519 let seat = self.seat(&seat_key, &spec.id);
1520 jobs.push(SeatJob {
1521 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1522 spec,
1523 seat,
1524 cwd: worktrees[i % worktrees.len()].clone(),
1525 timeout,
1526 allow_write: false,
1527 sessions: false,
1528 artifacts: artifacts.clone(),
1529 stem: seat_key,
1530 handover: None,
1531 });
1532 }
1533
1534 self.state.event(
1535 "advise",
1536 format!(
1537 "{} advisor seat(s) sketching a design in parallel",
1538 jobs.len()
1539 ),
1540 );
1541 let mut quota_losses = Vec::new();
1542 let cache = self.state.config.cache_dir();
1543 let ctx = WaveCtx {
1544 carry_seats: false,
1545 run: &run_id,
1546 node: "advise",
1547 prompts: &prompts,
1548 cache: cache.as_deref(),
1549 round: None,
1550 };
1551 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1552 let results = ask_json_wave::<Proposal>(
1553 jobs,
1554 Arc::clone(&self.sem),
1555 self.state.config.graph.retries,
1556 &advisor_roster,
1557 &ctx,
1558 &mut quota_losses,
1559 &mut self.state,
1560 &|p: &Proposal| p.validate(),
1561 )
1562 .await;
1563 self.state.quota.extend(quota_losses);
1564
1565 let mut records = Vec::with_capacity(results.len());
1566 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1567 let agent_id = seat.agent.clone();
1568 self.state.seats.insert(seat.key.clone(), seat);
1569 match res {
1570 Ok((proposal, out)) => {
1571 self.state
1572 .event("advise", format!("advisor-{} proposed a design", i + 1));
1573 records.push(advise::AdvisorRecord::proposed(
1574 i + 1,
1575 agent_id,
1576 proposal,
1577 out.duration_ms,
1578 ));
1579 }
1580 Err(e) => {
1581 self.state.event(
1582 "advise",
1583 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1584 );
1585 records.push(advise::AdvisorRecord::failed(
1586 i + 1,
1587 agent_id,
1588 e.to_string(),
1589 ));
1590 }
1591 }
1592 }
1593
1594 let mut advice = advise::Advice {
1595 records,
1596 synthesis: None,
1597 };
1598 if advice.proposals().is_empty() {
1599 self.state.event(
1600 "advise",
1601 "no advisor produced a usable proposal; continuing without a \
1602 synthesis brief"
1603 .to_owned(),
1604 );
1605 } else {
1606 match self
1607 .synthesize_brief(
1608 &advice,
1609 &instruction,
1610 &language,
1611 &worktrees[0],
1612 &artifacts,
1613 &run_id,
1614 &prompts,
1615 cache.as_deref(),
1616 )
1617 .await
1618 {
1619 Ok(Some(text)) => {
1620 self.state.event(
1621 "advise",
1622 "synthesized a design brief for the implementer".to_owned(),
1623 );
1624 advice.synthesis = Some(text);
1625 }
1626 Ok(None) => {
1627 self.state.event(
1628 "advise",
1629 "the synthesis seat produced nothing usable; continuing \
1630 without a design brief"
1631 .to_owned(),
1632 );
1633 }
1634 Err(e) => {
1635 self.state.event(
1636 "advise",
1637 format!("could not synthesize a design brief: {e:#}"),
1638 );
1639 }
1640 }
1641 }
1642 advise::apply_reflection(&mut advice);
1643
1644 self.state.advice = Some(advice);
1645 self.state.advise_attempted = true;
1646 self.state.save()?;
1647 Ok(())
1648 }
1649
1650 #[allow(clippy::too_many_arguments)]
1662 async fn synthesize_brief(
1663 &mut self,
1664 advice: &advise::Advice,
1665 instruction: &str,
1666 language: &str,
1667 cwd: &Path,
1668 artifacts: &Path,
1669 run_id: &str,
1670 prompts: &Prompts,
1671 cache: Option<&Path>,
1672 ) -> Result<Option<String>> {
1673 let chain = agent::pick_chain(
1674 &self.state.config.agents,
1675 self.state.config.roles.synthesizer.as_ref(),
1676 &agent::installed,
1677 "synthesizer",
1678 )?;
1679 let proposals = advice.proposals();
1680 let mut prompt = prompt::with_overlay(
1681 prompt::synthesize_brief(instruction, &proposals, language),
1682 prompts.overlay("advise"),
1683 );
1684 if cache.is_some() {
1685 prompt.push('\n');
1690 prompt.push_str(&prompt::build_cache_note("advise", false));
1691 }
1692 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1693 let mut last = None;
1698 for (n, spec) in chain.iter().enumerate() {
1699 if n > 0 {
1700 self.state
1701 .event("advise", format!("synthesis falling back to {}", spec.id));
1702 }
1703 let mut seat = self.seat("advise-synthesis", &spec.id);
1704 let outcome = agent::invoke(
1705 spec,
1706 &mut seat,
1707 &Invocation {
1708 cwd,
1709 prompt: &prompt,
1710 timeout,
1711 allow_write: false,
1712 unsandboxed: false,
1713 sessions: false,
1714 artifacts,
1715 stem: &if n == 0 {
1716 "advise-synthesis".to_owned()
1717 } else {
1718 format!("advise-synthesis-{}", spec.id)
1719 },
1720 run: run_id,
1721 node: "advise",
1722 cache_dir: None,
1723 attachments: &[],
1724 writable: &[],
1725 },
1726 )
1727 .await;
1728 if outcome.is_ok() {
1729 self.state.seats.insert(seat.key.clone(), seat);
1730 }
1731 let advance = agent::chain_advances(&outcome);
1732 last = Some(outcome);
1733 if !advance {
1734 break;
1735 }
1736 }
1737 let out = last.expect("a chain holds at least one agent")?;
1740 if !out.usable() {
1741 return Ok(None);
1742 }
1743 let text =
1744 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1745 Ok((!text.trim().is_empty()).then_some(text))
1746 }
1747
1748 async fn implement(&mut self) -> Result<()> {
1751 let run_id = self.state.id.clone();
1756 let prompts = self.state.config.prompts.clone();
1757 let todo: Vec<usize> = self
1758 .state
1759 .candidates
1760 .iter()
1761 .enumerate()
1762 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1763 .map(|(i, _)| i)
1764 .collect();
1765 if todo.is_empty() {
1766 return self.after_implement();
1767 }
1768 self.state.status = RunStatus::Implementing;
1769
1770 let language = self.state.config.graph.language.clone();
1771 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1772 let sessions = self.state.config.graph.sessions;
1773 let artifacts = agent::artifacts_dir(&self.state.dir());
1774 let brief = self
1778 .state
1779 .advice
1780 .as_ref()
1781 .and_then(|a| a.synthesis.as_deref())
1782 .map(str::to_owned);
1783 let attachments = self.state.attachments.clone();
1784
1785 let mut jobs = Vec::new();
1786 for &i in &todo {
1787 let (index, label, worktree) = {
1788 let c = &self.state.candidates[i];
1789 (c.index, c.label, c.worktree.clone())
1790 };
1791 let spec = self.roles.implementers[index].clone();
1792 let seat_key = format!("impl-{label}");
1793 let seat = self.seat(&seat_key, &spec.id);
1794 let instruction = seeded_instruction(&self.state);
1795 jobs.push(SeatJob {
1796 spec,
1797 seat,
1798 prompt: prompt::implement(
1799 &instruction,
1800 &worktree.to_string_lossy(),
1801 &language,
1802 brief.as_deref(),
1803 &attachments,
1804 ),
1805 cwd: worktree,
1806 timeout,
1807 allow_write: true,
1808 sessions,
1809 artifacts: artifacts.clone(),
1810 stem: format!("impl-{label}"),
1811 handover: None,
1812 });
1813 }
1814
1815 self.state.event(
1816 "implement",
1817 format!("{} candidates in parallel", jobs.len()),
1818 );
1819 let mut sent = jobs.clone();
1825 let cache = self.state.config.cache_dir();
1826 let ctx = WaveCtx {
1827 carry_seats: false,
1828 run: &run_id,
1829 node: "implement",
1830 prompts: &prompts,
1831 cache: cache.as_deref(),
1832 round: None,
1833 };
1834 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1835 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1836 .await;
1837 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1838 .await;
1839 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1840 .await;
1841
1842 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1843 let seat_key = seat.key.clone();
1844 let agent = seat.agent.clone();
1853 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1854 self.state.seats.insert(seat.key.clone(), seat);
1855 let label = self.state.candidates[i].label;
1856 let worktree = self.state.candidates[i].worktree.clone();
1857 let base = self.state.base_commit.clone();
1858
1859 let (summary, duration, failed, verified_claim) = match out {
1860 AgentOutcome::Ok(o) => {
1861 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1862 let failed = (!o.usable()).then(|| {
1863 if o.timed_out {
1864 "agent timed out".to_owned()
1865 } else {
1866 format!("agent exited with {:?}", o.exit_code)
1867 }
1868 });
1869 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1870 (text, o.duration_ms, failed, verified_claim)
1871 }
1872 AgentOutcome::Dropped(o) => {
1878 let why = o
1879 .dropped
1880 .as_ref()
1881 .map(|d| d.why.as_str())
1882 .unwrap_or("the CLI ended the stream without delivering its answer");
1883 (
1884 String::new(),
1885 o.duration_ms,
1886 Some(format!("the CLI dropped the stream ({why})")),
1887 None,
1888 )
1889 }
1890 AgentOutcome::Quota(o) => {
1891 self.state.quota.push(QuotaLoss {
1892 seat: seat_key,
1893 node: "implement".to_owned(),
1894 at: Timestamp::now(),
1895 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1896 });
1897 (
1898 String::new(),
1899 o.duration_ms,
1900 Some("rate limited (quota); produced no change".to_owned()),
1901 None,
1902 )
1903 }
1904 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1905 };
1906
1907 let rescued = match git::rescue_commit(
1910 &worktree,
1911 &format!("magi: candidate {label} (uncommitted work)"),
1912 )
1913 .await
1914 {
1915 Ok(r) => {
1916 self.state.note_withheld("implement", &r.withheld);
1917 r.committed
1918 }
1919 Err(_) => false,
1920 };
1921 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1922 .await
1923 .unwrap_or(0);
1924 let patch = git::diff(&worktree, &base, "HEAD")
1925 .await
1926 .unwrap_or_default();
1927 let stat = git::diff_stat(&worktree, &base, "HEAD")
1928 .await
1929 .unwrap_or_default();
1930 let files = git::changed_files(&worktree, &base, "HEAD")
1931 .await
1932 .map(|f| f.len())
1933 .unwrap_or(0);
1934 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1935
1936 let c = &mut self.state.candidates[i];
1937 if !exhausted_the_fallback_chain {
1938 c.agent = agent;
1939 }
1940 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1941 c.stat = stat;
1942 c.files = files;
1943 c.commits = commits;
1944 c.duration_ms = duration;
1945 c.empty = commits == 0 || patch.trim().is_empty();
1946 c.failed = match failed {
1949 Some(_) if c.empty => failed,
1950 _ => None,
1951 };
1952 c.verified_noop = if c.empty { verified_claim } else { None };
1957 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1958 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1959 (None, true, Some(_), _) => {
1960 format!("candidate {label}: no change produced (agent-verified no-op)")
1961 }
1962 (None, true, None, _) => format!("candidate {label}: no change produced"),
1963 (None, false, _, true) => {
1964 format!(
1965 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1966 )
1967 }
1968 (None, false, _, false) => {
1969 format!("candidate {label}: {files} files, {commits} commits")
1970 }
1971 };
1972 self.state.event("implement", note);
1973 self.state.save()?;
1974 }
1975
1976 self.after_implement()
1977 }
1978
1979 async fn resume_undelivered(
2007 &mut self,
2008 results: &mut [(usize, SeatState, AgentOutcome)],
2009 sent: &[SeatJob],
2010 prompts: &Prompts,
2011 run_id: &str,
2012 ) {
2013 for (wi, seat, out) in results.iter_mut() {
2014 let Some(dropped) = (match &*out {
2015 AgentOutcome::Dropped(o) => o.dropped.clone(),
2016 _ => None,
2017 }) else {
2018 continue;
2019 };
2020 let Some(job) = sent.get(*wi) else { continue };
2021 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2023 self.state.event(
2024 "implement",
2025 format!(
2026 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2027 work is in the tree",
2028 seat.key, dropped.output_tokens, dropped.why
2029 ),
2030 );
2031 continue;
2032 }
2033 if !has_context(&job.spec, seat, job.sessions) {
2041 self.state.event(
2042 "implement",
2043 format!(
2044 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2045 is no session left to resume",
2046 seat.key, dropped.output_tokens, dropped.why
2047 ),
2048 );
2049 continue;
2050 }
2051 self.state.event(
2052 "implement",
2053 format!(
2054 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2055 conversation",
2056 seat.key, dropped.output_tokens, dropped.why
2057 ),
2058 );
2059 let mut retry = job.clone();
2060 retry.seat = seat.clone();
2061 retry.prompt = prompt::resume_after_drop(&dropped.why);
2062 retry.timeout = retry_budget(job.timeout, true);
2063 retry.stem = format!("{}-resume", job.stem);
2064 let cache = self.state.config.cache_dir();
2065 let ctx = WaveCtx {
2066 carry_seats: false,
2067 run: run_id,
2068 node: "implement",
2069 prompts,
2070 cache: cache.as_deref(),
2071 round: None,
2072 };
2073 let (resumed_seat, resumed) =
2074 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2075 *seat = resumed_seat;
2076 *out = resumed;
2077 }
2078 }
2079
2080 async fn resume_seat_handovers(
2145 &mut self,
2146 results: &mut [(usize, SeatState, AgentOutcome)],
2147 sent: &mut [SeatJob],
2148 prompts: &Prompts,
2149 run_id: &str,
2150 ) {
2151 let instruction = seeded_instruction(&self.state);
2152 let language = self.state.config.graph.language.clone();
2153 let brief = self
2154 .state
2155 .advice
2156 .as_ref()
2157 .and_then(|a| a.synthesis.as_deref())
2158 .map(str::to_owned);
2159 let attachments = self.state.attachments.clone();
2160 for (wi, seat, out) in results.iter_mut() {
2161 let others: BTreeSet<String> = sent
2164 .iter()
2165 .enumerate()
2166 .filter(|(j, _)| j != wi)
2167 .map(|(_, j)| j.spec.id.clone())
2168 .collect();
2169 let Some(job) = sent.get_mut(*wi) else {
2170 continue;
2171 };
2172 let start = self
2177 .roles
2178 .implementer_roster
2179 .iter()
2180 .position(|s| s.id == job.spec.id)
2181 .unwrap_or(0);
2182 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2183 let mut fallback_attempt = 0usize;
2184 let mut prev: Option<FailClass> = None;
2185 while let Some(cur) = FailClass::of(&*out) {
2186 if !should_hand_over(prev.as_ref(), &cur) {
2187 break;
2188 }
2189 let Some(next) =
2190 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2191 .cloned()
2192 else {
2193 break;
2194 };
2195 tried.insert(next.id.clone());
2196 fallback_attempt += 1;
2197
2198 if let Ok(r) = git::rescue_commit(
2199 &job.cwd,
2200 &format!(
2201 "magi: candidate {} (uncommitted work before {} fallback)",
2202 seat.key,
2203 if cur == FailClass::Quota {
2204 "quota"
2205 } else {
2206 "handover"
2207 }
2208 ),
2209 )
2210 .await
2211 {
2212 self.state.note_withheld("implement", &r.withheld);
2213 }
2214
2215 record_handover(
2216 &mut self.state,
2217 "implement",
2218 &seat.key,
2219 &seat.agent,
2220 &next.id,
2221 &cur,
2222 &fail_reason(&*out),
2223 );
2224 prev = Some(cur.clone());
2225
2226 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2227 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2228 job.spec = next.clone();
2236 let mut retry = job.clone();
2237 retry.seat = new_seat;
2238 retry.prompt = prompt::implement(
2239 &instruction,
2240 &job.cwd.to_string_lossy(),
2241 &language,
2242 brief.as_deref(),
2243 &attachments,
2244 );
2245 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2246 let cache = self.state.config.cache_dir();
2247 let ctx = WaveCtx {
2248 carry_seats: false,
2249 run: run_id,
2250 node: "implement",
2251 prompts,
2252 cache: cache.as_deref(),
2253 round: None,
2254 };
2255 let (fallback_seat, fallback_out) = run_one(
2256 retry,
2257 Arc::clone(&self.sem),
2258 &ctx,
2259 &mut self.state,
2260 fallback_attempt,
2261 )
2262 .await;
2263 *seat = fallback_seat;
2264 *out = fallback_out;
2265 }
2266 }
2267 }
2268
2269 async fn resume_unconfirmed_commands(
2293 &mut self,
2294 results: &mut [(usize, SeatState, AgentOutcome)],
2295 sent: &[SeatJob],
2296 prompts: &Prompts,
2297 run_id: &str,
2298 ) {
2299 for (wi, seat, out) in results.iter_mut() {
2300 let AgentOutcome::Ok(o) = &*out else {
2301 continue;
2302 };
2303 if !has_unconfirmed_command(&o.commands) {
2304 continue;
2305 }
2306 let Some(job) = sent.get(*wi) else { continue };
2307 if !has_context(&job.spec, seat, job.sessions) {
2308 self.state.event(
2309 "implement",
2310 format!(
2311 "{}: the reply named a command whose own CLI never confirmed the exit \
2312 status of, but there is no session left to resume",
2313 seat.key
2314 ),
2315 );
2316 continue;
2317 }
2318 self.state.event(
2319 "implement",
2320 format!(
2321 "{}: the reply named a command whose own CLI never confirmed the exit \
2322 status of; resuming the conversation",
2323 seat.key
2324 ),
2325 );
2326 let mut retry = job.clone();
2327 retry.seat = seat.clone();
2328 retry.prompt = prompt::resume_incomplete(
2329 "a command in your last reply had no confirmed exit status",
2330 );
2331 retry.timeout = retry_budget(job.timeout, true);
2332 retry.stem = format!("{}-confirm", job.stem);
2333 let cache = self.state.config.cache_dir();
2334 let ctx = WaveCtx {
2335 carry_seats: false,
2336 run: run_id,
2337 node: "implement",
2338 prompts,
2339 cache: cache.as_deref(),
2340 round: None,
2341 };
2342 let (resumed_seat, resumed) =
2343 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2344 *seat = resumed_seat;
2345 *out = resumed;
2346 }
2347 }
2348
2349 async fn continue_fix_report(
2370 &mut self,
2371 mut seat: SeatState,
2372 parse_err: String,
2373 job: &SeatJob,
2374 prompts: &Prompts,
2375 run_id: &str,
2376 round: usize,
2377 ) -> (
2378 SeatState,
2379 Option<FixReport>,
2380 Option<String>,
2381 ContinuationRecord,
2382 ) {
2383 let mut last_err = parse_err;
2384 let mut cumulative_wait_ms = 0u64;
2385 let mut attempts = 0usize;
2386 loop {
2387 if !has_context(&job.spec, &seat, job.sessions) {
2388 self.state.event(
2389 "fix",
2390 format!(
2391 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2392 session left to resume into"
2393 ),
2394 );
2395 let outcome = if attempts == 0 {
2396 ContinuationOutcome::NoSession
2397 } else {
2398 ContinuationOutcome::Exhausted
2399 };
2400 return (
2401 seat,
2402 None,
2403 Some(format!("unparsable fix report: {last_err}")),
2404 ContinuationRecord {
2405 attempts,
2406 cumulative_wait_ms,
2407 outcome,
2408 },
2409 );
2410 }
2411 if attempts >= MAX_FIX_CONTINUATIONS {
2412 self.state.event(
2413 "fix",
2414 format!(
2415 "round {round}: fixer's reply still had no adoption report after \
2416 {attempts} continuation(s) ({last_err}); giving up"
2417 ),
2418 );
2419 return (
2420 seat,
2421 None,
2422 Some(format!(
2423 "unparsable fix report after {attempts} continuation(s): {last_err}"
2424 )),
2425 ContinuationRecord {
2426 attempts,
2427 cumulative_wait_ms,
2428 outcome: ContinuationOutcome::Exhausted,
2429 },
2430 );
2431 }
2432 attempts += 1;
2433 self.state.event(
2434 "fix",
2435 format!(
2436 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2437 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2438 ),
2439 );
2440 let mut retry = job.clone();
2441 retry.seat = seat.clone();
2442 retry.prompt = prompt::resume_incomplete(&last_err);
2443 retry.timeout = retry_budget(job.timeout, true);
2444 retry.stem = format!("{}-continue{attempts}", job.stem);
2445 let cache = self.state.config.cache_dir();
2446 let ctx = WaveCtx {
2447 carry_seats: false,
2448 run: run_id,
2449 node: "fix",
2450 prompts,
2451 cache: cache.as_deref(),
2452 round: Some(round),
2453 };
2454 let (resumed_seat, resumed_out) = run_one(
2455 retry,
2456 Arc::clone(&self.sem),
2457 &ctx,
2458 &mut self.state,
2459 attempts,
2460 )
2461 .await;
2462 seat = resumed_seat;
2463 match resumed_out {
2464 AgentOutcome::Ok(o) => {
2465 cumulative_wait_ms += o.duration_ms;
2466 match verdict::extract_json::<FixReport>(&o.text) {
2467 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2468 self.state.event(
2469 "fix",
2470 format!(
2471 "round {round}: fixer's adoption report recovered after \
2472 {attempts} continuation(s)"
2473 ),
2474 );
2475 return (
2476 seat,
2477 Some(report),
2478 None,
2479 ContinuationRecord {
2480 attempts,
2481 cumulative_wait_ms,
2482 outcome: ContinuationOutcome::Resumed,
2483 },
2484 );
2485 }
2486 Ok(_) => {
2494 last_err = "the reply parsed, but it reported a command whose own CLI \
2495 never confirmed an exit status"
2496 .to_owned();
2497 }
2498 Err(e) => last_err = e.to_string(),
2499 }
2500 }
2501 AgentOutcome::Quota(o) => {
2502 cumulative_wait_ms += o.duration_ms;
2503 self.state.quota.push(QuotaLoss {
2504 seat: seat.key.clone(),
2505 node: "fix".to_owned(),
2506 at: Timestamp::now(),
2507 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2508 });
2509 self.state.event(
2510 "fix",
2511 format!(
2512 "round {round}: continuation rate limited (quota); not retrying now"
2513 ),
2514 );
2515 return (
2516 seat,
2517 None,
2518 Some("rate limited (quota) while recovering the fix report".to_owned()),
2519 ContinuationRecord {
2520 attempts,
2521 cumulative_wait_ms,
2522 outcome: ContinuationOutcome::QuotaLost,
2523 },
2524 );
2525 }
2526 AgentOutcome::Dropped(o) => {
2527 cumulative_wait_ms += o.duration_ms;
2528 let why = o
2529 .dropped
2530 .as_ref()
2531 .map(|d| d.why.as_str())
2532 .unwrap_or("the CLI ended the stream without delivering its answer");
2533 last_err = format!("the CLI dropped the stream ({why})");
2534 }
2535 AgentOutcome::Failed(e) => last_err = e,
2536 }
2537 }
2538 }
2539
2540 fn after_implement(&mut self) -> Result<()> {
2541 if self.state.leaks.is_empty() {
2543 let cfg = self.state.config.blind.clone();
2544 let mut leaks = Vec::new();
2545 for c in &self.state.candidates {
2546 let Some(patch) =
2547 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2548 else {
2549 continue;
2550 };
2551 leaks.extend(blind::scan(
2552 &format!("candidate {} patch", c.label),
2553 &patch,
2554 &cfg.vendor_tokens,
2555 ));
2556 }
2557 if !leaks.is_empty() {
2558 let summary = leaks
2559 .iter()
2560 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2561 .collect::<Vec<_>>()
2562 .join(", ");
2563 match cfg.on_leak {
2564 LeakPolicy::Fail => {
2565 self.state.status = RunStatus::Failed;
2566 self.state
2567 .event("blind", format!("vendor text in a patch: {summary}"));
2568 self.state.leaks = leaks;
2569 self.state.save()?;
2570 self.settle_questions();
2571 bail!(
2572 "blind.on_leak = \"fail\" and vendor text reached a \
2573 judged patch: {summary}"
2574 );
2575 }
2576 LeakPolicy::Redact => self.state.event(
2577 "blind",
2578 format!("redacting vendor text for judging: {summary}"),
2579 ),
2580 LeakPolicy::Warn => self.state.event(
2581 "blind",
2582 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2583 ),
2584 }
2585 self.state.leaks = leaks;
2586 }
2587 }
2588
2589 if self.state.viable().is_empty() {
2590 if self.state.all_candidates_verified_noop() {
2591 self.state.status = RunStatus::VerifiedNoop;
2602 self.state.save()?;
2603 self.settle_questions();
2604 return Ok(());
2605 }
2606 self.state.status = RunStatus::Failed;
2607 self.state.save()?;
2608 self.settle_questions();
2609 bail!("no candidate produced a change; nothing to judge");
2610 }
2611 self.state.status = RunStatus::Judging;
2612 self.state.save()?;
2613 Ok(())
2614 }
2615
2616 async fn judge(&mut self) -> Result<()> {
2619 let run_id = self.state.id.clone();
2624 let prompts = self.state.config.prompts.clone();
2625 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2626 return Ok(());
2627 }
2628 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2629 if viable.len() == 1 {
2630 self.state.judge_skipped = true;
2637 self.state.event(
2638 "judge",
2639 format!(
2640 "only candidate {} produced a change; judging skipped",
2641 viable[0].label
2642 ),
2643 );
2644 self.state.save()?;
2645 return Ok(());
2646 }
2647 self.state.status = RunStatus::Judging;
2648
2649 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2650 let language = self.state.config.graph.language.clone();
2651 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2652 let sessions = self.state.config.graph.sessions;
2653 let artifacts = agent::artifacts_dir(&self.state.dir());
2654 let root = self.state.worktree_root();
2655 let base_short = short(&self.state.base_commit);
2656
2657 let mut jobs = Vec::new();
2658 let mut orders = Vec::new();
2659 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2660 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2661 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2662 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2663 let seat_key = format!("judge-{}", j + 1);
2664 let seat = self.seat(&seat_key, &spec.id);
2665 jobs.push(SeatJob {
2666 prompt: prompt::judge(
2667 &self.state.instruction,
2668 &views,
2669 self.roles.judges.len(),
2670 &base_short,
2671 &language,
2672 ),
2673 spec,
2674 seat,
2675 cwd: root.join(format!("judge-{}", j + 1)),
2676 timeout,
2677 allow_write: false,
2678 sessions,
2679 artifacts: artifacts.clone(),
2680 stem: format!("judge-{}", j + 1),
2681 handover: None,
2682 });
2683 }
2684
2685 self.state.event(
2686 "judge",
2687 format!(
2688 "{} judges ranking {} candidates blind",
2689 jobs.len(),
2690 viable.len()
2691 ),
2692 );
2693 let labels_for_check = labels.clone();
2694 let mut quota_losses = Vec::new();
2695 let cache = self.state.config.cache_dir();
2696 let ctx = WaveCtx {
2697 carry_seats: false,
2698 run: &run_id,
2699 node: "judge",
2700 prompts: &prompts,
2701 cache: cache.as_deref(),
2702 round: None,
2703 };
2704 let results = ask_json_wave::<Ranking>(
2705 jobs,
2706 Arc::clone(&self.sem),
2707 self.state.config.graph.retries,
2708 &self.roles.judge_roster,
2709 &ctx,
2710 &mut quota_losses,
2711 &mut self.state,
2712 &move |r: &Ranking| r.validate(&labels_for_check),
2713 )
2714 .await;
2715 self.state.quota.extend(quota_losses);
2716
2717 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2718 let agent_id = seat.agent.clone();
2719 self.state.seats.insert(seat.key.clone(), seat);
2720 let mut record = Judgement {
2721 judge: j + 1,
2722 seat: format!("judge-{}", j + 1),
2723 agent: agent_id,
2724 ranking: Vec::new(),
2725 reasons: BTreeMap::new(),
2726 confidence: None,
2727 strengths: BTreeMap::new(),
2728 order: orders[j].clone(),
2729 failed: None,
2730 duration_ms: 0,
2731 };
2732 match res {
2733 Ok((ranking, out)) => {
2734 record.ranking = ranking.normalized();
2735 record.reasons = ranking.reasons;
2736 record.strengths = ranking.strengths;
2737 record.confidence = ranking.confidence;
2738 record.duration_ms = out.duration_ms;
2739 self.state.event(
2740 "judge",
2741 format!(
2742 "judge {} ranked {}",
2743 j + 1,
2744 record.ranking.iter().collect::<String>()
2745 ),
2746 );
2747 }
2748 Err(e) => {
2749 record.failed = Some(e.to_string());
2750 self.state
2751 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2752 }
2753 }
2754 self.state.judgements.push(record);
2755 self.state.save()?;
2756 }
2757 Ok(())
2758 }
2759
2760 async fn deliberate(&mut self) -> Result<()> {
2763 let run_id = self.state.id.clone();
2768 let prompts = self.state.config.prompts.clone();
2769 if !self.state.deliberation.is_empty() {
2770 return Ok(());
2771 }
2772 let tops: Vec<char> = self
2773 .state
2774 .judgements
2775 .iter()
2776 .filter_map(|j| j.ranking.first().copied())
2777 .collect();
2778 let rounds = self.state.config.graph.deliberate_rounds;
2779 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2780 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2781 self.state.event(
2782 "deliberate",
2783 format!("judges agreed on {} outright; no deliberation", tops[0]),
2784 );
2785 }
2786 self.state.status = RunStatus::Voting;
2787 self.state.save()?;
2788 return Ok(());
2789 }
2790
2791 self.state.status = RunStatus::Deliberating;
2792 self.state.event(
2793 "deliberate",
2794 format!(
2795 "split: first choices were {} — opening {rounds} round(s)",
2796 tops.iter().collect::<String>()
2797 ),
2798 );
2799
2800 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2801 let language = self.state.config.graph.language.clone();
2802 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2803 let sessions = self.state.config.graph.sessions;
2804 let artifacts = agent::artifacts_dir(&self.state.dir());
2805 let root = self.state.worktree_root();
2806 let base_short = short(&self.state.base_commit);
2807
2808 for round in 1..=rounds {
2812 let mut turns: Vec<DeliberationTurn> = Vec::new();
2813 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2814 if self.state.judgements[j].failed.is_some() {
2815 continue;
2816 }
2817 let seat_key = format!("judge-{}", j + 1);
2818 let spec = self.occupant(&seat_key, spec);
2819 let mut seat = self.seat(&seat_key, &spec.id);
2820 let transcript = self.transcript(&turns, j);
2821 let build = |context: Option<&str>| {
2822 prompt::deliberate(
2823 &self.state.instruction,
2824 context,
2825 &transcript,
2826 round,
2827 rounds,
2828 &language,
2829 )
2830 };
2831 let block = self.candidate_block(&viable, &base_short);
2832 let full = build(Some(&block));
2833 let text = if has_context(&spec, &seat, sessions) {
2834 build(None)
2835 } else {
2836 full.clone()
2837 };
2838 let job = SeatJob {
2839 spec,
2840 seat: seat.clone(),
2841 prompt: text,
2842 cwd: root.join(format!("judge-{}", j + 1)),
2843 timeout,
2844 allow_write: false,
2845 sessions,
2846 artifacts: artifacts.clone(),
2847 stem: format!("delib-{round}-judge-{}", j + 1),
2848 handover: Some(full),
2849 };
2850 let cache = self.state.config.cache_dir();
2851 let ctx = WaveCtx {
2852 carry_seats: false,
2853 run: &run_id,
2854 node: "deliberate",
2855 prompts: &prompts,
2856 cache: cache.as_deref(),
2857 round: None,
2858 };
2859 let mut losses = Vec::new();
2863 let mut results = ask_wave_with::<String>(
2864 vec![job],
2865 Arc::clone(&self.sem),
2866 0,
2867 &self.roles.judge_roster,
2868 &ctx,
2869 &mut losses,
2870 &mut self.state,
2871 &|text: &str| {
2872 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2873 },
2874 )
2875 .await;
2876 self.state.quota.extend(losses);
2877 let (updated, res, _) = results.pop().expect("one job in, one result out");
2878 seat = updated;
2879 let agent_id = seat.agent.clone();
2880 self.state.seats.insert(seat.key.clone(), seat);
2881 let body = match res {
2882 Ok((body, _)) => body,
2883 Err(e) => {
2886 self.state
2887 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2888 continue;
2889 }
2890 };
2891 let tentative = verdict::extract_json::<Position>(&body)
2892 .ok()
2893 .and_then(|p| p.tentative)
2894 .and_then(|s| s.trim().chars().next())
2895 .map(|c| c.to_ascii_uppercase());
2896 self.state.event(
2897 "deliberate",
2898 format!(
2899 "round {round}: judge {} now favours {}",
2900 j + 1,
2901 tentative.map_or("—".to_owned(), |c| c.to_string())
2902 ),
2903 );
2904 turns.push(DeliberationTurn {
2905 judge: j + 1,
2906 agent: agent_id,
2907 body: blind::sanitize_prose(&body, &self.state.config.blind),
2908 tentative,
2909 });
2910 }
2911 self.state
2912 .deliberation
2913 .push(DeliberationRound { round, turns });
2914 self.state.save()?;
2915 }
2916
2917 self.state.status = RunStatus::Voting;
2918 self.state.save()?;
2919 Ok(())
2920 }
2921
2922 async fn vote(&mut self) -> Result<()> {
2925 let run_id = self.state.id.clone();
2930 let prompts = self.state.config.prompts.clone();
2931 if !self.state.votes.is_empty() {
2932 return Ok(());
2933 }
2934 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2935 if viable.len() == 1 {
2936 return Ok(());
2937 }
2938 self.state.status = RunStatus::Voting;
2939
2940 let language = self.state.config.graph.language.clone();
2941 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2942 let sessions = self.state.config.graph.sessions;
2943 let artifacts = agent::artifacts_dir(&self.state.dir());
2944 let root = self.state.worktree_root();
2945 let base_short = short(&self.state.base_commit);
2946 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2947
2948 let mut jobs = Vec::new();
2949 let mut seats_at = Vec::new();
2950 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2951 if self
2952 .state
2953 .judgements
2954 .get(j)
2955 .is_some_and(|r| r.failed.is_some())
2956 {
2957 continue;
2958 }
2959 let seat_key = format!("judge-{}", j + 1);
2960 let spec = self.occupant(&seat_key, spec);
2961 let seat = self.seat(&seat_key, &spec.id);
2962 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2963 let text = if has_context(&spec, &seat, sessions) {
2964 prompt::final_vote(&viable, &language)
2965 } else {
2966 full.clone()
2967 };
2968 jobs.push(SeatJob {
2969 spec,
2970 seat,
2971 prompt: text,
2972 cwd: root.join(format!("judge-{}", j + 1)),
2973 timeout,
2974 allow_write: false,
2975 sessions,
2976 artifacts: artifacts.clone(),
2977 stem: format!("vote-judge-{}", j + 1),
2978 handover: Some(full),
2979 });
2980 seats_at.push(j);
2981 }
2982
2983 self.state.event(
2984 "vote",
2985 format!(
2986 "collecting {} final votes one by one, privately",
2987 jobs.len()
2988 ),
2989 );
2990 let allowed = viable.clone();
2991 let mut quota_losses = Vec::new();
2992 let cache = self.state.config.cache_dir();
2993 let ctx = WaveCtx {
2994 carry_seats: false,
2995 run: &run_id,
2996 node: "vote",
2997 prompts: &prompts,
2998 cache: cache.as_deref(),
2999 round: None,
3000 };
3001 let results = ask_json_wave::<FinalVote>(
3002 jobs,
3003 Arc::clone(&self.sem),
3004 self.state.config.graph.retries,
3005 &self.roles.judge_roster,
3006 &ctx,
3007 &mut quota_losses,
3008 &mut self.state,
3009 &move |v: &FinalVote| match v.label() {
3010 Some(c) if allowed.contains(&c) => Ok(()),
3011 other => bail!("vote {other:?} is not one of {allowed:?}"),
3012 },
3013 )
3014 .await;
3015 self.state.quota.extend(quota_losses);
3016
3017 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3018 let agent_id = seat.agent.clone();
3019 self.state.seats.insert(seat.key.clone(), seat);
3020 let initial = self
3021 .state
3022 .judgements
3023 .get(j)
3024 .and_then(|r| r.ranking.first().copied());
3025 let mut record = VoteRecord {
3026 judge: j + 1,
3027 agent: agent_id,
3028 vote: None,
3029 reason: String::new(),
3030 changed: false,
3031 };
3032 match res {
3033 Ok((v, _)) => {
3034 record.vote = v.label();
3035 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3036 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3037 self.state.event(
3038 "vote",
3039 format!(
3040 "judge {} voted {}{}",
3041 j + 1,
3042 record.vote.unwrap_or('?'),
3043 if record.changed { " (changed)" } else { "" }
3044 ),
3045 );
3046 }
3047 Err(e) => {
3048 self.state
3049 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3050 }
3051 }
3052 self.state.votes.push(record);
3053 self.state.save()?;
3054 }
3055 Ok(())
3056 }
3057
3058 fn tally(&mut self) -> Result<()> {
3061 if self.state.tally.is_some() {
3062 return Ok(());
3063 }
3064 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3065 let tops: Vec<char> = self
3066 .state
3067 .judgements
3068 .iter()
3069 .filter_map(|j| j.ranking.first().copied())
3070 .collect();
3071 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3072
3073 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3076 let mut cast: Vec<char> = Vec::new();
3077 for (i, j) in self.state.judgements.iter().enumerate() {
3078 let vote = self
3079 .state
3080 .votes
3081 .iter()
3082 .find(|v| v.judge == i + 1)
3083 .and_then(|v| v.vote)
3084 .or_else(|| j.ranking.first().copied());
3085 if let Some(v) = vote {
3086 *first_choice.entry(v).or_insert(0) += 1;
3087 cast.push(v);
3088 }
3089 }
3090
3091 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3092 for j in &self.state.judgements {
3093 let n = j.ranking.len();
3094 for (pos, label) in j.ranking.iter().enumerate() {
3095 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3096 }
3097 }
3098
3099 let best = first_choice.values().copied().max().unwrap_or(0);
3100 let mut leaders: Vec<char> = first_choice
3101 .iter()
3102 .filter(|(_, v)| **v == best)
3103 .map(|(k, _)| *k)
3104 .collect();
3105 let mut tie_break = None;
3106 if leaders.len() > 1 {
3107 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3108 let borda_leaders: Vec<char> = leaders
3109 .iter()
3110 .copied()
3111 .filter(|l| borda[l] == top_borda)
3112 .collect();
3113 tie_break = Some(if borda_leaders.len() == 1 {
3114 format!(
3115 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3116 leaders.len()
3117 )
3118 } else {
3119 format!(
3120 "{} way tie on both first-choice votes and Borda points, broken by label order",
3121 leaders.len()
3122 )
3123 });
3124 leaders = borda_leaders;
3125 leaders.sort_unstable();
3126 }
3127 let winner = *leaders
3128 .first()
3129 .or(viable.first())
3130 .context("no candidate to declare a winner from")?;
3131
3132 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3133 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3134 let deliberated = !self.state.deliberation.is_empty();
3135
3136 let quota_seats: std::collections::BTreeSet<&str> =
3140 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3141 let mut present = 0usize;
3142 for (i, j) in self.state.judgements.iter().enumerate() {
3143 if quota_seats.contains(j.seat.as_str()) {
3144 continue;
3145 }
3146 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3147 let voted = self
3148 .state
3149 .votes
3150 .iter()
3151 .any(|v| v.judge == i + 1 && v.vote.is_some());
3152 if ranked || voted {
3153 present += 1;
3154 }
3155 }
3156 let needs_quorum = viable.len() > 1;
3162 let judges_total = if needs_quorum {
3163 self.roles.judges.len()
3164 } else {
3165 0
3166 };
3167 let quorum = if needs_quorum {
3168 judges_total / 2 + 1
3169 } else {
3170 0
3171 };
3172 let met_quorum = !needs_quorum || present >= quorum;
3173 let uncontested = (!needs_quorum).then(|| {
3174 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3175 });
3176
3177 self.state.event(
3178 "tally",
3179 match &uncontested {
3180 Some(reason) => format!("winner {winner} — {reason}"),
3181 None => format!(
3182 "winner {winner} — votes {} | initial {} | {} changed | \
3183 {present}/{judges_total} judges{}",
3184 first_choice
3185 .iter()
3186 .map(|(k, v)| format!("{k}:{v}"))
3187 .collect::<Vec<_>>()
3188 .join(" "),
3189 if unanimous_initial {
3190 "unanimous"
3191 } else {
3192 "split"
3193 },
3194 changed_votes,
3195 if met_quorum {
3196 String::new()
3197 } else {
3198 format!(" — below quorum ({quorum} required)")
3199 },
3200 ),
3201 },
3202 );
3203 if !met_quorum {
3204 self.state.event(
3205 "stall",
3206 format!(
3207 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3208 the run stops here, resumable"
3209 ),
3210 );
3211 }
3212 self.state.tally = Some(Tally {
3213 first_choice,
3214 borda,
3215 winner,
3216 rankings: tops.len(),
3217 unanimous_initial,
3218 deliberated,
3219 changed_votes,
3220 unanimous_final,
3221 tie_break,
3222 judges: judges_total,
3223 present,
3224 quorum,
3225 met_quorum,
3226 uncontested,
3227 });
3228 self.state.status = if met_quorum {
3229 RunStatus::Reviewing
3230 } else {
3231 RunStatus::Stalled
3232 };
3233 self.state.save()?;
3234 Ok(())
3235 }
3236
3237 #[allow(clippy::too_many_lines)]
3258 async fn recover_stall(&mut self) -> Result<bool> {
3259 let run_id = self.state.id.clone();
3264 let prompts = self.state.config.prompts.clone();
3265 let quota_seats: BTreeSet<&str> =
3270 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3271 let absent: Vec<String> = self
3272 .state
3273 .judgements
3274 .iter()
3275 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3276 .map(|j| j.seat.clone())
3277 .collect();
3278 if absent.is_empty() {
3279 return Ok(false);
3280 }
3281 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3282 if viable.len() <= 1 {
3283 return Ok(false);
3284 }
3285 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3286 let language = self.state.config.graph.language.clone();
3287 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3288 let sessions = self.state.config.graph.sessions;
3289 let artifacts = agent::artifacts_dir(&self.state.dir());
3290 let root = self.state.worktree_root();
3291 let base_short = short(&self.state.base_commit);
3292 let candidates: Vec<Candidate> = viable.clone();
3293
3294 let mut positions: Vec<usize> = absent
3296 .iter()
3297 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3298 .collect();
3299 if positions.is_empty() {
3300 return Ok(false);
3301 }
3302 positions.sort_unstable();
3303 positions.dedup();
3304
3305 let mut judge_jobs = Vec::new();
3307 for &j in &positions {
3308 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3309 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3310 let seat_key = format!("judge-{}", j + 1);
3311 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3312 let seat = self.seat(&seat_key, &spec.id);
3313 judge_jobs.push(SeatJob {
3314 spec,
3315 seat,
3316 prompt: prompt::judge(
3317 &self.state.instruction,
3318 &views,
3319 self.roles.judges.len(),
3320 &base_short,
3321 &language,
3322 ),
3323 cwd: root.join(seat_key),
3324 timeout,
3325 allow_write: false,
3326 sessions,
3327 artifacts: artifacts.clone(),
3328 stem: format!("judge-{}-recover", j + 1),
3329 handover: None,
3330 });
3331 }
3332
3333 let labels_for_check = labels.clone();
3334 let mut judge_losses = Vec::new();
3335 let retries = self.state.config.graph.retries;
3336 let cache = self.state.config.cache_dir();
3337 let ctx = WaveCtx {
3338 carry_seats: false,
3339 run: &run_id,
3340 node: "judge",
3341 prompts: &prompts,
3342 cache: cache.as_deref(),
3343 round: None,
3344 };
3345 let results = ask_json_wave::<Ranking>(
3346 judge_jobs,
3347 Arc::clone(&self.sem),
3348 retries,
3349 &self.roles.judge_roster,
3350 &ctx,
3351 &mut judge_losses,
3352 &mut self.state,
3353 &move |r: &Ranking| r.validate(&labels_for_check),
3354 )
3355 .await;
3356
3357 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3359 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3360 let agent_id = seat.agent.clone();
3361 self.state.seats.insert(seat.key.clone(), seat);
3362 let record = &mut self.state.judgements[j];
3363 match res {
3364 Ok((ranking, out)) => {
3365 record.agent = agent_id;
3366 record.ranking = ranking.normalized();
3367 record.reasons = ranking.reasons;
3368 record.strengths = ranking.strengths;
3369 record.confidence = ranking.confidence;
3370 record.failed = None;
3371 record.duration_ms = out.duration_ms;
3372 recovered.insert(j);
3373 self.state.event(
3374 "recover",
3375 format!("judge {} ranked again after the limit", j + 1),
3376 );
3377 }
3378 Err(e) => {
3379 self.state
3380 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3381 }
3382 }
3383 }
3384
3385 let mut vote_jobs = Vec::new();
3387 let mut vote_pos: Vec<usize> = Vec::new();
3388 for &j in &recovered {
3389 let seat_key = format!("judge-{}", j + 1);
3390 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3391 let seat = self.seat(&seat_key, &spec.id);
3392 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3393 let text = if has_context(&spec, &seat, sessions) {
3394 prompt::final_vote(&labels, &language)
3395 } else {
3396 full.clone()
3397 };
3398 vote_jobs.push(SeatJob {
3399 spec,
3400 seat,
3401 prompt: text,
3402 cwd: root.join(seat_key),
3403 timeout,
3404 allow_write: false,
3405 sessions,
3406 artifacts: artifacts.clone(),
3407 stem: format!("vote-judge-{}-recover", j + 1),
3408 handover: Some(full),
3409 });
3410 vote_pos.push(j);
3411 }
3412 let allowed = labels.clone();
3413 let mut vote_losses = Vec::new();
3414 let vote_retries = self.state.config.graph.retries;
3415 let vote_cache = self.state.config.cache_dir();
3416 let ctx = WaveCtx {
3417 carry_seats: false,
3418 run: &run_id,
3419 node: "vote",
3420 prompts: &prompts,
3421 cache: vote_cache.as_deref(),
3422 round: None,
3423 };
3424 let votes = ask_json_wave::<FinalVote>(
3425 vote_jobs,
3426 Arc::clone(&self.sem),
3427 vote_retries,
3428 &self.roles.judge_roster,
3429 &ctx,
3430 &mut vote_losses,
3431 &mut self.state,
3432 &move |v: &FinalVote| match v.label() {
3433 Some(c) if allowed.contains(&c) => Ok(()),
3434 other => bail!("vote {other:?} is not one of {allowed:?}"),
3435 },
3436 )
3437 .await;
3438 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3439 let agent_id = seat.agent.clone();
3440 self.state.seats.insert(seat.key.clone(), seat);
3441 match res {
3442 Ok((v, _)) => {
3443 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3444 rec.vote = v.label();
3445 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3446 } else {
3447 self.state.votes.push(VoteRecord {
3448 judge: j + 1,
3449 agent: agent_id,
3450 vote: v.label(),
3451 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3452 changed: false,
3453 });
3454 }
3455 self.state.event(
3456 "recover",
3457 format!("judge {} voted again after the limit", j + 1),
3458 );
3459 }
3460 Err(e) => {
3461 self.state
3462 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3463 }
3464 }
3465 }
3466
3467 let recovered_keys: BTreeSet<String> = recovered
3471 .iter()
3472 .map(|&j| format!("judge-{}", j + 1))
3473 .collect();
3474 self.state
3475 .quota
3476 .retain(|q| !recovered_keys.contains(&q.seat));
3477 for loss in judge_losses.into_iter().chain(vote_losses) {
3481 if recovered_keys.contains(&loss.seat) {
3482 continue;
3483 }
3484 self.state.quota.retain(|q| q.seat != loss.seat);
3485 self.state.quota.push(loss);
3486 }
3487
3488 self.state.tally = None;
3490 self.tally()?;
3491 Ok(self
3492 .state
3493 .tally
3494 .as_ref()
3495 .map(|t| t.met_quorum)
3496 .unwrap_or(false))
3497 }
3498
3499 async fn graft(&mut self) -> Result<()> {
3516 let Err(e) = self.graft_inner().await else {
3517 return Ok(());
3518 };
3519 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3520 self.state.status = RunStatus::Blocked;
3521 self.state.event("graft", format!("blocked: {e:#}"));
3522 self.state.save()?;
3523 return Err(e);
3524 }
3525 if let Some(rec) = self.state.graft.clone()
3530 && !rec.outcome.settled()
3531 {
3532 if let Some(winner) = self.state.winner().cloned() {
3533 if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3534 self.state.status = RunStatus::Blocked;
3535 self.state.event("graft", format!("blocked: {re:#}"));
3536 self.state.save()?;
3537 return Err(re);
3538 }
3539 }
3540 if let Some(g) = self.state.graft.as_mut() {
3541 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3542 }
3543 }
3544 self.state.event("graft", format!("skipped: {e:#}"));
3545 if let Err(e) = self.state.save() {
3546 tracing::warn!("save after graft: {e:#}");
3547 }
3548 Ok(())
3549 }
3550
3551 async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3554 let restored = git::restore_to(wt, pre).await;
3555 let tip = git::rev_parse(wt, "HEAD").await;
3556 match (restored, tip) {
3557 (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3558 (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3559 (Ok(()), Ok(tip)) => {
3560 Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3561 }
3562 (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3563 }
3564 }
3565
3566 fn graft_skipped(&mut self, why: &str) {
3568 let message = format!("skipped: {why}");
3569 if !self
3570 .state
3571 .events
3572 .iter()
3573 .any(|e| e.node == "graft" && e.message == message)
3574 {
3575 self.state.event("graft", message);
3576 }
3577 }
3578
3579 async fn graft_inner(&mut self) -> Result<()> {
3580 if let Some(rec) = self.state.graft.clone()
3583 && !rec.outcome.settled()
3584 {
3585 if self.state.reviews.is_empty()
3589 && let Some(winner) = self.state.winner().cloned()
3590 {
3591 Self::graft_restore(&winner.worktree, &rec.pre).await?;
3592 }
3593 if let Some(g) = self.state.graft.as_mut() {
3594 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3595 }
3596 self.state.event(
3597 "graft",
3598 "rolled back: the previous round was interrupted; the winner is untouched",
3599 );
3600 self.state.save()?;
3601 return Ok(());
3602 }
3603 let plan = match graft::plan(&self.state) {
3604 Ok(plan) => plan,
3605 Err(skip) => {
3606 if skip != graft::Skip::AlreadyDone {
3607 self.graft_skipped(skip.reason());
3608 self.state.save()?;
3609 }
3610 return Ok(());
3611 }
3612 };
3613 let Some(winner) = self.state.winner().cloned() else {
3614 return Ok(());
3615 };
3616 let wt = winner.worktree.clone();
3617 if !git::is_clean(&wt).await? {
3618 self.graft_skipped("the winner's worktree has uncommitted changes");
3619 self.state.save()?;
3620 return Ok(());
3621 }
3622 let pre = git::rev_parse(&wt, "HEAD").await?;
3623 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3624 else {
3625 self.graft_skipped("the winner's diff could not be measured");
3626 self.state.save()?;
3627 return Ok(());
3628 };
3629 let limit = graft::limit(winner_lines);
3630 self.state.graft = Some(GraftRecord {
3633 pre: pre.clone(),
3634 post: None,
3635 lines: None,
3636 limit: Some(limit),
3637 agent: None,
3638 outcome: GraftOutcome::Running,
3639 });
3640 self.state.event(
3641 "graft",
3642 format!(
3643 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3644 plan.strengths
3645 .iter()
3646 .map(|(l, _, _)| l.to_string())
3647 .collect::<Vec<_>>()
3648 .join(", "),
3649 plan.winner
3650 ),
3651 );
3652 self.state.save()?;
3653
3654 let fix_prompt = prompt::graft(
3655 &self.state.instruction,
3656 plan.winner,
3657 &plan.strengths,
3658 limit,
3659 &self.state.config.graph.language,
3660 );
3661 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3662 let sessions = self.state.config.graph.sessions;
3663 let artifacts = agent::artifacts_dir(&self.state.dir());
3664 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3665 let (_, seat, out) = self
3666 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3667 SeatJob {
3668 prompt: fix_prompt.clone(),
3669 spec,
3670 seat,
3671 cwd: wt.clone(),
3672 timeout,
3673 allow_write: true,
3674 sessions,
3675 artifacts: artifacts.clone(),
3676 stem: "graft".to_owned(),
3677 handover: None,
3678 }
3679 })
3680 .await;
3681 let mut why: Option<String> = None;
3682 match &out {
3683 AgentOutcome::Ok(o) if agent::output_advances(o) => {
3684 why = Some("the fixer produced nothing usable".to_owned());
3685 }
3686 AgentOutcome::Ok(_) => {}
3687 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3688 AgentOutcome::Quota(o) => {
3689 self.state.quota.push(QuotaLoss {
3690 seat: seat.key.clone(),
3691 node: "graft".to_owned(),
3692 at: Timestamp::now(),
3693 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3694 });
3695 why = Some("rate limited (quota)".to_owned());
3696 }
3697 AgentOutcome::Failed(e) => {
3698 why = Some(format!(
3699 "the fixer failed: {}",
3700 e.lines()
3701 .next()
3702 .unwrap_or("")
3703 .chars()
3704 .take(160)
3705 .collect::<String>()
3706 ));
3707 }
3708 }
3709 let agent_id = seat.agent.clone();
3710 self.state.seats.insert(seat.key.clone(), seat);
3711
3712 let mut post = None;
3713 let mut lines = None;
3714 if why.is_none() {
3715 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3716 if let Ok(r) = &rescued {
3717 self.state.note_withheld("graft", &r.withheld);
3718 }
3719 let head = git::rev_parse(&wt, "HEAD").await?;
3720 let dirty = match &rescued {
3724 Ok(r) => git::status_porcelain(&wt)
3725 .await
3726 .map(|st| {
3727 st.lines().any(|l| {
3728 !r.withheld
3729 .iter()
3730 .any(|w| l.trim_end().ends_with(w.path.as_str()))
3731 })
3732 })
3733 .unwrap_or(true),
3734 Err(_) => true,
3735 };
3736 if dirty {
3737 why = Some("the fixer left changes that could not be committed".to_owned());
3738 } else if head == pre {
3739 why = Some("the fixer changed nothing".to_owned());
3740 } else {
3741 lines = git::changed_lines(&wt, &pre, &head).await;
3742 if graft::within_budget(lines, limit) {
3743 post = Some(head);
3744 } else {
3745 why = Some(format!(
3746 "{} changed lines against a limit of {limit}",
3747 lines.map_or_else(
3748 || "an unmeasurable number of".to_owned(),
3749 |l| l.to_string()
3750 )
3751 ));
3752 }
3753 }
3754 }
3755 let outcome = match why {
3756 None => {
3757 self.state.event(
3758 "graft",
3759 format!(
3760 "applied: {} changed lines (limit {limit})",
3761 lines.unwrap_or(0)
3762 ),
3763 );
3764 GraftOutcome::Applied
3765 }
3766 Some(why) => {
3767 Self::graft_restore(&wt, &pre).await?;
3770 self.state.event(
3771 "graft",
3772 format!("rolled back: {why}; the winner is untouched"),
3773 );
3774 GraftOutcome::RolledBack(why)
3775 }
3776 };
3777 self.state.graft = Some(GraftRecord {
3778 pre,
3779 post,
3780 lines,
3781 limit: Some(limit),
3782 agent: Some(agent_id),
3783 outcome,
3784 });
3785 self.state.save()?;
3786 Ok(())
3787 }
3788
3789 async fn fold_losers(&mut self) -> Result<()> {
3792 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3793 return Ok(());
3794 };
3795 let repo = self.state.repo.clone();
3796 let mut folded = Vec::new();
3797 for i in 0..self.state.candidates.len() {
3798 let c = &self.state.candidates[i];
3799 if c.label == winner || c.folded {
3800 continue;
3801 }
3802 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3803 git::worktree_remove(&repo, &wt).await.ok();
3804 git::branch_delete(&repo, &branch).await.ok();
3805 self.state.candidates[i].folded = true;
3806 folded.push(label.to_string());
3807 }
3808 let root = self.state.worktree_root();
3810 for j in 1..=self.roles.judges.len() {
3811 let wt = root.join(format!("judge-{j}"));
3812 if wt.exists() {
3813 git::worktree_remove(&repo, &wt).await.ok();
3814 }
3815 }
3816 if self.state.config.graph.advise {
3819 for k in 1..=self.state.config.graph.advisors {
3820 let wt = root.join(format!("advisor-{k}"));
3821 if wt.exists() {
3822 git::worktree_remove(&repo, &wt).await.ok();
3823 }
3824 }
3825 }
3826 if !folded.is_empty() {
3827 self.state
3828 .event("fold", format!("folded candidates {}", folded.join(", ")));
3829 self.state.save()?;
3830 }
3831 Ok(())
3832 }
3833
3834 async fn sync_to_base(&mut self) -> Result<()> {
3874 if self.state.status == RunStatus::AlreadyInBase {
3875 return Ok(());
3876 }
3877 let conflicted = self
3878 .state
3879 .base_sync
3880 .as_ref()
3881 .is_some_and(|s| s.conflict.is_some());
3882 let Some(winner) = self.state.winner().cloned() else {
3883 return Ok(());
3884 };
3885
3886 let repo = self.state.repo.clone();
3887 let remote = self.state.config.merge.remote.clone();
3888 let base_branch = self.state.base_branch.clone();
3889 let tracking = format!("{remote}/{base_branch}");
3890
3891 git::fetch(&repo, &remote, &base_branch).await.ok();
3892 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3897 return Ok(());
3898 };
3899
3900 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3901 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3902 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3903
3904 if (behind > 0 || conflicted || head == tip)
3913 && self
3914 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3915 .await?
3916 {
3917 return Ok(());
3918 }
3919 if conflicted {
3920 return Ok(());
3921 }
3922
3923 if behind == 0 {
3924 if let Some(from) = self
3933 .state
3934 .rebase_fixes
3935 .iter()
3936 .rev()
3937 .find_map(|r| r.from.clone())
3938 && from != head
3939 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3940 .await
3941 .is_ok_and(|o| o.ok())
3942 {
3943 git::sync_to_head(&winner.worktree).await?;
3944 }
3945 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3951 if let Some(why) = &conflict {
3952 self.state.status = RunStatus::Blocked;
3953 self.state.event("land", why.clone());
3954 }
3955 self.state.base_sync = Some(BaseSync {
3956 tip,
3957 behind: 0,
3958 attempts,
3959 conflict,
3960 already_in: None,
3961 });
3962 self.state.save()?;
3963 return Ok(());
3964 }
3965
3966 if attempts >= BASE_SYNC_ROUNDS {
3967 let why = format!(
3968 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3969 rebase(s); rebasing again would only race it",
3970 winner.branch
3971 );
3972 self.state.status = RunStatus::Blocked;
3973 self.state.base_sync = Some(BaseSync {
3974 tip,
3975 behind,
3976 attempts,
3977 conflict: Some(why.clone()),
3978 already_in: None,
3979 });
3980 self.state.event("land", why);
3981 self.state.save()?;
3982 return Ok(());
3983 }
3984
3985 self.state.event(
3986 "land",
3987 format!(
3988 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3989 winner.branch
3990 ),
3991 );
3992 self.state.save()?;
3993
3994 let branch_tracking = format!("{remote}/{}", winner.branch);
4000 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4001 let remote_tip = if self.state.task_pr_branch().is_none()
4002 && matches!(&fetched_branch, Ok(o) if o.ok())
4003 {
4004 git::rev_parse(&repo, &branch_tracking).await.ok()
4005 } else {
4006 None
4007 };
4008 if let Some(theirs) = &remote_tip
4012 && !git::is_ancestor(&repo, theirs, &head).await
4013 && !crate::reconcile::origin_missing(&repo, &head, theirs)
4014 .await
4015 .is_ok_and(|missing| missing.is_empty())
4016 {
4017 let why = format!(
4018 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4019 them",
4020 short(theirs),
4021 winner.branch
4022 );
4023 self.state.status = RunStatus::Blocked;
4024 self.state.base_sync = Some(BaseSync {
4025 tip,
4026 behind,
4027 attempts,
4028 conflict: Some(why.clone()),
4029 already_in: None,
4030 });
4031 self.state.event("land", why);
4032 self.state.save()?;
4033 return Ok(());
4034 }
4035
4036 let scratch = self.state.dir().join("base-sync");
4037 let rebased = match crate::rebase::rebase_with_fixer(
4038 &mut self.state,
4039 &scratch,
4040 &winner.branch,
4041 &tracking,
4042 )
4043 .await
4044 {
4045 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4046 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4047 Err(e) => Err(e),
4048 };
4049 let attempts = attempts + 1;
4050 match rebased {
4051 Ok(None) => {
4052 git::sync_to_head(&winner.worktree).await?;
4056 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4057 let mut conflict = None;
4058 if let Some(pinned) = &remote_tip {
4059 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4060 match pushed {
4061 Ok(o) if o.ok() => self.state.event(
4062 "land",
4063 format!("pushed rebased {} to {remote}", winner.branch),
4064 ),
4065 Ok(o) => {
4066 conflict = Some(format!(
4067 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4068 winner.branch,
4069 short(pinned),
4070 o.stderr.chars().take(600).collect::<String>()
4071 ));
4072 }
4073 Err(e) => {
4074 conflict = Some(format!(
4075 "rebased {} locally but could not push it: {e:#}",
4076 winner.branch
4077 ));
4078 }
4079 }
4080 }
4081 if let Some(why) = &conflict {
4082 self.state.status = RunStatus::Blocked;
4083 self.state.event("land", why.clone());
4084 }
4085 self.state.base_sync = Some(BaseSync {
4086 tip: tip.clone(),
4087 behind: 0,
4088 attempts,
4089 conflict,
4090 already_in: None,
4091 });
4092 self.state
4093 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4094 }
4095 Ok(Some(conflict)) => {
4096 let why = format!(
4097 "{} conflicts with {tracking} and did not rebase: {}",
4098 winner.branch,
4099 conflict.chars().take(600).collect::<String>()
4100 );
4101 self.state.status = RunStatus::Blocked;
4102 self.state.base_sync = Some(BaseSync {
4103 tip,
4104 behind,
4105 attempts,
4106 conflict: Some(why.clone()),
4107 already_in: None,
4108 });
4109 self.state.event("land", why);
4110 }
4111 Err(e) => {
4112 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4113 self.state.status = RunStatus::Blocked;
4114 self.state.base_sync = Some(BaseSync {
4115 tip,
4116 behind,
4117 attempts,
4118 conflict: Some(why.clone()),
4119 already_in: None,
4120 });
4121 self.state.event("land", why);
4122 }
4123 }
4124 self.state.save()?;
4125 Ok(())
4126 }
4127
4128 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4134 if self.state.task_pr_branch().is_some() {
4137 return None;
4138 }
4139 let repo = self.state.repo.clone();
4140 let remote = self.state.config.merge.remote.clone();
4141 let fetched = git::fetch(&repo, &remote, branch).await;
4142 if !matches!(&fetched, Ok(o) if o.ok()) {
4143 return None;
4144 }
4145 let branch_tracking = format!("{remote}/{branch}");
4146 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4147 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4148 return None;
4149 }
4150 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4151 .await
4152 .is_ok_and(|missing| missing.is_empty())
4153 {
4154 return Some(format!(
4155 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4156 them",
4157 short(&theirs)
4158 ));
4159 }
4160 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4161 Ok(o) if o.ok() => {
4162 self.state
4163 .event("land", format!("pushed rebased {branch} to {remote}"));
4164 None
4165 }
4166 Ok(o) => Some(format!(
4167 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4168 someone may have pushed): {}",
4169 short(&theirs),
4170 o.stderr.chars().take(600).collect::<String>()
4171 )),
4172 Err(e) => Some(format!(
4173 "{branch} is rebased locally but could not be pushed: {e:#}"
4174 )),
4175 }
4176 }
4177
4178 async fn settle_already_in(
4195 &mut self,
4196 branch: &str,
4197 tip: &str,
4198 head: &str,
4199 attempts: usize,
4200 behind: usize,
4201 ) -> Result<bool> {
4202 let repo = self.state.repo.clone();
4203 let remote = self.state.config.merge.remote.clone();
4204 let start = self.state.base_commit.clone();
4205 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4206 Ok(Some(e)) => e,
4207 Ok(None) => return Ok(false),
4208 Err(e) => {
4209 tracing::warn!("already-in-base check for {branch}: {e:#}");
4210 return Ok(false);
4211 }
4212 };
4213 let mut verified = vec![head.to_owned()];
4214 let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4215 let fetched = git::fetch(&repo, &remote, &public_branch).await;
4216 if matches!(&fetched, Ok(o) if o.ok())
4217 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4218 && theirs != head
4219 {
4220 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4221 Ok(Some(_)) => verified.push(theirs),
4222 _ => return Ok(false),
4223 }
4224 }
4225 let base_branch = self.state.base_branch.clone();
4226 let message = format!(
4227 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4228 land",
4229 evidence.names(),
4230 evidence.proof.as_str()
4231 );
4232 let closed =
4233 crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4234 .await;
4235 match closed {
4236 Ok(Ok(url)) => self
4237 .state
4238 .event("land", format!("closed {url}: superseded on {base_branch}")),
4239 Ok(Err(why)) => self
4240 .state
4241 .event("land", format!("did not close a pull request: {why}")),
4242 Err(e) => {
4243 let why = format!(
4244 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4245 not be closed ({e:#}); resume to retry"
4246 );
4247 self.state.status = RunStatus::Blocked;
4248 self.state.base_sync = Some(BaseSync {
4249 tip: tip.to_owned(),
4250 behind,
4251 attempts,
4252 conflict: Some(why.clone()),
4253 already_in: None,
4254 });
4255 self.state.event("land", why);
4256 self.state.save()?;
4257 return Ok(true);
4258 }
4259 }
4260 self.state.status = RunStatus::AlreadyInBase;
4261 self.state.base_sync = Some(BaseSync {
4262 tip: tip.to_owned(),
4263 behind,
4264 attempts,
4265 conflict: None,
4266 already_in: Some(evidence),
4267 });
4268 self.state.event("land", message);
4269 self.state.save()?;
4270 self.settle_questions();
4271 Ok(true)
4272 }
4273
4274 fn landing_base(&self) -> String {
4284 self.state
4285 .base_sync
4286 .as_ref()
4287 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4288 }
4289
4290 pub async fn fix_selected(
4323 &mut self,
4324 ids: &[String],
4325 reason: &str,
4326 allow_stale: bool,
4327 ) -> Result<()> {
4328 let reason = reason.trim();
4329 if reason.is_empty() {
4330 bail!("a fix request needs a reason — that is the operator's own record of why");
4331 }
4332 if ids.is_empty() {
4333 bail!("no finding id given");
4334 }
4335 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4336 bail!(
4337 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4338 has already concluded — can be given a targeted fix. A run still \
4339 in progress should simply be resumed; a `merged` run's branch has \
4340 already landed, so its answer is a fresh `magi review <branch>`, \
4341 not reopening this run's own record",
4342 self.state.id,
4343 self.state.status.as_str()
4344 );
4345 }
4346 let Some(winner) = self.state.winner().cloned() else {
4347 bail!("run {} has no winning candidate to fix", self.state.id);
4348 };
4349 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4350 bail!(
4351 "branch `{}` no longer exists; this run cannot be extended",
4352 winner.branch
4353 );
4354 }
4355 let home = crate::run::home();
4356 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4357 bail!(
4358 "run {} is currently being worked on by another magi process",
4359 self.state.id
4360 );
4361 }
4362 let _claim = FixClaim::acquire(&self.state.dir())?;
4368
4369 let mut seen = BTreeSet::new();
4373 let mut findings = Vec::new();
4374 let mut missing = Vec::new();
4375 for id in ids {
4376 if !seen.insert(id.clone()) {
4377 continue;
4378 }
4379 match self.state.finding(id) {
4380 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4381 id: f.id.clone(),
4382 severity: f.severity,
4383 reviewer_vote: rec.vote,
4384 round: round.round,
4385 round_head: round.head.clone(),
4386 reviewer: rec.reviewer,
4387 agent: rec.agent.clone(),
4388 file: f.file.clone(),
4389 line: f.line,
4390 title: f.title.clone(),
4391 detail: f.detail.clone(),
4392 outcome: OperatorFixOutcome::Pending,
4393 }),
4394 None => missing.push(id.clone()),
4395 }
4396 }
4397 if !missing.is_empty() {
4398 bail!(
4399 "unknown finding id(s): {}; nothing was changed",
4400 missing.join(", ")
4401 );
4402 }
4403
4404 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4405 let stale_details: Vec<(String, String)> = findings
4406 .iter()
4407 .filter(|f| f.round_head != head_at_request)
4408 .map(|f| (f.id.clone(), f.round_head.clone()))
4409 .collect();
4410 let stale = !stale_details.is_empty();
4411 if stale && !allow_stale {
4412 bail!(
4413 "the branch has moved since some finding(s) were raised — {} — now \
4414 at {}; pass --allow-stale to fix anyway, or re-run review first",
4415 stale_details
4416 .iter()
4417 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4418 .collect::<Vec<_>>()
4419 .join(", "),
4420 short(&head_at_request)
4421 );
4422 }
4423
4424 let request = OperatorFixRequest {
4425 requested_at: Timestamp::now(),
4426 reason: reason.to_owned(),
4427 findings,
4428 head_at_request: head_at_request.clone(),
4429 allow_stale,
4430 stale,
4431 fix: None,
4432 result_head: None,
4433 follow_up_review_run: None,
4434 };
4435 self.state.event(
4436 "fix",
4437 format!(
4438 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4439 request.findings.len(),
4440 request
4441 .findings
4442 .iter()
4443 .map(|f| f.id.as_str())
4444 .collect::<Vec<_>>()
4445 .join(", "),
4446 ),
4447 );
4448 self.state.operator_fixes.push(request);
4455 self.state.save()?;
4456 let request_index = self.state.operator_fixes.len() - 1;
4457
4458 if winner.worktree.exists() {
4467 let dirty = git::git(
4470 &winner.worktree,
4471 &["status", "--porcelain", "--untracked-files=all"],
4472 )
4473 .await?;
4474 let only_withheld = dirty.lines().all(|l| {
4475 l.strip_prefix("?? ")
4476 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4477 });
4478 if !only_withheld {
4479 bail!(
4480 "`{}` has uncommitted changes; refusing to touch it — commit or \
4481 discard them first",
4482 winner.worktree.display()
4483 );
4484 }
4485 git::worktree_remove(&self.state.repo, &winner.worktree)
4486 .await
4487 .ok();
4488 }
4489 let fix_worktree = self.state.worktree_root().join("operator-fix");
4490 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4491 git::git(
4492 &self.state.repo,
4493 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4494 )
4495 .await
4496 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4497 if !git::is_clean(&fix_worktree).await? {
4498 git::worktree_remove(&self.state.repo, &fix_worktree)
4499 .await
4500 .ok();
4501 bail!(
4502 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4503 winner.branch
4504 );
4505 }
4506 let repo_for_setup = self.state.repo.clone();
4507 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4508 Ok(()) => {
4509 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4510 }
4511 Err(e) => Err(e),
4512 };
4513 if let Err(e) = setup {
4514 git::worktree_remove(&self.state.repo, &fix_worktree)
4515 .await
4516 .ok();
4517 return Err(e);
4518 }
4519
4520 let run_id = self.state.id.clone();
4521 let prompts = self.state.config.prompts.clone();
4522 let language = self.state.config.graph.language.clone();
4523 let sessions = self.state.config.graph.sessions;
4524 let artifacts = agent::artifacts_dir(&self.state.dir());
4525 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4526 .findings
4527 .iter()
4528 .map(|f| Finding {
4529 id: f.id.clone(),
4530 severity: f.severity,
4531 file: f.file.clone(),
4532 line: f.line,
4533 title: f.title.clone(),
4534 detail: f.detail.clone(),
4535 })
4536 .collect();
4537 let fix_prompt = prompt::operator_fix(
4538 &self.state.instruction,
4539 &finding_list,
4540 reason,
4541 &stale_details,
4542 &head_at_request,
4543 &language,
4544 );
4545 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4546 let (job, seat, out) = self
4547 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4548 prompt: fix_prompt.clone(),
4549 spec,
4550 seat,
4551 cwd: fix_worktree.clone(),
4552 timeout,
4553 allow_write: true,
4554 sessions,
4555 artifacts: artifacts.clone(),
4556 stem: "operator-fix".to_owned(),
4557 handover: None,
4558 })
4559 .await;
4560 let agent_id = seat.agent.clone();
4561
4562 let mut fix = FixRecord {
4563 agent: agent_id,
4564 addressed: Vec::new(),
4565 rejected: Vec::new(),
4566 notes: String::new(),
4567 committed: false,
4568 failed: None,
4569 duration_ms: 0,
4570 continuation: None,
4571 };
4572 let mut final_seat = seat.clone();
4573 match out {
4574 AgentOutcome::Ok(o) => {
4575 fix.duration_ms = o.duration_ms;
4576 let parsed = verdict::extract_json::<FixReport>(&o.text);
4577 let incomplete_reason = match &parsed {
4578 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4579 "the reply parsed, but it reported a command whose own CLI \
4580 never confirmed an exit status"
4581 .to_owned(),
4582 ),
4583 Ok(_) => None,
4584 Err(e) => Some(e.to_string()),
4585 };
4586 match incomplete_reason {
4587 None => {
4588 let report = parsed.expect("checked Ok above");
4589 fix.addressed = report.addressed;
4590 fix.rejected = report.rejected;
4591 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4592 }
4593 Some(reason) => {
4594 let (resumed_seat, resolved, failure, cont) = self
4595 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4596 .await;
4597 fix.duration_ms += cont.cumulative_wait_ms;
4598 fix.continuation = Some(cont);
4599 final_seat = resumed_seat;
4600 match resolved {
4601 Some(report) => {
4602 fix.addressed = report.addressed;
4603 fix.rejected = report.rejected;
4604 fix.notes =
4605 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4606 }
4607 None => fix.failed = failure,
4608 }
4609 }
4610 }
4611 }
4612 AgentOutcome::Dropped(o) => {
4613 fix.duration_ms = o.duration_ms;
4614 let why = o
4615 .dropped
4616 .as_ref()
4617 .map(|d| d.why.as_str())
4618 .unwrap_or("the CLI ended the stream without delivering its answer");
4619 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4620 }
4621 AgentOutcome::Quota(o) => {
4622 self.state.quota.push(QuotaLoss {
4623 seat: final_seat.key.clone(),
4624 node: "fix".to_owned(),
4625 at: Timestamp::now(),
4626 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4627 });
4628 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4629 }
4630 AgentOutcome::Failed(e) => fix.failed = Some(e),
4631 }
4632 if fix.continuation.is_none() {
4633 fix.continuation = Some(ContinuationRecord::not_needed());
4634 }
4635 self.state.seats.insert(final_seat.key.clone(), final_seat);
4636
4637 let rescue_message = format!(
4638 "magi: operator-selected fix ({}) (uncommitted work)",
4639 self.state.operator_fixes[request_index]
4640 .findings
4641 .iter()
4642 .map(|f| f.id.as_str())
4643 .collect::<Vec<_>>()
4644 .join(", ")
4645 );
4646 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4647 self.state.note_withheld("fix", &r.withheld);
4648 }
4649 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4650 fix.committed = after != head_at_request;
4651 git::worktree_remove(&self.state.repo, &fix_worktree)
4652 .await
4653 .ok();
4654
4655 self.state.event(
4656 "fix",
4657 match &fix.failed {
4658 Some(reason) => format!(
4659 "operator fix: adoption report was lost ({reason}); {}",
4660 if fix.committed {
4661 "committed"
4662 } else {
4663 "NO new commit"
4664 }
4665 ),
4666 None => format!(
4667 "operator fix: {} addressed, {} rejected, {}",
4668 fix.addressed.len(),
4669 fix.rejected.len(),
4670 if fix.committed {
4671 "committed"
4672 } else {
4673 "NO new commit"
4674 }
4675 ),
4676 },
4677 );
4678
4679 for f in &mut self.state.operator_fixes[request_index].findings {
4686 f.outcome = if fix.failed.is_some() {
4687 OperatorFixOutcome::Unreported
4688 } else if fix.addressed.contains(&f.id) {
4689 OperatorFixOutcome::Addressed
4690 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4691 OperatorFixOutcome::Rejected { why: r.why.clone() }
4692 } else {
4693 OperatorFixOutcome::Unreported
4694 };
4695 }
4696
4697 let committed = fix.committed;
4698 if committed {
4699 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4700 }
4701 self.state.operator_fixes[request_index].fix = Some(fix);
4702 self.state.save()?;
4705
4706 if committed {
4707 self.state.event(
4708 "fix",
4709 format!(
4710 "operator fix committed {}; opening a follow-up review-only run",
4711 short(&after)
4712 ),
4713 );
4714 let origin =
4717 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4718 match Self::review(
4719 &self.state.repo,
4720 &winner.branch,
4721 self.state.config.clone(),
4722 origin,
4723 )
4724 .await
4725 {
4726 Ok(mut follow_up) => {
4727 follow_up.state.event(
4728 "start",
4729 format!(
4730 "requested by an operator fix on run {} for finding(s) {}",
4731 self.state.id,
4732 self.state.operator_fixes[request_index]
4733 .findings
4734 .iter()
4735 .map(|f| f.id.as_str())
4736 .collect::<Vec<_>>()
4737 .join(", "),
4738 ),
4739 );
4740 follow_up.state.save()?;
4741 let follow_up_id = follow_up.state.id.clone();
4742 let adopted = match crate::direct::adopt(
4745 &follow_up.state,
4746 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4747 ) {
4748 Ok(a) => a,
4749 Err(e) => {
4750 self.state.event(
4753 "fix",
4754 format!(
4755 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4756 ),
4757 );
4758 self.state.save()?;
4759 return Ok(());
4760 }
4761 };
4762 let executed = follow_up.execute().await;
4763 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4764 if let Some(a) = adopted {
4765 a.finish(&follow_up.state, executed);
4766 }
4767 if let Some(e) = failure {
4768 self.state.event(
4769 "fix",
4770 format!(
4771 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4772 ),
4773 );
4774 }
4775 self.state.operator_fixes[request_index].follow_up_review_run =
4776 Some(follow_up_id);
4777 }
4778 Err(e) => {
4779 self.state.event(
4780 "fix",
4781 format!("committed the fix but could not open a follow-up review: {e:#}"),
4782 );
4783 }
4784 }
4785 self.state.save()?;
4786 }
4787
4788 Ok(())
4789 }
4790
4791 async fn ask_fixer(
4811 &mut self,
4812 winner: &Candidate,
4813 node: &'static str,
4814 round: Option<usize>,
4815 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4816 ) -> (SeatJob, SeatState, AgentOutcome) {
4817 let attempts = fixer::attempts(&self.state, &self.roles, winner);
4818 self.ask_fixer_among(attempts, node, round, None, build)
4819 .await
4820 }
4821
4822 async fn ask_fixer_among(
4827 &mut self,
4828 attempts: Vec<(AgentSpec, String)>,
4829 node: &'static str,
4830 round: Option<usize>,
4831 restore: Option<(&Path, &str)>,
4832 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4833 ) -> (SeatJob, SeatState, AgentOutcome) {
4834 let run_id = self.state.id.clone();
4835 let prompts = self.state.config.prompts.clone();
4836 let cache = self.state.config.cache_dir();
4837 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4838 let mut last = None;
4839 for (i, (spec, key)) in attempts.into_iter().enumerate() {
4840 let seat = self.seat(&key, &spec.id);
4841 let mut job = build(spec.clone(), seat);
4842 if i > 0 {
4843 job.stem = format!("{}-{}", job.stem, spec.id);
4844 }
4845 let ctx = WaveCtx {
4846 carry_seats: false,
4847 run: &run_id,
4848 node,
4849 prompts: &prompts,
4850 cache: cache.as_deref(),
4851 round,
4852 };
4853 let (seat, out) =
4854 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4855 let advances = match &out {
4856 AgentOutcome::Ok(o) => agent::output_advances(o),
4857 _ => true,
4858 };
4859 if let Some(next) = ids.get(i + 1)
4860 && advances
4861 {
4862 self.state.seats.insert(seat.key.clone(), seat.clone());
4863 let class =
4864 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4865 let reason = match &out {
4866 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4867 other => fail_reason(other),
4868 };
4869 record_handover(
4870 &mut self.state,
4871 node,
4872 &seat.key,
4873 &spec.id,
4874 next,
4875 &class,
4876 &reason,
4877 );
4878 if let Some((wt, rev)) = restore
4882 && let Err(e) = git::restore_to(wt, rev).await
4883 {
4884 let why =
4885 format!("could not restore the worktree before the next fixer: {e:#}");
4886 tracing::warn!("{why}");
4887 last = Some((job, seat, AgentOutcome::Failed(why)));
4888 break;
4889 }
4890 continue;
4891 }
4892 last = Some((job, seat, out));
4893 break;
4894 }
4895 last.expect("the fixer chain always has an entry")
4896 }
4897
4898 async fn review_loop(&mut self) -> Result<()> {
4899 if self
4904 .state
4905 .base_sync
4906 .as_ref()
4907 .is_some_and(|s| s.conflict.is_some())
4908 {
4909 return Ok(());
4910 }
4911 let run_id = self.state.id.clone();
4916 let prompts = self.state.config.prompts.clone();
4917 let Some(winner) = self.state.winner().cloned() else {
4918 return Ok(());
4919 };
4920 let max_rounds = self.state.config.graph.review_rounds;
4921 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4931 if status == RunStatus::Gating {
4934 self.record_contested_handoff();
4935 }
4936 self.state.status = status;
4937 self.state.save()?;
4938 return Ok(());
4939 }
4940 self.state.status = RunStatus::Reviewing;
4941 if self
4951 .state
4952 .reviews
4953 .last()
4954 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4955 {
4956 let shell = self.state.config.shell();
4957 return self
4958 .stop_reviewing(
4959 "the last round's own verification never resolved",
4960 &shell,
4961 &winner.worktree,
4962 )
4963 .await;
4964 }
4965
4966 let repo = self.state.repo.clone();
4967 let root = self.state.worktree_root();
4968 let language = self.state.config.graph.language.clone();
4969 let sessions = self.state.config.graph.sessions;
4970 let artifacts = agent::artifacts_dir(&self.state.dir());
4971 let base = self.landing_base();
4972 let base_short = short(&base);
4973 let reviewers = self.roles.reviewers.clone();
4974 let shell = self.state.config.shell();
4975
4976 for round in (self.state.reviews.len() + 1)..=max_rounds {
4977 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4978 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4979 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4980 let prev_verification = self
4989 .state
4990 .reviews
4991 .last()
4992 .and_then(|r| r.verification_summary(&head));
4993
4994 let mut jobs = Vec::new();
4998 for (r, spec) in reviewers.iter().cloned().enumerate() {
4999 let wt = root.join(format!("review-{}", r + 1));
5000 if wt.exists() {
5003 git::reset_detached(&wt, &head).await?;
5004 } else {
5005 git::worktree_add_detached(&repo, &wt, &head).await?;
5006 }
5007 self.setup_seat_worktree(&repo, &wt, false).await?;
5008 let seat_key = format!("review-{}", r + 1);
5009 let spec = pick_start_spec(
5012 &self.roles.reviewer_roster,
5013 spec,
5014 self.state.seat_history.get(&seat_key),
5015 );
5016 let seat = self.seat(&seat_key, &spec.id);
5017 jobs.push(SeatJob {
5018 prompt: prompt::review(&prompt::ReviewCtx {
5019 instruction: &self.state.instruction,
5020 branch: &winner.branch,
5021 base_short: &base_short,
5022 stat: &stat,
5023 patch: &patch,
5024 verification: prev_verification.as_ref(),
5025 reviewers: reviewers.len(),
5026 round,
5027 rounds: max_rounds,
5028 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5031 lens: Lens::for_seat(r),
5032 language: &language,
5033 }),
5034 spec,
5035 seat,
5036 cwd: wt,
5037 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5038 allow_write: false,
5039 sessions,
5040 artifacts: artifacts.clone(),
5041 stem: format!("review-{round}-{}", r + 1),
5042 handover: None,
5043 });
5044 }
5045
5046 self.state.event(
5047 "review",
5048 format!(
5049 "round {round}: {} reviewers on {}",
5050 jobs.len(),
5051 short(&head)
5052 ),
5053 );
5054 let mut quota_losses = Vec::new();
5055 let review_retries = self.state.config.graph.retries;
5056 let review_cache = self.state.config.cache_dir();
5057 let ctx = WaveCtx {
5058 carry_seats: true,
5059 run: &run_id,
5060 node: "review",
5061 prompts: &prompts,
5062 cache: review_cache.as_deref(),
5063 round: Some(round),
5064 };
5065 let results = ask_json_wave::<Review>(
5066 jobs,
5067 Arc::clone(&self.sem),
5068 review_retries,
5069 &self.roles.reviewer_roster,
5070 &ctx,
5071 &mut quota_losses,
5072 &mut self.state,
5073 &|_: &Review| Ok(()),
5074 )
5075 .await;
5076 let round_quota_missing = quota_losses.len();
5080 self.state.quota.extend(quota_losses);
5081
5082 let mut records = Vec::new();
5083 let mut all_findings = Vec::new();
5084 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5085 let agent_id = seat.agent.clone();
5086 self.state.seats.insert(seat.key.clone(), seat);
5087 let mut record = ReviewRecord {
5088 reviewer: r + 1,
5089 agent: agent_id,
5090 summary: String::new(),
5091 findings: Vec::new(),
5092 vote: None,
5093 failed: None,
5094 duration_ms: 0,
5095 attempts,
5101 };
5102 match res {
5103 Ok((review, out)) => {
5104 record.summary =
5112 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5113 record.vote = Some(review.vote);
5114 record.duration_ms = out.duration_ms;
5115 for (n, mut f) in review.findings.into_iter().enumerate() {
5116 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5119 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5120 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5121 f.file = f
5127 .file
5128 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5129 all_findings.push(f.clone());
5130 record.findings.push(f);
5131 }
5132 self.state.event(
5133 "review",
5134 format!(
5135 "round {round}: reviewer {} voted {} with {} finding(s)",
5136 r + 1,
5137 review.vote.label(),
5138 record.findings.len()
5139 ),
5140 );
5141 }
5142 Err(e) => {
5143 record.failed = Some(e.to_string());
5144 self.state.event(
5145 "review",
5146 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5147 );
5148 }
5149 }
5150 records.push(record);
5151 }
5152
5153 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5160 let vote_split =
5161 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5162 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5163 if vote_split {
5164 self.state.event(
5165 "review",
5166 format!(
5167 "round {round}: votes split ({}) — one round of reconsideration",
5168 initial_votes
5169 .iter()
5170 .map(|v| v.label())
5171 .collect::<Vec<_>>()
5172 .join(", ")
5173 ),
5174 );
5175 let panel: Vec<ReviewSeatReport<'_>> = records
5178 .iter()
5179 .filter_map(|r| {
5180 r.vote.map(|vote| ReviewSeatReport {
5181 reviewer: r.reviewer,
5182 vote,
5183 summary: &r.summary,
5184 findings: &r.findings,
5185 })
5186 })
5187 .collect();
5188
5189 let mut jobs = Vec::new();
5190 let mut seats_at = Vec::new();
5191 for (r, spec) in reviewers.iter().cloned().enumerate() {
5192 if records[r].vote.is_none() {
5196 continue;
5197 }
5198 let wt = root.join(format!("review-{}", r + 1));
5199 let seat_key = format!("review-{}", r + 1);
5200 let spec = self.occupant(&seat_key, spec);
5201 let seat = self.seat(&seat_key, &spec.id);
5202 let build = |with_patch: bool| {
5210 prompt::review_reconsider(&ReviewReconsiderCtx {
5211 instruction: &self.state.instruction,
5212 reviewer: r + 1,
5213 lens: Lens::for_seat(r),
5214 panel: &panel,
5215 patch: with_patch.then_some(ReviewPatch {
5216 branch: &winner.branch,
5217 base_short: &base_short,
5218 stat: &stat,
5219 patch: &patch,
5220 }),
5221 round,
5222 rounds: max_rounds,
5223 language: &language,
5224 })
5225 };
5226 let full = build(true);
5227 let prompt = if has_context(&spec, &seat, sessions) {
5228 build(false)
5229 } else {
5230 full.clone()
5231 };
5232 jobs.push(SeatJob {
5233 prompt,
5234 spec,
5235 seat,
5236 cwd: wt,
5237 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5238 allow_write: false,
5239 sessions,
5240 artifacts: artifacts.clone(),
5241 stem: format!("review-{round}-reconsider-{}", r + 1),
5242 handover: Some(full),
5243 });
5244 seats_at.push(r);
5245 }
5246
5247 let mut recon_quota_losses = Vec::new();
5248 let recon_cache = self.state.config.cache_dir();
5249 let recon_ctx = WaveCtx {
5250 carry_seats: true,
5251 run: &run_id,
5252 node: "review",
5253 prompts: &prompts,
5254 cache: recon_cache.as_deref(),
5255 round: Some(round),
5256 };
5257 let recon_results = ask_json_wave::<ReviewRevote>(
5258 jobs,
5259 Arc::clone(&self.sem),
5260 review_retries,
5261 &self.roles.reviewer_roster,
5262 &recon_ctx,
5263 &mut recon_quota_losses,
5264 &mut self.state,
5265 &|_: &ReviewRevote| Ok(()),
5266 )
5267 .await;
5268 self.state.quota.extend(recon_quota_losses);
5269
5270 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5271 let agent_id = seat.agent.clone();
5272 self.state.seats.insert(seat.key.clone(), seat);
5273 let mut rec = ReviewRevoteRecord {
5274 reviewer: r + 1,
5275 agent: agent_id,
5276 vote: None,
5277 reason: String::new(),
5278 failed: None,
5279 };
5280 match res {
5281 Ok((rv, _)) => {
5282 rec.vote = Some(rv.vote);
5283 rec.reason =
5284 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5285 self.state.event(
5286 "review",
5287 format!(
5288 "round {round}: reviewer {} revoted {}",
5289 r + 1,
5290 rv.vote.label()
5291 ),
5292 );
5293 }
5294 Err(e) => {
5295 rec.failed = Some(e.to_string());
5296 self.state.event(
5297 "review",
5298 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5299 );
5300 }
5301 }
5302 reconsideration.push(rec);
5303 }
5304 } else if initial_votes.len() > 1 {
5305 self.state.event(
5306 "review",
5307 format!(
5308 "round {round}: votes agreed ({}) — no reconsideration",
5309 initial_votes[0].label()
5310 ),
5311 );
5312 }
5313
5314 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5315 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5316 let defer_e2e =
5327 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5328 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5329 let reason =
5330 format!("{blocking} blocking finding(s) already required a fix this round");
5331 self.state.event(
5332 "verify",
5333 format!(
5334 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5335 {}); it will run once a round has none left",
5336 short(&head)
5337 ),
5338 );
5339 (Vec::new(), false, true, Some(reason))
5340 } else {
5341 let e2e_commands = self.state.config.verify.e2e.clone();
5342 let cache_dir = self.state.config.cache_dir();
5343 let context = format!("round {round}");
5344 let (e2e, verify_retried) = with_cache_lease(
5345 &mut self.state,
5346 cache_dir.as_deref(),
5347 "e2e",
5348 "e2e",
5349 &winner.worktree,
5350 &head,
5351 verify_timeout,
5352 &context,
5353 |state, budget| {
5354 let shell = shell.clone();
5355 let e2e_commands = e2e_commands.clone();
5356 let worktree = winner.worktree.clone();
5357 let context = context.clone();
5358 async move {
5359 run_e2e_with_retry(
5360 state,
5361 &shell,
5362 &e2e_commands,
5363 &worktree,
5364 budget,
5365 &context,
5366 )
5367 .await
5368 }
5369 },
5370 )
5371 .await;
5372 (e2e, verify_retried, false, None)
5373 };
5374
5375 let expected = records.len();
5376 let answered = records.iter().filter(|r| r.failed.is_none()).count();
5377 let incomplete = answered < expected;
5378 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5379 let policy = self.state.config.graph.incomplete_review;
5380 let clean = round_is_clean(
5381 blocking,
5382 e2e_ok,
5383 answered,
5384 expected,
5385 round_quota_missing,
5386 policy,
5387 );
5388
5389 let mut round_record = ReviewRound {
5390 round,
5391 head: head.clone(),
5392 verified_head: None,
5393 verified_at: None,
5394 reviews: records,
5395 e2e,
5396 verify_retried,
5397 e2e_deferred,
5398 e2e_defer_reason,
5399 fix: None,
5400 blocking,
5401 answered,
5402 expected,
5403 clean,
5404 progressed: false,
5405 vote_split,
5406 reconsideration,
5407 verdict: None,
5408 };
5409 round_record.verdict = ReviewVote::worst(
5413 round_record
5414 .final_votes()
5415 .into_iter()
5416 .map(|(_, _, vote)| vote),
5417 );
5418 if !matches!(
5429 round_record.e2e_status(),
5430 E2eStatus::Deferred | E2eStatus::NotConfigured
5431 ) {
5432 round_record.verified_head = Some(head.clone());
5433 round_record.verified_at = Some(Timestamp::now());
5434 }
5435 let this_round_verification = round_record.verification_summary(&head);
5436
5437 if incomplete {
5438 let missing: Vec<String> = round_record
5439 .reviews
5440 .iter()
5441 .filter(|r| r.failed.is_some())
5442 .map(|r| format!("review-{}", r.reviewer))
5443 .collect();
5444 self.state.event(
5445 "review",
5446 format!(
5447 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5448 missing.join(", ")
5449 ),
5450 );
5451 }
5452
5453 if clean {
5454 self.state.event(
5455 "review",
5456 if incomplete && policy == IncompleteReviewPolicy::Warn {
5457 format!(
5458 "round {round}: clean (warn policy, incomplete panel) — no \
5459 blocking findings from the seats that answered, verification green"
5460 )
5461 } else if incomplete {
5462 format!(
5463 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5464 quorum) — no blocking findings from the seats that answered, \
5465 verification green",
5466 expected - answered
5467 )
5468 } else {
5469 format!("round {round}: clean — no blocking findings, verification green")
5470 },
5471 );
5472 self.state.reviews.push(round_record);
5473 self.state.status = RunStatus::Gating;
5474 self.state.save()?;
5475 return Ok(());
5476 }
5477
5478 if incomplete && blocking == 0 && e2e_ok {
5486 self.state.reviews.push(round_record);
5487 self.state.save()?;
5488 if round == max_rounds {
5489 self.state.status = RunStatus::Blocked;
5490 self.state.event(
5491 "review",
5492 format!(
5493 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5494 refusing to call it clean",
5495 expected - answered
5496 ),
5497 );
5498 return Ok(());
5499 }
5500 continue;
5501 }
5502
5503 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5515 self.state.reviews.push(round_record);
5516 return self
5517 .stop_reviewing(
5518 "the round's own verification could not run",
5519 &shell,
5520 &winner.worktree,
5521 )
5522 .await;
5523 }
5524
5525 if round == max_rounds {
5526 self.state.reviews.push(round_record);
5527 return self
5528 .stop_reviewing(
5529 &format!(
5530 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5531 ),
5532 &shell,
5533 &winner.worktree,
5534 )
5535 .await;
5536 }
5537
5538 let blocking_findings: Vec<_> = all_findings
5541 .iter()
5542 .filter(|f| f.severity.blocks())
5543 .cloned()
5544 .collect();
5545 let fix_prompt = prompt::fix(
5546 &self.state.instruction,
5547 &blocking_findings,
5548 this_round_verification.as_ref(),
5549 round,
5550 max_rounds,
5551 &language,
5552 );
5553 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5554 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5555 let (job, seat, out) = self
5556 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5557 prompt: fix_prompt.clone(),
5558 spec,
5559 seat,
5560 cwd: winner.worktree.clone(),
5561 timeout,
5562 allow_write: true,
5563 sessions,
5564 artifacts: artifacts.clone(),
5565 stem: format!("fix-{round}"),
5566 handover: None,
5567 })
5568 .await;
5569 let agent_id = seat.agent.clone();
5570
5571 let mut fix = FixRecord {
5572 agent: agent_id,
5573 addressed: Vec::new(),
5574 rejected: Vec::new(),
5575 notes: String::new(),
5576 committed: false,
5577 failed: None,
5578 duration_ms: 0,
5579 continuation: None,
5580 };
5581 let mut continuation = ContinuationRecord::not_needed();
5582 let mut final_seat = seat.clone();
5583 match out {
5584 AgentOutcome::Ok(o) => {
5585 fix.duration_ms = o.duration_ms;
5586 let parsed = verdict::extract_json::<FixReport>(&o.text);
5587 let incomplete_reason = match &parsed {
5594 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5595 "the reply parsed, but it reported a command whose own CLI never \
5596 confirmed an exit status"
5597 .to_owned(),
5598 ),
5599 Ok(_) => None,
5600 Err(e) => Some(e.to_string()),
5601 };
5602 match incomplete_reason {
5603 None => {
5604 let report = parsed.expect("checked Ok above");
5605 fix.addressed = report.addressed;
5606 fix.rejected = report.rejected;
5607 fix.notes =
5608 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5609 }
5610 Some(reason) => {
5611 let (resumed_seat, resolved, failure, cont) = self
5612 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5613 .await;
5614 fix.duration_ms += cont.cumulative_wait_ms;
5615 continuation = cont;
5616 final_seat = resumed_seat;
5617 match resolved {
5618 Some(report) => {
5619 fix.addressed = report.addressed;
5620 fix.rejected = report.rejected;
5621 fix.notes = blind::sanitize_prose(
5622 &report.notes,
5623 &self.state.config.blind,
5624 );
5625 }
5626 None => fix.failed = failure,
5627 }
5628 }
5629 }
5630 }
5631 AgentOutcome::Dropped(o) => {
5633 fix.duration_ms = o.duration_ms;
5634 let why = o
5635 .dropped
5636 .as_ref()
5637 .map(|d| d.why.as_str())
5638 .unwrap_or("the CLI ended the stream without delivering its answer");
5639 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5640 }
5641 AgentOutcome::Quota(o) => {
5642 self.state.quota.push(QuotaLoss {
5643 seat: final_seat.key.clone(),
5644 node: "fix".to_owned(),
5645 at: Timestamp::now(),
5646 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5647 });
5648 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5649 }
5650 AgentOutcome::Failed(e) => fix.failed = Some(e),
5651 }
5652 fix.continuation = Some(continuation);
5653 self.state.seats.insert(final_seat.key.clone(), final_seat);
5654 if let Ok(r) = git::rescue_commit(
5655 &winner.worktree,
5656 &format!("magi: review round {round} fixes (uncommitted work)"),
5657 )
5658 .await
5659 {
5660 self.state.note_withheld("fix", &r.withheld);
5661 }
5662 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5663 fix.committed = after != before;
5664 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5672 let progressed = diff_after != patch;
5673 let commit_note = if fix.committed {
5674 "committed"
5675 } else {
5676 "NO new commit"
5677 };
5678 let tree_note = if progressed {
5679 "changed vs base"
5680 } else {
5681 "unchanged vs base"
5682 };
5683 self.state.event(
5684 "fix",
5685 match &fix.failed {
5686 Some(reason) => {
5692 format!(
5693 "round {round}: fixer's adoption report was lost ({reason}); \
5694 {commit_note}, tree {tree_note}"
5695 )
5696 }
5697 None => format!(
5698 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5699 {tree_note}{}",
5700 fix.addressed.len(),
5701 fix.rejected.len(),
5702 if continuation.outcome == ContinuationOutcome::Resumed {
5703 format!(
5704 " (adoption report recovered after {} continuation(s))",
5705 continuation.attempts
5706 )
5707 } else {
5708 String::new()
5709 },
5710 ),
5711 },
5712 );
5713 round_record.fix = Some(fix);
5714 round_record.progressed = progressed;
5715 self.state.reviews.push(round_record);
5716 self.state.save()?;
5717
5718 if matches!(
5731 continuation.outcome,
5732 ContinuationOutcome::Exhausted
5733 | ContinuationOutcome::QuotaLost
5734 | ContinuationOutcome::NoSession
5735 ) {
5736 return self
5737 .stop_reviewing(
5738 "the fixer's adoption report never came back, even after resuming its \
5739 own seat; refusing to start another round against the same worktree \
5740 while that is unresolved",
5741 &shell,
5742 &winner.worktree,
5743 )
5744 .await;
5745 }
5746
5747 let streak = self
5748 .state
5749 .reviews
5750 .iter()
5751 .rev()
5752 .take_while(|r| !r.progressed)
5753 .count();
5754 if streak >= STAGNANT_LIMIT {
5755 return self
5756 .stop_reviewing(
5757 &format!(
5758 "the tree has not moved against base for {streak} round(s) in a row"
5759 ),
5760 &shell,
5761 &winner.worktree,
5762 )
5763 .await;
5764 }
5765 }
5766 Ok(())
5767 }
5768
5769 fn record_contested_handoff(&mut self) {
5803 if self.state.contested_handoff.is_some() {
5804 return;
5805 }
5806 let Some(contested) = self
5807 .state
5808 .reviews
5809 .last()
5810 .and_then(ReviewRound::contested_handoff)
5811 else {
5812 return;
5813 };
5814 self.state.event(
5815 "review",
5816 format!(
5817 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5818 wait for the owner's approval",
5819 contested.findings.len(),
5820 contested.rejecters.len()
5821 ),
5822 );
5823 self.state.contested_handoff = Some(contested);
5824 }
5825
5826 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5827 let round_idx = self.state.reviews.len() - 1;
5828 let needs_catchup_run = matches!(
5836 self.state.reviews[round_idx].e2e_status(),
5837 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5838 );
5839 if needs_catchup_run {
5840 let round = self.state.reviews[round_idx].round;
5841 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5842 let commands = self.state.config.verify.e2e.clone();
5843 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5844 let cache_dir = self.state.config.cache_dir();
5845 let context = format!(
5846 "round {round}: verification unresolved, catching up before the final decision"
5847 );
5848 let (outcomes, verify_retried) = with_cache_lease(
5849 &mut self.state,
5850 cache_dir.as_deref(),
5851 "e2e",
5852 "e2e",
5853 worktree,
5854 &attempted_head,
5855 timeout,
5856 &context,
5857 |state, budget| {
5858 let shell = shell.to_vec();
5859 let commands = commands.clone();
5860 let context = context.clone();
5861 async move {
5862 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5863 .await
5864 }
5865 },
5866 )
5867 .await;
5868 let last = &mut self.state.reviews[round_idx];
5869 last.e2e = outcomes;
5870 last.verify_retried = verify_retried;
5871 last.verified_head = Some(attempted_head);
5878 last.verified_at = Some(Timestamp::now());
5879 if verify_inconclusive(&last.e2e) {
5880 self.state.save()?;
5887 return Ok(());
5888 }
5889 last.e2e_deferred = false;
5890 }
5891 let last = &self.state.reviews[round_idx];
5892 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5893
5894 match last.e2e_status() {
5895 E2eStatus::Failed => {
5896 let red: Vec<String> = last
5897 .e2e
5898 .iter()
5899 .filter(|o| !o.ok())
5900 .map(|o| {
5901 format!(
5902 "`{}` -> {:?}\n{}",
5903 o.command,
5904 o.code,
5905 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5906 )
5907 })
5908 .collect();
5909 self.state
5910 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5911 self.state.status = RunStatus::Blocked;
5912 }
5913 E2eStatus::ResourceBlocked => {
5918 self.state.event(
5919 "review",
5920 format!(
5921 "{why}; e2e could not run (shared build cache unavailable); not \
5922 deciding yet"
5923 ),
5924 );
5925 }
5926 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5927 self.state.event(
5928 "review",
5929 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5930 );
5931 self.record_contested_handoff();
5932 self.state.status = RunStatus::Gating;
5933 }
5934 }
5935 self.state.save()?;
5936 Ok(())
5937 }
5938
5939 async fn gate(&mut self) -> Result<()> {
5942 if self.state.status == RunStatus::Failed
5954 || self
5955 .state
5956 .base_sync
5957 .as_ref()
5958 .is_some_and(|s| s.conflict.is_some())
5959 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5960 != Some(RunStatus::Gating)
5961 {
5962 return Ok(());
5963 }
5964 if self.state.gate_ran {
5965 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5976 self.state.status = RunStatus::Blocked;
5977 self.state.save()?;
5978 }
5979 return Ok(());
5980 }
5981 let Some(winner) = self.state.winner().cloned() else {
5982 return Ok(());
5983 };
5984 self.state.status = RunStatus::Gating;
5985 let mut outcomes = self.run_gate(&winner).await?;
5986 loop {
5987 if verify_inconclusive(&outcomes) {
5998 self.state.save()?;
5999 return Ok(());
6000 }
6001 if outcomes.iter().all(CommandOutcome::ok) {
6002 break;
6003 }
6004 match self.gate_fix_round(&winner, &outcomes).await? {
6005 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6006 GateFix::Stop => break,
6007 GateFix::Defer => {
6008 self.state.save()?;
6009 return Ok(());
6010 }
6011 }
6012 }
6013 let passed = outcomes.iter().all(CommandOutcome::ok);
6014 self.state.gate = outcomes;
6015 self.state.gate_ran = true;
6016 if !passed {
6017 self.state.status = RunStatus::Blocked;
6018 let spent = self.state.gate_fixes.len();
6019 self.state.event(
6020 "gate",
6021 if spent == 0 {
6022 "gate failed; not merging".to_owned()
6023 } else {
6024 format!("gate failed after {spent} gate-fix round(s); not merging")
6025 },
6026 );
6027 }
6028 self.state.save()?;
6029 Ok(())
6030 }
6031
6032 async fn run_pre_gate(&mut self, winner: &Candidate) {
6042 let commands = self.state.config.verify.pre_gate.clone();
6043 if commands.is_empty() {
6044 return;
6045 }
6046 let shell = self.state.config.shell();
6047 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6048 let (outcomes, _) = run_commands(
6049 &mut self.state,
6050 "pre_gate",
6051 "pre_gate",
6052 0,
6053 &shell,
6054 &commands,
6055 &winner.worktree,
6056 timeout,
6057 )
6058 .await;
6059 for o in &outcomes {
6060 if !o.ok() {
6061 tracing::warn!(
6062 "pre_gate `{}` failed ({:?}); the gate decides",
6063 o.command,
6064 o.code
6065 );
6066 }
6067 self.state.event(
6068 "pre_gate",
6069 format!(
6070 "`{}` -> {}",
6071 o.command,
6072 if o.ok() {
6073 "pass".to_owned()
6074 } else {
6075 format!(
6076 "FAIL ({:?})\n{}",
6077 o.code,
6078 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6079 )
6080 }
6081 ),
6082 );
6083 }
6084 self.state.pre_gate = outcomes;
6085 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6086 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6087 Ok(head) => {
6088 self.state
6089 .event("pre_gate", format!("committed mechanical fixes ({head})"));
6090 self.state.pre_gate_commit = Some(head);
6091 }
6092 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6093 },
6094 Ok(false) => {}
6095 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6096 }
6097 if let Err(e) = self.state.save() {
6098 tracing::warn!("could not persist the pre_gate record: {e:#}");
6099 }
6100 }
6101
6102 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6105 self.run_pre_gate(winner).await;
6106 let shell = self.state.config.shell();
6107 let gate_commands = self.state.config.verify.gate.clone();
6108 let outcomes = if gate_commands.is_empty() {
6117 Vec::new()
6118 } else {
6119 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6120 let cache_dir = self.state.config.cache_dir();
6121 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6122 let (outcomes, _) = with_cache_lease(
6123 &mut self.state,
6124 cache_dir.as_deref(),
6125 "gate",
6126 "gate",
6127 &winner.worktree,
6128 &head,
6129 timeout,
6130 "final gate",
6131 |state, budget| {
6132 let shell = shell.clone();
6133 let gate_commands = gate_commands.clone();
6134 let worktree = winner.worktree.clone();
6135 async move {
6136 let (outcomes, timed_out_pids) = run_commands(
6137 state,
6138 "gate",
6139 "gate",
6140 0,
6141 &shell,
6142 &gate_commands,
6143 &worktree,
6144 budget,
6145 )
6146 .await;
6147 (outcomes, false, timed_out_pids)
6148 }
6149 },
6150 )
6151 .await;
6152 outcomes
6153 };
6154 if outcomes.is_empty() {
6155 self.state.event(
6160 "gate",
6161 "no gate commands configured; nothing to check, passing",
6162 );
6163 }
6164 for o in &outcomes {
6165 self.state.event(
6166 "gate",
6167 format!(
6168 "`{}` -> {}",
6169 o.command,
6170 if o.ok() {
6171 "pass".to_owned()
6172 } else {
6173 format!(
6174 "FAIL ({:?})\n{}",
6175 o.code,
6176 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6177 )
6178 }
6179 ),
6180 );
6181 }
6182 Ok(outcomes)
6183 }
6184
6185 async fn gate_fix_round(
6198 &mut self,
6199 winner: &Candidate,
6200 outcomes: &[CommandOutcome],
6201 ) -> Result<GateFix> {
6202 let cap = self.state.config.graph.gate_fix_rounds;
6203 let spent = self.state.gate_fixes.len();
6204 if spent >= cap {
6205 if cap > 0 {
6206 self.state.event(
6207 "gate",
6208 format!("{spent} gate-fix round(s) spent and the gate still fails"),
6209 );
6210 }
6211 return Ok(GateFix::Stop);
6212 }
6213 if !gate_fixable(outcomes) {
6214 self.state.event(
6215 "gate",
6216 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6217 command or similar); not spending a fix round on it",
6218 );
6219 return Ok(GateFix::Stop);
6220 }
6221 let min_free = self.state.config.disk.min_free_bytes;
6222 if min_free > 0 {
6223 match crate::disk::free_bytes(&winner.worktree) {
6224 Ok(free) if crate::disk::enough_space(free, min_free) => {}
6225 Ok(free) => {
6226 self.state.event(
6227 "gate",
6228 format!(
6229 "only {free} bytes free ({min_free} required by `[disk] \
6230 min_free_bytes`); not spending a fix round on a failure the disk \
6231 may explain"
6232 ),
6233 );
6234 return Ok(GateFix::Stop);
6235 }
6236 Err(e) => {
6237 self.state.event(
6238 "gate",
6239 format!("free disk space could not be measured ({e:#}); no fix round"),
6240 );
6241 return Ok(GateFix::Stop);
6242 }
6243 }
6244 }
6245
6246 let attempt = spent + 1;
6247 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6248 let base = self.landing_base();
6249 let fix_prompt = prompt::gate_fix(
6250 &self.state.instruction,
6251 &failed,
6252 attempt,
6253 cap,
6254 &self.state.config.graph.language,
6255 );
6256 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6257 let sessions = self.state.config.graph.sessions;
6258 let artifacts = agent::artifacts_dir(&self.state.dir());
6259 self.state.event(
6260 "gate",
6261 format!("gate failed; gate-fix round {attempt} of {cap}"),
6262 );
6263 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6264 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6265 let (_, seat, out) = self
6266 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6267 prompt: fix_prompt.clone(),
6268 spec,
6269 seat,
6270 cwd: winner.worktree.clone(),
6271 timeout,
6272 allow_write: true,
6273 sessions,
6274 artifacts: artifacts.clone(),
6275 stem: format!("gate-fix-{attempt}"),
6276 handover: None,
6277 })
6278 .await;
6279 let mut record = GateFixRecord {
6280 agent: seat.agent.clone(),
6281 failed,
6282 notes: String::new(),
6283 committed: false,
6284 error: None,
6285 };
6286 match out {
6287 AgentOutcome::Ok(o) => {
6288 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6291 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6292 }
6293 }
6294 AgentOutcome::Dropped(_) => {
6295 record.error = Some("the CLI dropped the stream".to_owned());
6296 }
6297 AgentOutcome::Quota(o) => {
6298 self.state.quota.push(QuotaLoss {
6299 seat: seat.key.clone(),
6300 node: "gate-fix".to_owned(),
6301 at: Timestamp::now(),
6302 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6303 });
6304 record.error = Some("rate limited (quota); fixer could not run".to_owned());
6305 }
6306 AgentOutcome::Failed(e) => record.error = Some(e),
6307 }
6308 self.state.seats.insert(seat.key.clone(), seat);
6309 if let Ok(r) = git::rescue_commit(
6310 &winner.worktree,
6311 &format!("magi: gate fix {attempt} (uncommitted work)"),
6312 )
6313 .await
6314 {
6315 self.state.note_withheld("gate-fix", &r.withheld);
6316 }
6317 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6318 record.committed = after != before;
6319 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6320 let note = record.error.clone();
6321 self.state.gate_fixes.push(record);
6322 self.state.save()?;
6323 if !changed {
6324 self.state.event(
6325 "gate",
6326 match note {
6327 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6328 None => format!("gate-fix round {attempt}: the tree did not change"),
6329 },
6330 );
6331 return Ok(GateFix::Stop);
6332 }
6333 self.state.event(
6334 "gate",
6335 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6336 );
6337
6338 let commands = self.state.config.verify.e2e.clone();
6339 if !commands.is_empty() {
6340 let shell = self.state.config.shell();
6341 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6342 let cache_dir = self.state.config.cache_dir();
6343 let context = format!("gate-fix round {attempt}");
6344 let (e2e, _) = with_cache_lease(
6345 &mut self.state,
6346 cache_dir.as_deref(),
6347 "e2e",
6348 "e2e",
6349 &winner.worktree,
6350 &after,
6351 timeout,
6352 &context,
6353 |state, budget| {
6354 let shell = shell.clone();
6355 let commands = commands.clone();
6356 let context = context.clone();
6357 let worktree = winner.worktree.clone();
6358 async move {
6359 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6360 .await
6361 }
6362 },
6363 )
6364 .await;
6365 if verify_inconclusive(&e2e) {
6366 return Ok(GateFix::Defer);
6367 }
6368 if e2e.iter().any(|o| !o.ok()) {
6369 self.state.event(
6370 "gate",
6371 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6372 );
6373 return Ok(GateFix::Stop);
6374 }
6375 }
6376 Ok(GateFix::Retry)
6377 }
6378
6379 async fn guarded_pr_message(
6386 &mut self,
6387 winner: &Candidate,
6388 facts: Option<&BranchFacts>,
6389 posting: bool,
6390 ) -> Result<Option<PrMessage>> {
6391 let mut pr = pr_message_raw(&self.state, winner.label, facts);
6392 if !posting {
6393 let identity = crate::scrub::Identity::current();
6394 return Ok(Some(PrMessage {
6395 title: crate::scrub::scrub(&pr.title, &identity),
6396 body: crate::scrub::scrub(&pr.body, &identity),
6397 }));
6398 }
6399 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6402 if let Some(known) = self
6403 .state
6404 .github_text
6405 .clone()
6406 .filter(|g| g.fingerprint == raw_fp)
6407 {
6408 return self.settle_withheld(winner, &pr, known).await;
6409 }
6410 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6411 let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6412 let identity = crate::scrub::Identity::current();
6413 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6414 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6415 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6416 {
6417 violations.push(crate::github_text::Violation::SensitiveData);
6418 }
6419 let has_language = violations
6423 .iter()
6424 .any(|v| *v != crate::github_text::Violation::SensitiveData);
6425 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6426 self.state.event(
6427 "github-text",
6428 if has_language {
6429 format!("description rejected: {violations:?}; requesting one rewrite")
6430 } else {
6431 format!("description rejected: {violations:?}; asking the owner")
6432 },
6433 );
6434 let mut rewritten = false;
6435 if has_language
6436 && let Some(spec) = self
6437 .state
6438 .config
6439 .agents
6440 .iter()
6441 .find(|a| a.id == winner.agent)
6442 .cloned()
6443 {
6444 let key = format!("impl-{}", winner.label);
6445 let seat = self.seat(&key, &spec.id);
6446 let prompt = format!(
6447 "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{}",
6448 pr.title, pr.body
6449 );
6450 let job = SeatJob {
6451 spec,
6452 seat,
6453 cwd: winner.worktree.clone(),
6454 prompt,
6455 timeout: retry_budget(
6456 Duration::from_secs(self.state.config.graph.timeout_implement),
6457 true,
6458 ),
6459 allow_write: false,
6460 sessions: self.state.config.graph.sessions,
6461 artifacts: agent::artifacts_dir(&self.state.dir()),
6462 stem: "github-text-rewrite".to_owned(),
6463 handover: None,
6464 };
6465 let prompts = self.state.config.prompts.clone();
6466 let cache = self.state.config.cache_dir();
6467 let run = self.state.id.clone();
6468 let ctx = WaveCtx {
6469 run: &run,
6470 node: "github-text",
6471 prompts: &prompts,
6472 cache: cache.as_deref(),
6473 round: None,
6474 carry_seats: false,
6475 };
6476 let (seat, outcome) =
6477 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6478 self.state.seats.insert(seat.key.clone(), seat);
6479 if let AgentOutcome::Ok(output) = outcome
6480 && let Some(title) = summary_title(&output.text)
6481 {
6482 let mut body = summary_without_title(&output.text);
6483 let rewrite = PrMessage {
6484 title: title.clone(),
6485 body: body.clone(),
6486 };
6487 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6488 if !body.trim().is_empty()
6489 && crate::github_text::check_with(&title, &body, redecision).is_empty()
6490 && crate::github_text::shareable(&title)
6491 && crate::github_text::shareable(&body)
6492 {
6493 decision = redecision;
6494 let marker = format!("magi:run/{}", self.state.id);
6495 if !body.contains(&marker) {
6496 body.push_str(&format!("\n\n{marker}"));
6497 }
6498 pr = PrMessage { title, body };
6499 rewritten = true;
6500 self.state
6501 .event("github-text", "description rewrite passed");
6502 }
6503 }
6504 }
6505 if !rewritten {
6506 self.state.event(
6507 "github-text",
6508 "description rewrite unavailable or rejected; asking the owner",
6509 );
6510 if let Some(w) =
6511 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6512 {
6513 self.retire_github_text_question("the withheld text changed");
6516 let record = crate::run::GithubTextAsk {
6517 fingerprint: raw_fp,
6518 title: w.title,
6519 body: w.body,
6520 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6521 question: None,
6522 asks: 0,
6523 resolved: false,
6524 chosen_title: None,
6525 choice: None,
6526 };
6527 return self.settle_withheld(winner, &pr, record).await;
6528 }
6529 }
6530 }
6531 let (title, body) =
6532 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6533 Ok(Some(PrMessage { title, body }))
6534 }
6535
6536 fn retire_github_text_question(&mut self, why: &str) {
6538 let Some(id) = self
6539 .state
6540 .github_text
6541 .as_ref()
6542 .and_then(|g| g.question.clone())
6543 else {
6544 return;
6545 };
6546 let _ = ask::Questions::open().update(&id, |q| {
6547 q.abandon(why);
6548 Ok(())
6549 });
6550 }
6551
6552 async fn settle_withheld(
6556 &mut self,
6557 winner: &Candidate,
6558 pr: &PrMessage,
6559 mut g: crate::run::GithubTextAsk,
6560 ) -> Result<Option<PrMessage>> {
6561 use crate::github_text as gt;
6562 let store = ask::Questions::open();
6563 let timeout = self.state.config.graph.answer_timeout;
6564 if !g.resolved {
6565 let mut standing = g
6566 .question
6567 .as_ref()
6568 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6569 if let Some(q) = standing.as_ref()
6570 && q.status.open()
6571 && gt::expired(q, timeout)
6572 {
6573 let why = format!("no answer within {}s of asking", timeout.max(1));
6574 standing = store
6575 .update(&q.id, |q| {
6576 q.abandon(&why);
6577 Ok(())
6578 })
6579 .ok()
6580 .map(|(q, ())| q);
6581 }
6582 let mut ask_again: Option<Option<Vec<String>>> = None;
6583 match standing {
6584 None if g.asks <= 1 => {
6587 g.asks = 0;
6588 ask_again = Some(None);
6589 }
6590 None => {
6591 g.resolved = true;
6592 g.chosen_title = None;
6593 g.choice = Some(GithubTextChoice::Fallback);
6594 }
6595 Some(q) if q.status.open() => {
6596 self.park_on_github_text(&g)?;
6597 return Ok(None);
6598 }
6599 Some(q) => match gt::read_reply(&q) {
6600 gt::Reply::Fallback => {
6601 g.resolved = true;
6602 g.chosen_title = None;
6603 g.choice = Some(GithubTextChoice::Fallback);
6604 }
6605 gt::Reply::Original => {
6606 g.resolved = true;
6607 g.chosen_title = None;
6608 g.choice = Some(GithubTextChoice::Original);
6609 }
6610 gt::Reply::Title(text) => {
6611 let first = text
6612 .lines()
6613 .map(str::trim)
6614 .find(|l| !l.is_empty())
6615 .unwrap_or("");
6616 let decision = if first.is_empty() {
6617 None
6618 } else {
6619 let candidate = PrMessage {
6620 title: first.to_owned(),
6621 body: String::new(),
6622 };
6623 self.judge_pr_language(&winner.worktree, &candidate).await
6624 };
6625 match gt::vet_title(&text, decision) {
6626 Ok(title) => {
6627 g.resolved = true;
6628 g.chosen_title = Some(title);
6629 g.choice = Some(GithubTextChoice::MyTitle);
6630 }
6631 Err(cats) if g.asks < 2 => {
6632 ask_again = Some(Some(
6633 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
6634 ))
6635 }
6636 Err(_) => {
6637 g.resolved = true;
6638 g.chosen_title = None;
6639 g.choice = Some(GithubTextChoice::Fallback);
6640 }
6641 }
6642 }
6643 },
6644 }
6645 if let Some(rejected) = ask_again {
6646 let w = gt::Withheld {
6649 title: g.title,
6650 body: g.body,
6651 categories: g.categories.clone(),
6652 };
6653 let artifact = match crate::run::write_artifact(
6656 &self.state,
6657 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
6658 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id),
6659 ) {
6660 Ok(p) => Some(p),
6661 Err(e) => {
6662 tracing::warn!("could not write the withheld-text artifact: {e:#}");
6663 None
6664 }
6665 };
6666 let mut q = ask::Question::new(
6667 self.state.id.clone(),
6668 gt::ASK_NODE.to_owned(),
6669 gt::ASK_SEAT.to_owned(),
6670 gt::question_summary(&self.state.config.graph.language, &w),
6671 gt::question_detail(
6672 &w,
6673 &pr.title,
6674 &pr.body,
6675 &self.state.id,
6676 rejected.as_deref(),
6677 artifact.as_deref(),
6678 ),
6679 gt::question_choices(&w),
6680 );
6681 q.answer_timeout = timeout;
6682 g.question = Some(q.id.clone());
6685 g.asks += 1;
6686 self.state.github_text = Some(g.clone());
6687 self.state.event(
6688 "github-text",
6689 format!("asking the owner about the withheld text ({})", q.short()),
6690 );
6691 self.state.save()?;
6692 store
6693 .put(&mut q)
6694 .context("file the posting-gate question")?;
6695 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6696 tracing::warn!(
6697 "could not notify about posting-gate question {}: {e:#}",
6698 q.short()
6699 );
6700 }
6701 self.park_on_github_text(&g)?;
6702 return Ok(None);
6703 }
6704 }
6705 let resolved = g.chosen_title.clone();
6706 let original = g.choice == Some(GithubTextChoice::Original);
6707 self.state.event(
6709 "github-text",
6710 if original {
6711 format!(
6712 "posting the original text for: {}",
6713 match (g.title, g.body) {
6714 (true, true) => "title, description",
6715 (true, false) => "title",
6716 _ => "description",
6717 }
6718 )
6719 } else if resolved.is_some() {
6720 "posting the owner's replacement title".to_owned()
6721 } else {
6722 "posting the neutral text for the withheld fields".to_owned()
6723 },
6724 );
6725 self.state.github_text = Some(g.clone());
6726 self.state.save()?;
6727 let w = gt::Withheld {
6728 title: g.title,
6729 body: g.body,
6730 categories: g.categories.clone(),
6731 };
6732 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
6733 if let Some(t) = resolved {
6734 title = t;
6735 }
6736 let english = gt::LanguageDecision {
6738 title_english: true,
6739 body_english: true,
6740 body_complete: true,
6741 };
6742 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6743 if original {
6744 if g.title {
6747 title = pr.title.clone();
6748 }
6749 if g.body {
6750 body = pr.body.clone();
6751 }
6752 }
6753 Ok(Some(PrMessage { title, body }))
6754 }
6755
6756 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6757 self.state.github_text = Some(g.clone());
6758 self.state.parked = true;
6759 self.state.event(
6760 "github-text",
6761 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6762 );
6763 self.state.save()
6764 }
6765
6766 async fn judge_pr_language(
6769 &mut self,
6770 cwd: &Path,
6771 pr: &PrMessage,
6772 ) -> Option<crate::github_text::LanguageDecision> {
6773 self.state.config.roles.language_judge.as_ref()?;
6774 let decision =
6775 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6776 self.state.event(
6777 "github-text",
6778 if decision.is_some() {
6779 "language decided by the language judge"
6780 } else {
6781 "language judge unavailable; using built-in heuristics"
6782 },
6783 );
6784 decision
6785 }
6786
6787 async fn merge(&mut self) -> Result<()> {
6788 if self
6803 .state
6804 .base_sync
6805 .as_ref()
6806 .is_some_and(|s| s.conflict.is_some())
6807 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6808 != Some(RunStatus::Gating)
6809 || !self.state.gate_status().ok()
6818 {
6819 return Ok(());
6820 }
6821 if self.state.merge.is_some() && !self.state.pr_publication_pending {
6830 return Ok(());
6831 }
6832 let Some(winner) = self.state.winner().cloned() else {
6833 return Ok(());
6834 };
6835 let repo = self.state.repo.clone();
6836 let base = self.state.base_branch.clone();
6837 let mode = self.state.config.merge.mode;
6838 let style = self.state.config.merge.style;
6839 let facts = if is_review_run(&self.state) {
6840 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6841 let start = review_base(
6842 &repo,
6843 &self.state.config.merge.remote,
6844 &base,
6845 &self.state.base_commit,
6846 &winner.branch,
6847 )
6848 .await;
6849 branch_facts(&repo, &start, &winner.branch).await
6850 } else {
6851 None
6852 };
6853 let leaked = if is_review_run(&self.state) {
6856 leaked_subjects(&self.state, &winner.branch).await
6857 } else {
6858 Some(Vec::new())
6859 };
6860 let Some(pr) = self
6864 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6865 .await?
6866 else {
6867 return Ok(());
6868 };
6869 let message = pr.commit_message();
6870
6871 let outcome = match mode {
6872 MergeMode::None => MergeOutcome {
6873 mode,
6874 ok: true,
6875 detail: manual_merge_command(style, &repo, &winner.branch, &message),
6876 empty: false,
6877 },
6878 MergeMode::Pr | MergeMode::Local
6879 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6880 {
6881 MergeOutcome {
6882 mode,
6883 ok: false,
6884 detail: empty_candidate_detail(&self.state, &base),
6885 empty: true,
6886 }
6887 }
6888 MergeMode::Local => {
6889 let on = git::current_branch(&repo).await?;
6890 if on.as_deref() != Some(base.as_str()) {
6891 MergeOutcome {
6892 mode,
6893 ok: false,
6894 detail: format!(
6895 "{} has {} checked out, not the base branch {base}",
6896 repo.display(),
6897 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6898 ),
6899 empty: false,
6900 }
6901 } else if !git::is_clean(&repo).await? {
6902 MergeOutcome {
6903 mode,
6904 ok: false,
6905 detail: format!("{} is dirty; refusing to merge", repo.display()),
6906 empty: false,
6907 }
6908 } else {
6909 let out = match style {
6910 MergeStyle::Merge => {
6911 git::merge_no_ff(&repo, &winner.branch, &message).await?
6912 }
6913 MergeStyle::Squash => {
6914 git::merge_squash(&repo, &winner.branch, &message).await?
6915 }
6916 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6917 };
6918 MergeOutcome {
6919 mode,
6920 ok: out.ok(),
6921 detail: if out.ok() { out.stdout } else { out.stderr },
6922 empty: false,
6923 }
6924 }
6925 }
6926 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
6927 self.state.pr_publication_pending = true;
6928 self.state.save()?;
6929 let out =
6930 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
6931 self.state.pr_publication_pending = out.is_err();
6932 MergeOutcome {
6933 mode,
6934 ok: out.is_ok(),
6935 detail: match out {
6936 Ok(url) => url,
6937 Err(e) => format!("{e:#}"),
6938 },
6939 empty: false,
6940 }
6941 }
6942 MergeMode::Pr => {
6943 let remote = self.state.config.merge.remote.clone();
6944 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6945 if !pushed.ok() {
6946 MergeOutcome {
6947 mode,
6948 ok: false,
6949 detail: pushed.stderr,
6950 empty: false,
6951 }
6952 } else {
6953 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6959 let out = match pr_merge_plan(found) {
6960 PrPlan::Create => {
6961 gh_pr_create(
6962 &winner.worktree,
6963 &base,
6964 &winner.branch,
6965 &pr.title,
6966 &pr.body,
6967 )
6968 .await
6969 }
6970 PrPlan::Adopt { url, title } => {
6971 self.state
6972 .event("merge", format!("Pr: adopted open pull request {url}"));
6973 if title != pr.title
6974 && (!is_review_run(&self.state)
6975 || leaked
6976 .as_deref()
6977 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6978 && let Err(e) =
6979 if self.state.github_text.as_ref().is_some_and(|g| {
6980 g.resolved
6981 && g.title
6982 && g.choice == Some(GithubTextChoice::Original)
6983 }) {
6984 land::set_pr_title_verbatim(
6986 &winner.worktree,
6987 &url,
6988 &pr.title,
6989 )
6990 .await
6991 } else {
6992 land::set_pr_title(
6993 &mut self.state,
6994 &winner.worktree,
6995 &url,
6996 &pr.title,
6997 )
6998 .await
6999 }
7000 {
7001 tracing::warn!("could not refresh title of {url}: {e:#}");
7002 self.state
7003 .event("merge", format!("Pr: title refresh failed: {e:#}"));
7004 }
7005 Ok(url)
7006 }
7007 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7008 };
7009 match out {
7010 Ok(url) => MergeOutcome {
7011 mode,
7012 ok: true,
7013 detail: url,
7014 empty: false,
7015 },
7016 Err(e) => MergeOutcome {
7017 mode,
7018 ok: false,
7019 detail: e.to_string(),
7020 empty: false,
7021 },
7022 }
7023 }
7024 }
7025 };
7026
7027 self.state.status = match (mode, outcome.ok) {
7028 (MergeMode::None, _) => RunStatus::Ready,
7029 (_, true) => RunStatus::Merged,
7030 (_, false) => RunStatus::Blocked,
7031 };
7032 self.state.event(
7033 "merge",
7034 format!(
7035 "{:?}: {}",
7036 mode,
7037 outcome.detail.lines().next().unwrap_or("")
7038 ),
7039 );
7040 self.state.merge = Some(outcome);
7041 self.state.save()?;
7042
7043 if self.state.config.graph.land
7049 && mode == MergeMode::Pr
7050 && self.state.status == RunStatus::Merged
7051 {
7052 self.run_land().await?;
7053 }
7054 self.settle_questions();
7059 Ok(())
7060 }
7061
7062 async fn run_land(&mut self) -> Result<()> {
7073 crate::pr_head::ensure_owner(&self.state)?;
7074 let url = self
7075 .state
7076 .merge
7077 .as_ref()
7078 .map(|m| m.detail.clone())
7079 .unwrap_or_default();
7080 let url = url.lines().next().unwrap_or("").trim().to_owned();
7081 if !url.starts_with("http") {
7082 return Ok(());
7083 }
7084 match land::land(&mut self.state, &url).await {
7087 Ok(pr) if self.state.parked => {
7088 let _ = pr;
7092 }
7093 Ok(pr) => {
7094 self.state.status = match pr.state {
7095 land::PrLifecycle::Merged => RunStatus::Merged,
7096 _ => RunStatus::Blocked,
7097 };
7098 if bump::should_release_bump(self.state.status)
7105 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7106 {
7107 self.state
7113 .event("bump", format!("release bump skipped: {e:#}"));
7114 }
7115 if self.state.status == RunStatus::Merged {
7118 crate::followup::after_merge(&mut self.state, &pr.url).await;
7119 }
7120 self.state.save()?;
7121 }
7122 Err(e) => {
7123 self.state.status = RunStatus::Blocked;
7124 self.state.event("land", format!("gave up: {e}"));
7125 self.state.save()?;
7126 }
7127 }
7128 Ok(())
7129 }
7130
7131 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7135 if let Some(existing) = self.state.seats.get(key)
7136 && existing.agent == agent
7137 {
7138 return existing.clone();
7139 }
7140 let fresh = if self.state.seats.contains_key(key) {
7143 handover_seat(key, agent, self.state.next_seat_seed())
7144 } else {
7145 SeatState::new(key, agent, self.state.seed)
7146 };
7147 self.state.seats.insert(key.to_owned(), fresh.clone());
7148 fresh
7149 }
7150
7151 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7157 match self.state.seats.get(key) {
7158 Some(s) if s.agent != spec.id => {
7159 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7160 }
7161 _ => spec,
7162 }
7163 }
7164
7165 fn view(&self, c: &Candidate) -> CandidateView {
7167 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7168 .unwrap_or_default();
7169 let (patch, _) = blind::sanitize_patch(
7170 &format!("candidate {} patch", c.label),
7171 &raw,
7172 &self.state.config.blind,
7173 );
7174 CandidateView {
7175 label: c.label,
7176 branch: c.branch.clone(),
7177 summary: c.summary.clone(),
7178 stat: c.stat.clone(),
7179 patch,
7180 }
7181 }
7182
7183 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7185 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7186 prompt::judge(
7187 "(see above)",
7188 &views,
7189 self.roles.judges.len(),
7190 base_short,
7191 "en",
7192 )
7193 }
7194
7195 fn vote_prompt_full(
7201 &self,
7202 j: usize,
7203 labels: &[char],
7204 language: &str,
7205 candidates: &[Candidate],
7206 base_short: &str,
7207 ) -> String {
7208 let mut text = format!(
7209 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7210 prompt::final_vote(labels, language),
7211 self.state.instruction,
7212 self.candidate_block(candidates, base_short)
7213 );
7214 if let Some(own) = self
7215 .state
7216 .judgements
7217 .get(j)
7218 .filter(|r| !r.ranking.is_empty())
7219 {
7220 let reasons = own
7221 .reasons
7222 .iter()
7223 .map(|(k, v)| format!("- {k}: {v}"))
7224 .collect::<Vec<_>>()
7225 .join("\n");
7226 text.push_str(&format!(
7227 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
7228 own.ranking.iter().collect::<String>(),
7229 if reasons.is_empty() {
7230 ""
7231 } else {
7232 ", because:\n"
7233 }
7234 ));
7235 }
7236 if !self.state.deliberation.is_empty() {
7237 text.push_str("\n# What was argued before this vote\n");
7238 for t in self.transcript(&[], j) {
7239 text.push_str(&format!(
7240 "\n## {}{}\n\n{}\n",
7241 t.who,
7242 if t.is_self { " (you)" } else { "" },
7243 t.body.trim()
7244 ));
7245 }
7246 }
7247 text
7248 }
7249
7250 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
7257 let mut turns = Vec::new();
7258 for j in &self.state.judgements {
7259 if j.ranking.is_empty() {
7260 continue;
7261 }
7262 let reasons = j
7263 .reasons
7264 .iter()
7265 .map(|(k, v)| format!("- {k}: {v}"))
7266 .collect::<Vec<_>>()
7267 .join("\n");
7268 turns.push(Turn {
7269 who: format!("Judge {} (opening ranking)", j.judge),
7270 is_self: j.judge == self_idx + 1,
7271 body: format!(
7272 "Ranked {}{}{reasons}",
7273 j.ranking.iter().collect::<String>(),
7274 if reasons.is_empty() {
7275 ""
7276 } else {
7277 ", because:\n"
7278 }
7279 ),
7280 });
7281 }
7282 for t in self
7283 .state
7284 .deliberation
7285 .iter()
7286 .flat_map(|r| r.turns.iter())
7287 .chain(current)
7288 {
7289 turns.push(Turn {
7290 who: format!("Judge {}", t.judge),
7291 is_self: t.judge == self_idx + 1,
7292 body: t.body.clone(),
7293 });
7294 }
7295 turns
7296 }
7297}
7298
7299fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
7301 agent::has_session(spec.kind, seat, sessions)
7302}
7303
7304fn next_untried_in_roster<'a>(
7325 roster: &'a [AgentSpec],
7326 start: usize,
7327 tried: &BTreeSet<String>,
7328) -> Option<&'a AgentSpec> {
7329 roster
7330 .get(start + 1..)?
7331 .iter()
7332 .find(|s| !tried.contains(&s.id))
7333}
7334
7335fn pick_successor<'a>(
7356 roster: &'a [AgentSpec],
7357 start: usize,
7358 tried: &BTreeSet<String>,
7359 carried: Option<&BTreeSet<String>>,
7360 others: &BTreeSet<String>,
7361) -> Option<&'a AgentSpec> {
7362 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
7363 roster
7364 .get(start + 1..)
7365 .and_then(|tail| {
7366 tail.iter()
7367 .filter(free)
7368 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
7369 })
7370 .or_else(|| {
7371 carried
7372 .is_some()
7373 .then(|| roster.iter().find(free))
7374 .flatten()
7375 })
7376 .or_else(|| match carried {
7377 Some(c) => next_for_seat(roster, start, tried, c),
7378 None => next_untried_in_roster(roster, start, tried),
7379 })
7380}
7381
7382fn next_for_seat<'a>(
7392 roster: &'a [AgentSpec],
7393 start: usize,
7394 round_tried: &BTreeSet<String>,
7395 carried_failed: &BTreeSet<String>,
7396) -> Option<&'a AgentSpec> {
7397 roster
7398 .get(start + 1..)?
7399 .iter()
7400 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
7401 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
7402}
7403
7404fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
7410 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
7411 return spec;
7412 };
7413 let ok = |id: &str| !h.failed.contains(id);
7414 if let Some(last) = h.last_ok.as_deref()
7415 && ok(last)
7416 && let Some(s) = roster.iter().find(|s| s.id == last)
7417 {
7418 return s.clone();
7419 }
7420 if ok(&spec.id) {
7421 return spec;
7422 }
7423 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
7424 roster
7425 .get(from + 1..)
7426 .into_iter()
7427 .flatten()
7428 .chain(roster.iter())
7429 .find(|s| ok(&s.id))
7430 .cloned()
7431 .unwrap_or(spec)
7432}
7433
7434pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
7438 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
7439}
7440
7441const TIMED_OUT: &str = "timed out";
7444
7445impl FailClass {
7446 fn of(out: &AgentOutcome) -> Option<Self> {
7448 match out {
7449 AgentOutcome::Ok(_) => None,
7450 AgentOutcome::Quota(_) => Some(Self::Quota),
7451 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7452 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7453 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7454 }
7455 }
7456
7457 fn stem_word(&self) -> &'static str {
7459 match self {
7460 Self::Quota => "quota",
7461 _ => "handover",
7462 }
7463 }
7464}
7465
7466fn failure_signature(msg: &str) -> String {
7470 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7471 let mut out = Vec::new();
7472 for word in line.split_whitespace() {
7473 if word.contains('/') || word.contains('\\') {
7474 out.push("<path>".to_owned());
7475 continue;
7476 }
7477 let mut w = String::new();
7478 let mut in_digits = false;
7479 for c in word.chars() {
7480 if c.is_ascii_digit() {
7481 if !in_digits {
7482 w.push('#');
7483 }
7484 in_digits = true;
7485 } else {
7486 in_digits = false;
7487 w.push(c);
7488 }
7489 }
7490 out.push(w);
7491 }
7492 out.join(" ").chars().take(120).collect()
7493}
7494
7495fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7502 match cur {
7503 FailClass::Quota | FailClass::Timeout => true,
7504 FailClass::Other(_) => prev != Some(cur),
7505 }
7506}
7507
7508fn fail_reason(out: &AgentOutcome) -> String {
7510 match out {
7511 AgentOutcome::Ok(_) => String::new(),
7512 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7513 AgentOutcome::Dropped(o) => format!(
7514 "the CLI dropped the stream ({})",
7515 o.dropped
7516 .as_ref()
7517 .map(|d| d.why.as_str())
7518 .unwrap_or("it ended without delivering its answer")
7519 ),
7520 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7521 }
7522}
7523
7524pub(crate) fn record_handover(
7527 state: &mut RunState,
7528 node: &str,
7529 seat: &str,
7530 from: &str,
7531 to: &str,
7532 class: &FailClass,
7533 reason: &str,
7534) {
7535 let message = if *class == FailClass::Quota {
7536 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7537 } else {
7538 format!("{seat}: handed over {from} -> {to} ({reason})")
7539 };
7540 state.event(node, message);
7541 state.handovers.push(Handover {
7542 at: Timestamp::now(),
7543 node: node.to_owned(),
7544 seat: seat.to_owned(),
7545 from: from.to_owned(),
7546 to: to.to_owned(),
7547 reason: reason.to_owned(),
7548 });
7549}
7550
7551fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7565 commands.iter().any(|c| c.exit_code.is_none())
7566}
7567
7568fn verified_noop_claim(
7581 usable: bool,
7582 commands: &[agent::CommandEvidence],
7583 text: &str,
7584) -> Option<String> {
7585 (usable && !has_unconfirmed_command(commands))
7586 .then(|| verdict::verified_noop(text))
7587 .flatten()
7588}
7589
7590fn short(commit: &str) -> String {
7591 commit.chars().take(7).collect()
7592}
7593
7594fn make_executable(path: &Path) -> Result<()> {
7595 #[cfg(unix)]
7596 {
7597 use std::os::unix::fs::PermissionsExt as _;
7598 let mut perms = std::fs::metadata(path)?.permissions();
7599 perms.set_mode(0o755);
7600 std::fs::set_permissions(path, perms)?;
7601 }
7602 #[cfg(not(unix))]
7603 {
7604 let _ = path;
7605 }
7606 Ok(())
7607}
7608
7609struct WaveCtx<'a> {
7616 run: &'a str,
7619 node: &'a str,
7621 prompts: &'a Prompts,
7622 cache: Option<&'a Path>,
7624 round: Option<usize>,
7627 carry_seats: bool,
7631}
7632
7633async fn run_one(
7635 job: SeatJob,
7636 sem: Arc<Semaphore>,
7637 ctx: &WaveCtx<'_>,
7638 state: &mut RunState,
7639 attempt: usize,
7640) -> (SeatState, AgentOutcome) {
7641 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7642 .await
7643 .pop()
7644 .expect("one job in, one result out");
7645 (seat, out)
7646}
7647
7648async fn wave(
7654 jobs: Vec<SeatJob>,
7655 sem: Arc<Semaphore>,
7656 ctx: &WaveCtx<'_>,
7657 state: &mut RunState,
7658 attempt: usize,
7659) -> Vec<(usize, SeatState, AgentOutcome)> {
7660 let WaveCtx {
7661 run,
7662 node,
7663 prompts,
7664 cache,
7665 round,
7666 carry_seats: _,
7667 } = *ctx;
7668 for job in &jobs {
7669 state.seat_started(node, &job.seat.key, job.timeout, attempt);
7670 }
7671 if let Err(e) = state.save() {
7672 tracing::warn!("could not persist in-progress seats: {e:#}");
7677 }
7678 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7694 let wait_started = Instant::now();
7695 let cache_guard = if let Some(cache_dir) = cache {
7696 if jobs_had_a_writer {
7697 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7698 let budget = jobs
7699 .iter()
7700 .map(|j| j.timeout)
7701 .max()
7702 .unwrap_or(Duration::from_secs(60));
7703 acquire_cache_lease(state, cache_dir, &owner, budget, node)
7704 .await
7705 .ok()
7706 } else {
7707 None
7708 }
7709 } else {
7710 None
7711 };
7712 let waited_for_lease = wait_started.elapsed();
7719 let mut set = tokio::task::JoinSet::new();
7720 let overlay = prompts.overlay(node);
7721 for (i, mut job) in jobs.into_iter().enumerate() {
7722 job.timeout = job.timeout.saturating_sub(waited_for_lease);
7723 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7724 if cache.is_some() {
7725 job.prompt.push('\n');
7726 job.prompt
7727 .push_str(&prompt::build_cache_note(node, job.allow_write));
7728 }
7729 let sem = Arc::clone(&sem);
7730 let run = run.to_owned();
7731 let node = node.to_owned();
7732 let attachments = if node == "implement" {
7735 state.attachments.clone()
7736 } else {
7737 Vec::new()
7738 };
7739 let cache = cache
7750 .filter(|_| job.allow_write && cache_guard.is_some())
7751 .map(Path::to_path_buf);
7752 set.spawn(async move {
7753 let _permit = sem.acquire().await;
7754 let mut seat = job.seat;
7755 let out = agent::invoke(
7756 &job.spec,
7757 &mut seat,
7758 &Invocation {
7759 cwd: &job.cwd,
7760 prompt: &job.prompt,
7761 timeout: job.timeout,
7762 allow_write: job.allow_write,
7763 unsandboxed: false,
7764 sessions: job.sessions,
7765 artifacts: &job.artifacts,
7766 stem: &job.stem,
7767 run: &run,
7768 node: &node,
7769 cache_dir: cache.as_deref(),
7770 attachments: &attachments,
7771 writable: &[],
7772 },
7773 )
7774 .await;
7775 let out = match out {
7776 Ok(o) if o.usable() => AgentOutcome::Ok(o),
7777 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7778 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7786 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7787 Ok(o) => AgentOutcome::Failed(format!(
7788 "exited with {:?} and no usable output",
7789 o.exit_code
7790 )),
7791 Err(e) => AgentOutcome::Failed(e.to_string()),
7792 };
7793 (i, seat, out)
7794 });
7795 }
7796 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7797 while let Some(joined) = set.join_next().await {
7798 let (i, seat, out) = match joined {
7799 Ok(v) => v,
7800 Err(e) => {
7804 tracing::error!("agent task panicked: {e}");
7805 continue;
7806 }
7807 };
7808 state.seat_finished(&seat.key);
7809 record_jobs(state, node, round, &seat.key, &out);
7810 if let Err(e) = state.save() {
7811 tracing::warn!("could not persist a seat's completion: {e:#}");
7812 }
7813 if collected.len() <= i {
7814 collected.resize_with(i + 1, || None);
7815 }
7816 collected[i] = Some((i, seat, out));
7817 }
7818 if state
7824 .active
7825 .values()
7826 .any(|a| a.node == node && a.attempt == attempt)
7827 {
7828 state
7829 .active
7830 .retain(|_, a| !(a.node == node && a.attempt == attempt));
7831 if let Err(e) = state.save() {
7832 tracing::warn!("could not persist the end of a wave: {e:#}");
7833 }
7834 }
7835 if let Some(cache_dir) = cache
7842 && jobs_had_a_writer
7843 {
7844 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7845 }
7846 if let Some(guard) = cache_guard {
7847 guard.release();
7848 }
7849 collected.into_iter().flatten().collect()
7850}
7851
7852fn record_jobs(
7863 state: &mut RunState,
7864 node: &str,
7865 round: Option<usize>,
7866 seat: &str,
7867 out: &AgentOutcome,
7868) {
7869 let commands: &[agent::CommandEvidence] = match out {
7870 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7871 AgentOutcome::Failed(_) => &[],
7872 };
7873 let checked_at = Timestamp::now();
7874 for c in commands {
7875 state.jobs.push(JobRecord {
7876 node: node.to_owned(),
7877 round,
7878 seat: seat.to_owned(),
7879 id: c.id.clone(),
7880 description: c.description.clone(),
7881 checked_at,
7882 status: match c.exit_code {
7883 Some(0) => JobStatus::Completed,
7884 Some(_) => JobStatus::Failed,
7885 None => JobStatus::Unknown,
7886 },
7887 exit_code: c.exit_code,
7888 result_summary: c.result_summary.clone(),
7889 source: c.source.clone(),
7890 });
7891 }
7892}
7893
7894fn round_is_clean(
7915 blocking: usize,
7916 e2e_ok: bool,
7917 answered: usize,
7918 expected: usize,
7919 quota_missing: usize,
7920 policy: IncompleteReviewPolicy,
7921) -> bool {
7922 if blocking != 0 || !e2e_ok {
7923 return false;
7924 }
7925 if answered == expected || policy == IncompleteReviewPolicy::Warn {
7926 return true;
7927 }
7928 answered > 0 && expected - answered <= quota_missing
7929}
7930
7931fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7955 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7956 return Some(RunStatus::Gating);
7957 }
7958 let last = reviews.last()?;
7959 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7960 if reviews.len() < max_rounds && !stagnant {
7961 return None;
7962 }
7963 if last.incomplete() && last.blocking == 0 {
7964 return Some(RunStatus::Blocked);
7965 }
7966 if last.e2e_status() == E2eStatus::ResourceBlocked {
7967 return None;
7968 }
7969 Some(if last.e2e.iter().all(CommandOutcome::ok) {
7970 RunStatus::Gating
7971 } else {
7972 RunStatus::Blocked
7973 })
7974}
7975
7976pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7991 if nudged {
7992 (full / 4).max(Duration::from_secs(120)).min(full)
7993 } else {
7994 full
7995 }
7996}
7997
7998#[allow(clippy::too_many_arguments)]
8027async fn ask_json_wave<T>(
8028 jobs: Vec<SeatJob>,
8029 sem: Arc<Semaphore>,
8030 retries: usize,
8031 roster: &[AgentSpec],
8032 ctx: &WaveCtx<'_>,
8033 losses: &mut Vec<QuotaLoss>,
8034 state: &mut RunState,
8035 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8036) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8037where
8038 T: serde::de::DeserializeOwned + Send + 'static,
8039{
8040 ask_wave_with(
8041 jobs,
8042 sem,
8043 retries,
8044 roster,
8045 ctx,
8046 losses,
8047 state,
8048 &|text: &str| {
8049 let v = verdict::extract_json::<T>(text)?;
8050 validate(&v)?;
8051 Ok(v)
8052 },
8053 )
8054 .await
8055}
8056
8057#[allow(clippy::too_many_arguments)]
8066async fn ask_wave_with<T>(
8067 jobs: Vec<SeatJob>,
8068 sem: Arc<Semaphore>,
8069 retries: usize,
8070 roster: &[AgentSpec],
8071 ctx: &WaveCtx<'_>,
8072 losses: &mut Vec<QuotaLoss>,
8073 state: &mut RunState,
8074 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8075) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8076where
8077 T: Send + 'static,
8078{
8079 let n = jobs.len();
8080 let originals: Vec<SeatJob> = jobs;
8081 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8082 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8083 let mut nudges: Vec<usize> = vec![0; n];
8090 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8094 let mut tried: Vec<BTreeSet<String>> = specs
8095 .iter()
8096 .map(|s| BTreeSet::from([s.id.clone()]))
8097 .collect();
8098 let starts: Vec<usize> = specs
8099 .iter()
8100 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8101 .collect();
8102 let carry = ctx.carry_seats && !roster.is_empty();
8103 let carried: Vec<BTreeSet<String>> = originals
8106 .iter()
8107 .map(|j| {
8108 state
8109 .seat_history
8110 .get(&j.seat.key)
8111 .filter(|_| carry)
8112 .map(|h| h.failed.clone())
8113 .unwrap_or_default()
8114 })
8115 .collect();
8116 let mut prev: Vec<Option<FailClass>> = originals
8117 .iter()
8118 .map(|j| {
8119 state
8120 .seat_history
8121 .get(&j.seat.key)
8122 .filter(|_| carry)
8123 .and_then(|h| h.last_fail.clone())
8124 })
8125 .collect();
8126 let next_agent =
8127 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
8128 let others: BTreeSet<String> = specs
8129 .iter()
8130 .enumerate()
8131 .filter(|(j, _)| *j != i)
8132 .map(|(_, s)| s.id.clone())
8133 .collect();
8134 pick_successor(
8135 roster,
8136 starts[i],
8137 tried,
8138 carry.then(|| &carried[i]),
8139 &others,
8140 )
8141 .cloned()
8142 };
8143 let mut fresh: Vec<Option<String>> = vec![None; n];
8144 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
8145 let mut pending: Vec<usize> = (0..n).collect();
8146
8147 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
8152 for round in 0..max_rounds {
8153 if pending.is_empty() {
8154 break;
8155 }
8156 let mut batch = Vec::with_capacity(pending.len());
8157 let mut renudged: Vec<&str> = Vec::new();
8158 for &i in &pending {
8159 let src = &originals[i];
8160 let full: &str = match &src.handover {
8165 Some(h) if specs[i].id != src.spec.id => h,
8166 _ => &src.prompt,
8167 };
8168 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
8171 (
8172 full.to_owned(),
8173 src.timeout,
8174 format!("{}-{word}-{}", src.stem, specs[i].id),
8175 )
8176 } else if nudges[i] == 0 {
8177 (full.to_owned(), src.timeout, src.stem.clone())
8178 } else {
8179 renudged.push(src.seat.key.as_str());
8180 let why = done[i]
8181 .as_ref()
8182 .and_then(|r| r.as_ref().err().map(ToString::to_string))
8183 .unwrap_or_else(|| "no parsable answer".to_owned());
8184 let nudge = prompt::nudge(&why);
8185 let nudged = has_context(&specs[i], &seats[i], src.sessions);
8186 let prompt = if nudged {
8187 nudge
8188 } else {
8189 format!("{full}\n\n---\n\n{nudge}")
8190 };
8191 (
8192 prompt,
8193 retry_budget(src.timeout, nudged),
8194 format!("{}-retry{}", src.stem, nudges[i]),
8195 )
8196 };
8197 batch.push(SeatJob {
8198 spec: specs[i].clone(),
8199 seat: seats[i].clone(),
8200 cwd: src.cwd.clone(),
8201 prompt,
8202 timeout,
8203 allow_write: src.allow_write,
8204 sessions: src.sessions,
8205 artifacts: src.artifacts.clone(),
8206 stem,
8207 handover: None,
8208 });
8209 }
8210
8211 if !renudged.is_empty() {
8212 state.event(
8213 ctx.node,
8214 format!("retry {round}: re-asking {}", renudged.join(", ")),
8215 );
8216 }
8217 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
8218 let mut still = Vec::new();
8219 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
8220 seats[i] = seat;
8221 let class = FailClass::of(&out);
8222 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
8225 && nudges[i] < retries
8226 && !roster.is_empty();
8227 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
8228 let next = should_hand_over(prev[i].as_ref(), &cur)
8229 .then(|| next_agent(i, &tried[i], &specs))
8230 .flatten();
8231 if carry {
8232 let h = state
8233 .seat_history
8234 .entry(originals[i].seat.key.clone())
8235 .or_default();
8236 h.failed.insert(specs[i].id.clone());
8237 h.last_fail = Some(cur.clone());
8238 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
8239 h.last_ok = None;
8240 }
8241 if let Err(e) = state.save() {
8244 tracing::warn!("could not persist a seat's failure history: {e:#}");
8245 }
8246 }
8247 if let Some(next) = next {
8248 record_handover(
8249 state,
8250 ctx.node,
8251 &originals[i].seat.key,
8252 &specs[i].id,
8253 &next.id,
8254 &cur,
8255 &fail_reason(&out),
8256 );
8257 tried[i].insert(next.id.clone());
8258 prev[i] = Some(cur.clone());
8259 seats[i] =
8260 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
8261 specs[i] = next;
8262 fresh[i] = Some(cur.stem_word().to_owned());
8263 nudges[i] = 0;
8264 done[i] = Some(Err(anyhow::anyhow!(
8265 "handed over after: {}",
8266 fail_reason(&out)
8267 )));
8268 still.push(i);
8269 continue;
8270 }
8271 }
8272 if carry
8273 && !nudge_first
8274 && let Some(cur) = class.clone()
8275 {
8276 let h = state
8278 .seat_history
8279 .entry(originals[i].seat.key.clone())
8280 .or_default();
8281 h.failed.insert(specs[i].id.clone());
8282 h.last_fail = Some(cur);
8283 }
8284 let parsed = match out {
8285 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
8286 AgentOutcome::Quota(o) => {
8287 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
8288 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
8289 }
8290 AgentOutcome::Dropped(o) => {
8294 let why = o
8295 .dropped
8296 .as_ref()
8297 .map(|d| d.why.as_str())
8298 .unwrap_or("the CLI ended the stream without delivering its answer");
8299 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
8300 }
8301 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
8302 };
8303 let quota = class == Some(FailClass::Quota);
8304 let failed = parsed.is_err();
8305 done[i] = Some(parsed);
8306 if carry && !failed {
8307 let h = state
8308 .seat_history
8309 .entry(originals[i].seat.key.clone())
8310 .or_default();
8311 h.failed.remove(&specs[i].id);
8312 h.last_ok = Some(specs[i].id.clone());
8313 h.last_fail = None;
8314 }
8315 let agent_failure = class.is_some()
8323 && !nudge_first
8324 && !roster.is_empty()
8325 && next_agent(i, &tried[i], &specs).is_some();
8326 if failed && !quota && !agent_failure && nudges[i] < retries {
8327 nudges[i] += 1;
8328 still.push(i);
8329 }
8330 }
8331 pending = still;
8332 }
8333
8334 for (i, q) in last_quota.into_iter().enumerate() {
8337 if let Some(reset) = q {
8338 losses.push(QuotaLoss {
8339 seat: originals[i].seat.key.clone(),
8340 node: ctx.node.to_owned(),
8341 at: Timestamp::now(),
8342 reset,
8343 });
8344 }
8345 }
8346
8347 seats
8348 .into_iter()
8349 .zip(done)
8350 .zip(nudges)
8351 .map(|((seat, res), attempts)| {
8352 (
8353 seat,
8354 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
8355 attempts,
8356 )
8357 })
8358 .collect()
8359}
8360
8361async fn acquire_cache_lease(
8374 state: &mut RunState,
8375 cache_dir: &Path,
8376 owner: &crate::cache::Owner,
8377 budget: Duration,
8378 context: &str,
8379) -> Result<crate::cache::Guard> {
8380 let home = crate::run::home();
8381 let started = Instant::now();
8382 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
8383 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
8384 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
8385 Err(e) => {
8386 state.event(
8387 "verify",
8388 format!("{context}: could not check the shared build cache: {e:#}"),
8389 );
8390 if let Err(e2) = state.save() {
8391 tracing::warn!("could not persist a cache-check failure: {e2:#}");
8392 }
8393 return Err(e);
8394 }
8395 };
8396 state.event(
8397 "verify",
8398 format!(
8399 "{context}: waiting for the shared build cache at {} ({})",
8400 cache_dir.display(),
8401 busy.describe()
8402 ),
8403 );
8404 if let Err(e) = state.save() {
8405 tracing::warn!("could not persist a cache wait: {e:#}");
8406 }
8407 let remaining = budget.saturating_sub(started.elapsed());
8408 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
8409 Ok(g) => Ok(g),
8410 Err(e) => {
8411 state.event("verify", format!("{context}: {e:#}"));
8412 if let Err(e2) = state.save() {
8413 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
8414 }
8415 Err(e)
8416 }
8417 }
8418}
8419
8420#[allow(clippy::too_many_arguments)]
8441async fn with_cache_lease<'s, F, Fut>(
8442 state: &'s mut RunState,
8443 cache_dir: Option<&Path>,
8444 node: &str,
8445 seat: &str,
8446 worktree: &Path,
8447 head: &str,
8448 budget: Duration,
8449 context: &str,
8450 body: F,
8451) -> (Vec<CommandOutcome>, bool)
8452where
8453 F: FnOnce(&'s mut RunState, Duration) -> Fut,
8454 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8455{
8456 let Some(cache_dir) = cache_dir else {
8457 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8458 return (outcomes, retried);
8459 };
8460 let home = crate::run::home();
8461 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8462 let started = Instant::now();
8463 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8464 Ok(g) => g,
8465 Err(e) => {
8466 return (
8467 vec![CommandOutcome {
8468 command: "(waiting for the shared build cache)".to_owned(),
8469 code: None,
8470 output_tail: e.to_string(),
8471 duration_ms: started.elapsed().as_millis() as u64,
8472 resource_blocked: true,
8473 }],
8474 false,
8475 );
8476 }
8477 };
8478 let identity = crate::cache::Identity::new(worktree, head);
8479 if let Err(e) = crate::cache::ensure_fresh(
8480 &home,
8481 cache_dir,
8482 &identity,
8483 &state.config.verify.cargo_manifests(),
8484 ) {
8485 state.event(
8493 "verify",
8494 format!(
8495 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8496 worktree.display(),
8497 short(head)
8498 ),
8499 );
8500 guard.release();
8501 return (
8502 vec![CommandOutcome {
8503 command: "(confirming the shared build cache is fresh)".to_owned(),
8504 code: None,
8505 output_tail: e.to_string(),
8506 duration_ms: started.elapsed().as_millis() as u64,
8507 resource_blocked: true,
8508 }],
8509 false,
8510 );
8511 }
8512 let remaining = budget.saturating_sub(started.elapsed());
8513 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8514 if !timed_out_pids.is_empty() {
8519 wait_for_timed_out_children_to_die(&timed_out_pids).await;
8520 }
8521 guard.release();
8522 (outcomes, retried)
8523}
8524
8525async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8537 wait_for_pids_with(
8538 pids,
8539 crate::proc::pid_alive,
8540 LEASE_RELEASE_POLL,
8541 LEASE_RELEASE_MAX_WAIT,
8542 )
8543 .await;
8544}
8545
8546async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8552 pids: &[u32],
8553 alive: F,
8554 poll: Duration,
8555 max_wait: Duration,
8556) {
8557 let deadline = Instant::now() + max_wait;
8558 loop {
8559 if pids.iter().all(|&pid| !alive(pid)) {
8560 return;
8561 }
8562 if Instant::now() >= deadline {
8563 return;
8564 }
8565 tokio::time::sleep(poll).await;
8566 }
8567}
8568
8569fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8576 outcomes.iter().any(|o| o.resource_blocked)
8577}
8578
8579enum GateFix {
8581 Retry,
8583 Stop,
8586 Defer,
8589}
8590
8591fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8599 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8600 red.peek().is_some()
8601 && red.all(|o| {
8602 !o.resource_blocked
8603 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8604 && !o.output_tail.trim().is_empty()
8605 })
8606}
8607
8608fn e2e_outcome_label(o: &CommandOutcome) -> String {
8612 if o.ok() {
8613 return "pass".to_owned();
8614 }
8615 let reason = if o.build_failed() {
8616 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8617 } else {
8618 format!("FAIL ({:?})", o.code)
8619 };
8620 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8621}
8622
8623async fn run_e2e_with_retry(
8631 state: &mut RunState,
8632 shell: &[String],
8633 commands: &[String],
8634 worktree: &Path,
8635 timeout: Duration,
8636 context: &str,
8637) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8638 let (mut e2e, mut timed_out_pids) = run_commands(
8639 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8640 )
8641 .await;
8642 for o in &e2e {
8643 state.event(
8644 "verify",
8645 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8646 );
8647 }
8648 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8652 if verify_retried {
8653 state.event(
8654 "verify",
8655 format!(
8656 "{context}: verify could not build/link, not a test result — retrying once \
8657 before concluding"
8658 ),
8659 );
8660 let retried = run_commands(
8661 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8662 )
8663 .await;
8664 e2e = retried.0;
8665 timed_out_pids.extend(retried.1);
8668 for o in &e2e {
8669 state.event(
8670 "verify",
8671 format!(
8672 "{context}: retry `{}` -> {}",
8673 o.command,
8674 e2e_outcome_label(o)
8675 ),
8676 );
8677 }
8678 }
8679 (e2e, verify_retried, timed_out_pids)
8680}
8681
8682#[allow(clippy::too_many_arguments)]
8698async fn run_commands(
8699 state: &mut RunState,
8700 node: &str,
8701 task: &str,
8702 attempt: usize,
8703 shell: &[String],
8704 commands: &[String],
8705 cwd: &Path,
8706 timeout: Duration,
8707) -> (Vec<CommandOutcome>, Vec<u32>) {
8708 if commands.is_empty() {
8709 return (Vec::new(), Vec::new());
8714 }
8715 let mut out = Vec::new();
8716 let mut timed_out_pids = Vec::new();
8717 let total = commands.len();
8718 for (idx, command) in commands.iter().enumerate() {
8719 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8720 if let Err(e) = state.save() {
8721 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8722 }
8723 let started = Instant::now();
8724 let mut cmd = tokio::process::Command::new(&shell[0]);
8725 cmd.quiet();
8726 cmd.args(&shell[1..])
8727 .arg(command)
8728 .current_dir(cwd)
8729 .stdin(std::process::Stdio::null())
8730 .stdout(std::process::Stdio::piped())
8731 .stderr(std::process::Stdio::piped())
8732 .kill_on_drop(true);
8733 let spawned = cmd.spawn();
8734 let (code, body) = match spawned {
8735 Ok(child) => {
8736 let pid = child.id();
8741 match tokio::time::timeout(timeout, child.wait_with_output()).await {
8742 Ok(Ok(o)) => {
8743 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8744 body.push_str(&String::from_utf8_lossy(&o.stderr));
8745 (o.status.code(), body)
8746 }
8747 Ok(Err(e)) => (None, format!("failed to run: {e}")),
8748 Err(_) => {
8749 if let Some(pid) = pid {
8750 timed_out_pids.push(pid);
8751 }
8752 (None, format!("timed out after {}s", timeout.as_secs()))
8753 }
8754 }
8755 }
8756 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8757 };
8758 out.push(CommandOutcome {
8759 command: command.clone(),
8760 code,
8761 output_tail: tail(&body, OUTPUT_TAIL),
8762 duration_ms: started.elapsed().as_millis() as u64,
8763 resource_blocked: false,
8764 });
8765 }
8766 state.task_finished(task);
8767 if let Err(e) = state.save() {
8768 tracing::warn!("could not persist the end of {task}: {e:#}");
8769 }
8770 (out, timed_out_pids)
8771}
8772
8773fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8785 let repo = repo.display();
8786 match style {
8787 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8788 MergeStyle::Squash => {
8789 let subject = message
8792 .lines()
8793 .next()
8794 .unwrap_or(branch)
8795 .replace(['\\', '"', '$', '`'], "");
8796 format!(
8797 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8798 )
8799 }
8800 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8801 }
8802}
8803
8804const PR_TITLE_MAX: usize = 240;
8815
8816fn english_title(text: &str, max: usize) -> Option<String> {
8827 let line = queue::first_line(text)?;
8828 let chars: Vec<char> = line.chars().collect();
8829 let (kept, mark) = if chars.len() <= max {
8830 (line.to_owned(), "")
8831 } else if let Some(end) = sentence_end(&chars, max) {
8832 (chars[..end].iter().collect::<String>(), "")
8833 } else {
8834 let room = max.saturating_sub(3);
8835 let cut = if chars[room].is_whitespace() {
8838 room
8839 } else {
8840 chars[..room]
8841 .iter()
8842 .rposition(|c| c.is_whitespace())
8843 .unwrap_or(room)
8844 };
8845 let head: String = chars[..cut].iter().collect();
8846 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8847 (head.to_owned(), "...")
8848 };
8849 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8850 return None;
8851 }
8852 Some(format!("{kept}{mark}"))
8853}
8854
8855fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8859 const MIN: usize = 20;
8860 for i in MIN..max.min(chars.len()) {
8861 if !matches!(chars[i], '.' | '!' | '?') {
8862 continue;
8863 }
8864 let Some(&next) = chars.get(i + 1) else {
8865 continue;
8866 };
8867 if !next.is_whitespace() {
8868 continue;
8869 }
8870 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8871 if after.is_some_and(|c| c.is_ascii_lowercase()) {
8872 continue;
8873 }
8874 let word: String = chars[..i]
8875 .iter()
8876 .rev()
8877 .take_while(|c| !c.is_whitespace())
8878 .collect::<Vec<_>>()
8879 .into_iter()
8880 .rev()
8881 .collect();
8882 let word = word.to_ascii_lowercase();
8883 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8884 continue;
8885 }
8886 let end = chars[..i]
8887 .iter()
8888 .rposition(|c| !c.is_whitespace())
8889 .map_or(i, |p| p + 1);
8890 return Some(end);
8891 }
8892 None
8893}
8894
8895struct PrMessage {
8900 title: String,
8901 body: String,
8902}
8903
8904impl PrMessage {
8905 fn commit_message(&self) -> String {
8909 format!("{}\n\n{}", self.title, self.body)
8910 }
8911}
8912
8913fn title_marker(line: &str) -> Option<&str> {
8915 let line = line.trim();
8916 let head = line.get(..6)?;
8917 head.eq_ignore_ascii_case("title:")
8918 .then(|| line[6..].trim())
8919}
8920
8921fn summary_title(summary: &str) -> Option<String> {
8926 let first = summary.lines().find(|l| !l.trim().is_empty())?;
8927 let raw = title_marker(first)?;
8928 if raw.is_empty() {
8929 return None;
8930 }
8931 let title = queue::title_from(raw, PR_TITLE_MAX);
8932 let lower = title.to_ascii_lowercase();
8933 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8934 return None;
8935 }
8936 Some(title)
8937}
8938
8939const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8941
8942const EXISTING_BRANCH: &str = "(existing branch)";
8944
8945fn is_review_run(state: &RunState) -> bool {
8949 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8950}
8951
8952fn review_title(state: &RunState) -> Option<String> {
8957 english_subject(state.reviewed_commits.as_ref()?.first()?)
8958}
8959
8960fn english_subject(raw: &str) -> Option<String> {
8963 let raw = raw.trim();
8964 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8965 return None;
8966 }
8967 let title = queue::title_from(raw, PR_TITLE_MAX);
8968 let lower = title.to_ascii_lowercase();
8969 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8970 return None;
8971 }
8972 Some(title)
8973}
8974
8975#[derive(Debug, Clone, PartialEq, Eq)]
8978struct BranchFacts {
8979 commits: Vec<(String, String)>,
8981 stat: String,
8983}
8984
8985const STAT_FILE_LINES: usize = 25;
8987const COMMITS_MAX_CHARS: usize = 20_000;
8989
8990async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8993 let commits = git::commit_log(repo, base, branch).await.ok()?;
8994 if commits.is_empty() {
8995 return None;
8996 }
8997 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8998 let lines: Vec<&str> = stat.lines().collect();
8999 let stat = if lines.len() > STAT_FILE_LINES + 1 {
9000 let omitted = lines.len() - 1 - STAT_FILE_LINES;
9001 let more = format!(" ... {omitted} more file(s)");
9002 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
9003 kept.push(&more);
9004 kept.push(lines[lines.len() - 1]);
9005 kept.join("\n")
9006 } else {
9007 lines.join("\n")
9008 };
9009 Some(BranchFacts { commits, stat })
9010}
9011
9012fn markdown_safe(text: &str) -> String {
9015 text.replace("</details>", "</details>")
9016 .replace("\x60\x60\x60", "~~~")
9017}
9018
9019fn neutral_title(state: &RunState, winner: char) -> String {
9020 format!(
9021 "chore: land candidate {} of run {}",
9022 winner.to_ascii_uppercase(),
9023 state.id
9024 )
9025}
9026
9027pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
9033 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
9034 ""
9035 } else {
9036 pr_title
9037 }
9038}
9039
9040pub fn landing_subject_source(state: &RunState) -> String {
9044 if is_review_run(state) {
9045 let winner = state.candidates.first().map_or('A', |c| c.label);
9046 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
9047 }
9048 state.instruction.clone()
9049}
9050
9051fn summary_without_title(summary: &str) -> String {
9054 let mut lines = summary.trim().lines().peekable();
9055 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
9056 lines.next();
9057 }
9058 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
9059}
9060
9061#[cfg(test)]
9075fn pr_message(state: &RunState, winner: char) -> PrMessage {
9076 pr_message_with(state, winner, None)
9077}
9078
9079#[cfg(test)]
9082fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9083 let raw = pr_message_raw(state, winner, facts);
9084 let id = crate::scrub::Identity::current();
9085 PrMessage {
9086 title: crate::scrub::scrub(&raw.title, &id),
9087 body: crate::scrub::scrub(&raw.body, &id),
9088 }
9089}
9090
9091fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9092 let summary = state
9093 .candidates
9094 .iter()
9095 .find(|c| c.label == winner)
9096 .map(|c| c.summary.as_str())
9097 .unwrap_or_default();
9098 let review = is_review_run(state);
9101 let title = if review {
9102 facts
9103 .and_then(|f| english_subject(&f.commits.first()?.0))
9104 .or_else(|| review_title(state))
9105 .or_else(|| {
9106 state
9107 .candidates
9108 .iter()
9109 .find(|c| c.label == winner)
9110 .filter(|c| !c.branch.starts_with("magi/"))
9111 .and_then(|c| english_subject(&c.branch))
9112 })
9113 .unwrap_or_else(|| neutral_title(state, winner))
9114 } else {
9115 summary_title(summary).unwrap_or_else(|| {
9116 english_title(&state.instruction, PR_TITLE_MAX)
9117 .unwrap_or_else(|| neutral_title(state, winner))
9118 })
9119 };
9120
9121 let mut body = String::new();
9122 let what = summary_without_title(summary);
9123 if !what.is_empty() {
9124 body.push_str("## Summary\n\n");
9125 body.push_str(&what);
9126 body.push_str("\n\n");
9127 }
9128
9129 let notes: Vec<(usize, &str)> = state
9132 .reviews
9133 .iter()
9134 .filter_map(|r| {
9135 let n = r.fix.as_ref()?.notes.trim();
9136 (!n.is_empty()).then_some((r.round, n))
9137 })
9138 .collect();
9139 if !notes.is_empty() {
9140 body.push_str("## Review fixes\n\n");
9141 if let [(_, only)] = notes.as_slice() {
9142 body.push_str(only);
9143 body.push_str("\n\n");
9144 } else {
9145 for (round, n) in ¬es {
9146 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
9147 }
9148 }
9149 }
9150 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
9151
9152 let open = state.open_findings();
9153 if !open.is_empty() {
9154 body.push_str("## Open review findings\n\n");
9155 for f in &open {
9156 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
9157 }
9158 body.push('\n');
9159 }
9160
9161 if let Some(fix) = fix
9162 && !fix.rejected.is_empty()
9163 {
9164 body.push_str("## Declined by the fixer\n\n");
9165 for r in &fix.rejected {
9166 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
9167 }
9168 body.push('\n');
9169 }
9170
9171 if review {
9172 body.push_str("## Commits under review\n\n");
9174 if let Some(facts) = facts {
9175 let mut left = COMMITS_MAX_CHARS;
9176 for (i, (subject, text)) in facts.commits.iter().enumerate() {
9177 let mut entry = format!("- {}\n", markdown_safe(subject));
9178 for l in markdown_safe(text).lines() {
9179 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
9180 }
9181 if left == 0 {
9182 body.push_str(&format!(
9183 "- ... {} more commit(s)\n",
9184 facts.commits.len() - i
9185 ));
9186 break;
9187 }
9188 if entry.len() > left {
9189 let mut end = left;
9192 while !entry.is_char_boundary(end) {
9193 end -= 1;
9194 }
9195 entry.truncate(end);
9196 entry.push_str("\n ... (truncated)\n");
9197 left = 0;
9198 } else {
9199 left -= entry.len();
9200 }
9201 body.push_str(&entry);
9202 }
9203 if !facts.stat.trim().is_empty() {
9204 body.push_str(&format!(
9205 "\n## Diff stat\n\n```\n{}\n```\n",
9206 markdown_safe(facts.stat.trim())
9207 ));
9208 }
9209 } else {
9210 match &state.reviewed_commits {
9211 Some(subjects) => {
9212 for s in subjects {
9213 body.push_str(&format!("- {}\n", s.trim()));
9214 }
9215 }
9216 None => {
9217 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
9220 body.push_str(rest.trim());
9221 body.push('\n');
9222 }
9223 }
9224 }
9225 } else {
9226 let task = state.instruction.trim();
9227 let task = if task.is_empty() {
9228 "(empty task)"
9229 } else {
9230 task
9231 };
9232 body.push_str(&format!(
9233 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
9234 task.replace("</details>", "</details>")
9235 ));
9236 }
9237
9238 body.push_str(&format!(
9239 "\n---\nmagi:run/{} magi:candidate-{}\n",
9240 state.id,
9241 winner.to_ascii_lowercase()
9242 ));
9243
9244 PrMessage { title, body }
9245}
9246
9247fn seeded_instruction(state: &RunState) -> String {
9251 match refs::describe(&state.seeds) {
9252 Some(facts) => format!(
9253 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
9254 Candidates start from the unmerged branch named above, when there \
9255 is one, and carry any unmerged commit named by sha as a \
9256 cherry-pick. Check that this is what the task meant before \
9257 building on it.",
9258 state.instruction
9259 ),
9260 None => state.instruction.clone(),
9261 }
9262}
9263
9264async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
9268 let base = &state.base_branch;
9269 let mut against = base.clone();
9270 if mode == MergeMode::Pr {
9271 let remote = &state.config.merge.remote;
9275 let tracking = format!("{remote}/{base}");
9276 let fetched = git::fetch(repo, remote, base).await;
9277 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
9278 against = tracking;
9279 } else {
9280 return false;
9281 }
9282 }
9283 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
9284}
9285
9286fn empty_candidate_detail(state: &RunState, base: &str) -> String {
9289 let mut detail = format!(
9290 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
9291 nothing to open a pull request for"
9292 );
9293 match refs::describe(&state.seeds) {
9294 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
9295 None => detail.push_str(
9296 "\nThe task names no existing branch or commit; if it means to land work \
9297 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
9298 ),
9299 }
9300 detail
9301}
9302
9303#[derive(Debug, PartialEq, Eq)]
9306enum PrPlan {
9307 Create,
9308 Adopt { url: String, title: String },
9309 Stop(String),
9310}
9311
9312fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
9315 match found {
9316 Ok(land::OpenPr::None) => PrPlan::Create,
9317 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
9318 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
9319 "several open pull requests exist for this branch, not picking one: {}",
9320 urls.join(" ")
9321 )),
9322 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
9323 }
9324}
9325
9326pub(crate) async fn gh_pr_create(
9328 cwd: &Path,
9329 base: &str,
9330 head: &str,
9331 title: &str,
9332 body: &str,
9333) -> Result<String> {
9334 let out = tokio::process::Command::new("gh")
9335 .args([
9336 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
9337 ])
9338 .current_dir(cwd)
9339 .quiet()
9340 .stdin(std::process::Stdio::null())
9341 .output()
9342 .await
9343 .context("spawn gh")?;
9344 if out.status.success() {
9345 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
9346 } else {
9347 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
9348 }
9349}
9350
9351pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
9360 let repo = state.repo.clone();
9361 let root = state.worktree_root();
9362 let winner = state.tally.as_ref().map(|t| t.winner);
9363 let mut removed = Vec::new();
9364
9365 for i in 0..state.candidates.len() {
9366 let c = state.candidates[i].clone();
9367 let is_winner = Some(c.label) == winner;
9368 if is_winner && !drop_winner {
9369 continue;
9370 }
9371 if c.worktree.exists() {
9372 git::worktree_remove(&repo, &c.worktree).await.ok();
9373 removed.push(c.worktree.to_string_lossy().into_owned());
9374 }
9375 let handed_over = state.released_branches.contains(&c.branch);
9378 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
9379 git::branch_delete(&repo, &c.branch).await.ok();
9380 removed.push(c.branch.clone());
9381 }
9382 state.candidates[i].folded = true;
9383 }
9384
9385 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
9386 let path = name.path();
9387 let keep = !drop_winner
9388 && winner.is_some_and(|w| {
9389 path.file_name()
9390 .is_some_and(|n| n == format!("cand-{w}").as_str())
9391 });
9392 if keep {
9393 continue;
9394 }
9395 git::worktree_remove(&repo, &path).await.ok();
9396 removed.push(path.to_string_lossy().into_owned());
9397 }
9398
9399 remove_if_empty(&root);
9408
9409 if state.enabled_worktree_config && drop_winner {
9410 git::release_worktree_config(&repo).await.ok();
9414 state.enabled_worktree_config = false;
9415 }
9416 state.save_under(home)?;
9417 Ok(removed)
9418}
9419
9420fn remove_if_empty(dir: &Path) {
9431 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
9432 std::fs::remove_dir(dir).ok();
9433 }
9434}
9435
9436pub fn worst_open(state: &RunState) -> Option<Severity> {
9438 state
9439 .reviews
9440 .last()?
9441 .reviews
9442 .iter()
9443 .flat_map(|r| r.findings.iter())
9444 .map(|f| f.severity)
9445 .max()
9446}
9447
9448async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9452 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9453 git::acquire_worktree_config(repo).await?;
9454 state.enabled_worktree_config = true;
9455 }
9456 Ok(())
9457}
9458
9459#[cfg(test)]
9460mod tests {
9461 #[test]
9462 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9463 let leaked = vec!["chore(deps): update a crate".to_owned()];
9464 let own = "fix(daemon): apply a chosen action";
9465 assert!(should_retitle("", own, &leaked));
9466 assert!(should_retitle(
9467 &format!("{REVIEW_PROMPT_OPENING} `x`"),
9468 own,
9469 &leaked
9470 ));
9471 assert!(should_retitle(
9472 "chore: land candidate A of run 1",
9473 own,
9474 &leaked
9475 ));
9476 assert!(should_retitle(
9477 "magi: candidate A (uncommitted work)",
9478 own,
9479 &leaked
9480 ));
9481 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9482 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9483 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9484 }
9485
9486 #[test]
9487 fn pr_merge_plan_creates_adopts_or_stops() {
9488 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9489 assert_eq!(
9490 pr_merge_plan(Ok(land::OpenPr::One {
9491 url: "u".into(),
9492 title: "t".into()
9493 })),
9494 PrPlan::Adopt {
9495 url: "u".into(),
9496 title: "t".into()
9497 }
9498 );
9499 let PrPlan::Stop(many) =
9500 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9501 else {
9502 panic!("many must stop");
9503 };
9504 assert!(many.contains('a') && many.contains('b'));
9505 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9506 panic!("a failed lookup must stop");
9507 };
9508 assert!(err.contains("bad token"));
9509 }
9510
9511 use super::*;
9512 use crate::run::GateStatus;
9513 use std::collections::BTreeMap;
9514 use std::time::Duration;
9515
9516 fn conductor() -> AgentSpec {
9517 AgentSpec {
9518 id: "conductor".to_owned(),
9519 kind: crate::config::AgentKind::Command,
9520 model: None,
9521 command: vec!["true".to_owned()],
9522 extra_args: Vec::new(),
9523 env: BTreeMap::new(),
9524 prompt_delivery: None,
9525 }
9526 }
9527
9528 fn spec(id: &str) -> AgentSpec {
9529 AgentSpec {
9530 id: id.to_owned(),
9531 kind: crate::config::AgentKind::Command,
9532 model: None,
9533 command: vec!["true".to_owned()],
9534 extra_args: Vec::new(),
9535 env: BTreeMap::new(),
9536 prompt_delivery: None,
9537 }
9538 }
9539
9540 fn ids(xs: &[&str]) -> BTreeSet<String> {
9541 xs.iter().map(|s| (*s).to_owned()).collect()
9542 }
9543
9544 #[test]
9545 fn pick_successor_skips_an_agent_another_seat_holds() {
9546 let roster = [spec("a"), spec("b"), spec("c")];
9548 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9549 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9550 }
9551
9552 #[test]
9553 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9554 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9556 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9557 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9558 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9559 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9560 }
9561
9562 #[test]
9563 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9564 let roster = [spec("a"), spec("b")];
9565 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9566 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9567 let carried = ids(&["b"]);
9568 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9569 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9570 }
9571
9572 #[test]
9573 fn pick_successor_returns_none_without_any_untried_successor() {
9574 let roster = [spec("a"), spec("b")];
9575 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9576 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9577 let carried = ids(&["a"]);
9578 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9579 }
9580
9581 #[test]
9582 fn pick_successor_rescue_avoids_another_seats_occupant() {
9583 let roster = [spec("a"), spec("b"), spec("c")];
9585 let carried = ids(&["b", "c"]);
9586 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9587 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9588 }
9589
9590 #[test]
9591 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9592 let roster = [spec("a"), spec("b"), spec("c")];
9593 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9594 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9595 }
9596
9597 #[test]
9598 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9599 let roster = [spec("a"), spec("b"), spec("c")];
9600 let failed = ids(&["a", "b", "c"]);
9601 let mut tried = ids(&["b"]);
9603 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9604 assert_eq!(first.id, "a");
9605 tried.insert(first.id.clone());
9606 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9607 assert_eq!(second.id, "c");
9608 tried.insert(second.id.clone());
9609 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9610 }
9611
9612 #[test]
9613 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9614 let roster = [spec("a"), spec("b")];
9615 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9616 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9617 }
9618
9619 #[test]
9620 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9621 let roster = [spec("a"), spec("b"), spec("c")];
9622 let h = SeatHistory {
9623 failed: ids(&["a"]),
9624 last_ok: Some("b".to_owned()),
9625 last_fail: None,
9626 };
9627 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9628 let gone = SeatHistory {
9630 last_ok: Some("zzz".to_owned()),
9631 ..h.clone()
9632 };
9633 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9634 let failed = SeatHistory {
9635 failed: ids(&["a", "b"]),
9636 last_ok: Some("b".to_owned()),
9637 last_fail: None,
9638 };
9639 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9640 }
9641
9642 #[test]
9643 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9644 let first = SeatState::new("review-1", "alpha", 7);
9645 let next = handover_seat("review-1", "gamma", 7);
9646 assert_ne!(first.claude_session, next.claude_session);
9647 }
9648
9649 #[test]
9650 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9651 let mut state = state_with_summary("x", "y");
9652 let a = handover_seat("review-1", "beta", state.next_seat_seed());
9653 let b = handover_seat("review-1", "beta", state.next_seat_seed());
9654 assert_ne!(a.claude_session, b.claude_session);
9655 }
9656
9657 #[test]
9658 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9659 let roster = [spec("a"), spec("b")];
9660 let h = SeatHistory {
9661 failed: ids(&["a", "b"]),
9662 ..SeatHistory::default()
9663 };
9664 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9665 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9666 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9667 }
9668
9669 #[test]
9676 fn failure_signature_ignores_numbers_and_paths() {
9677 assert_eq!(
9678 failure_signature("exited with Some(2) and no usable output"),
9679 failure_signature("exited with Some(137) and no usable output")
9680 );
9681 assert_eq!(
9682 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9683 failure_signature("cannot open /var/x.txt: denied")
9684 );
9685 assert_ne!(failure_signature("boom"), failure_signature("bang"));
9686 }
9687
9688 #[test]
9689 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9690 let other = FailClass::Other("x".into());
9691 assert!(should_hand_over(None, &FailClass::Quota));
9692 assert!(should_hand_over(Some(&other), &FailClass::Quota));
9693 assert!(should_hand_over(
9694 Some(&FailClass::Timeout),
9695 &FailClass::Timeout
9696 ));
9697 assert!(should_hand_over(None, &other));
9698 assert!(!should_hand_over(Some(&other), &other));
9699 assert!(should_hand_over(
9700 Some(&other),
9701 &FailClass::Other("y".into())
9702 ));
9703 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9705 assert!(should_hand_over(Some(&FailClass::Quota), &other));
9706 }
9707
9708 #[test]
9709 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9710 let a = SeatState::new("judge-1", "alpha", 7);
9711 let b = handover_seat("judge-1", "beta", 7);
9712 assert_ne!(a.claude_session, b.claude_session);
9713 assert_eq!(b.turns, 0);
9714 }
9715
9716 #[test]
9717 fn a_timeout_is_classified_apart_from_other_failures() {
9718 assert_eq!(
9719 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9720 Some(FailClass::Timeout)
9721 );
9722 assert!(matches!(
9723 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9724 Some(FailClass::Other(_))
9725 ));
9726 }
9727
9728 #[test]
9729 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9730 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9731 let tried = BTreeSet::from(["beta".to_owned()]);
9732 let next = next_untried_in_roster(&roster, 1, &tried);
9735 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9736 }
9737
9738 #[test]
9739 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9740 let roster = vec![spec("alpha"), spec("beta")];
9741 let tried = BTreeSet::from(["beta".to_owned()]);
9742 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9746 }
9747
9748 #[test]
9749 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9750 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9751 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9752 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9756 }
9757
9758 #[test]
9759 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9760 let roster = vec![spec("a"), spec("a"), spec("b")];
9761 let tried = BTreeSet::from(["a".to_owned()]);
9762 let next = next_untried_in_roster(&roster, 0, &tried);
9763 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9764 }
9765
9766 #[test]
9767 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9768 let roster = vec![spec("a"), spec("b")];
9769 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9770 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9771 }
9772
9773 #[test]
9774 fn remove_if_empty_only_ever_takes_a_bare_directory() {
9775 let dir = tempfile::tempdir().unwrap();
9776 let bay = dir.path().join("ffff");
9777
9778 remove_if_empty(&bay);
9780 assert!(!bay.exists());
9781
9782 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9785 remove_if_empty(&bay);
9786 assert!(bay.exists(), "non-empty directory must survive");
9787
9788 std::fs::remove_dir(bay.join("cand-A")).unwrap();
9790 remove_if_empty(&bay);
9791 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9792 }
9793
9794 #[test]
9803 fn a_full_panel_that_found_nothing_is_clean() {
9804 assert!(round_is_clean(
9805 0,
9806 true,
9807 2,
9808 2,
9809 0,
9810 IncompleteReviewPolicy::Block
9811 ));
9812 }
9813
9814 #[test]
9815 fn a_missing_seat_is_never_clean_under_the_default_policy() {
9816 assert!(!round_is_clean(
9817 0,
9818 true,
9819 1,
9820 2,
9821 0,
9822 IncompleteReviewPolicy::Block
9823 ));
9824 }
9825
9826 #[test]
9827 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9828 assert!(!round_is_clean(
9829 1,
9830 true,
9831 1,
9832 2,
9833 0,
9834 IncompleteReviewPolicy::Warn
9835 ));
9836 }
9837
9838 #[test]
9839 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9840 assert!(round_is_clean(
9841 0,
9842 true,
9843 1,
9844 2,
9845 0,
9846 IncompleteReviewPolicy::Warn
9847 ));
9848 }
9849
9850 #[test]
9851 fn a_full_panel_with_an_open_finding_is_not_clean() {
9852 assert!(!round_is_clean(
9853 1,
9854 true,
9855 2,
9856 2,
9857 0,
9858 IncompleteReviewPolicy::Block
9859 ));
9860 }
9861
9862 #[test]
9863 fn a_full_panel_with_a_red_e2e_is_not_clean() {
9864 assert!(!round_is_clean(
9865 0,
9866 false,
9867 2,
9868 2,
9869 0,
9870 IncompleteReviewPolicy::Block
9871 ));
9872 }
9873
9874 #[test]
9881 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9882 assert!(round_is_clean(
9885 0,
9886 true,
9887 1,
9888 2,
9889 1,
9890 IncompleteReviewPolicy::Block
9891 ));
9892 }
9893
9894 #[test]
9895 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9896 assert!(!round_is_clean(
9899 0,
9900 true,
9901 1,
9902 2,
9903 0,
9904 IncompleteReviewPolicy::Block
9905 ));
9906 }
9907
9908 #[test]
9909 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9910 assert!(!round_is_clean(
9911 1,
9912 true,
9913 1,
9914 2,
9915 1,
9916 IncompleteReviewPolicy::Block
9917 ));
9918 assert!(!round_is_clean(
9919 0,
9920 false,
9921 1,
9922 2,
9923 1,
9924 IncompleteReviewPolicy::Block
9925 ));
9926 }
9927
9928 #[test]
9929 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9930 assert!(!round_is_clean(
9934 0,
9935 true,
9936 0,
9937 2,
9938 2,
9939 IncompleteReviewPolicy::Block
9940 ));
9941 }
9942
9943 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9944 CommandOutcome {
9945 command: "test".to_owned(),
9946 code,
9947 output_tail: String::new(),
9948 duration_ms: 0,
9949 resource_blocked,
9950 }
9951 }
9952
9953 #[test]
9954 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9955 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9956 assert!(
9957 !verify_inconclusive(&[outcome(Some(1), false)]),
9958 "an ordinary failure is still evidence about the patch"
9959 );
9960 assert!(verify_inconclusive(&[outcome(None, true)]));
9961 assert!(
9962 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9963 "one inconclusive outcome taints the whole batch"
9964 );
9965 assert!(!verify_inconclusive(&[]));
9966 }
9967
9968 #[tokio::test]
9969 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9970 let calls = std::sync::atomic::AtomicUsize::new(0);
9974 let started = Instant::now();
9975 wait_for_pids_with(
9976 &[123],
9977 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9978 Duration::from_millis(5),
9979 Duration::from_secs(5),
9980 )
9981 .await;
9982 assert!(
9983 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9984 "must keep checking rather than deciding on the first answer"
9985 );
9986 assert!(
9987 started.elapsed() < Duration::from_secs(1),
9988 "must return the moment it is confirmed dead, not wait out the ceiling"
9989 );
9990 }
9991
9992 #[tokio::test]
9993 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9994 let started = Instant::now();
9995 wait_for_pids_with(
9996 &[123],
9997 |_| true, Duration::from_millis(5),
9999 Duration::from_millis(30),
10000 )
10001 .await;
10002 let elapsed = started.elapsed();
10003 assert!(
10004 elapsed >= Duration::from_millis(30),
10005 "must not give up before its own ceiling: {elapsed:?}"
10006 );
10007 assert!(
10008 elapsed < Duration::from_secs(1),
10009 "must not wait past its own ceiling either: {elapsed:?}"
10010 );
10011 }
10012
10013 #[tokio::test]
10014 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
10015 let started = Instant::now();
10016 wait_for_pids_with(
10017 &[],
10018 |_| true,
10019 Duration::from_secs(5),
10020 Duration::from_secs(5),
10021 )
10022 .await;
10023 assert!(
10024 started.elapsed() < Duration::from_millis(200),
10025 "an empty pid list has nothing to confirm"
10026 );
10027 }
10028
10029 fn review_round(
10035 clean: bool,
10036 blocking: usize,
10037 answered: usize,
10038 expected: usize,
10039 progressed: bool,
10040 e2e_ok: bool,
10041 ) -> ReviewRound {
10042 ReviewRound {
10043 round: 1,
10044 head: "h".to_owned(),
10045 verified_head: None,
10046 verified_at: None,
10047 reviews: Vec::new(),
10048 e2e: vec![CommandOutcome {
10049 command: "test".to_owned(),
10050 code: Some(if e2e_ok { 0 } else { 1 }),
10051 output_tail: String::new(),
10052 duration_ms: 0,
10053 resource_blocked: false,
10054 }],
10055 verify_retried: false,
10056 e2e_deferred: false,
10057 e2e_defer_reason: None,
10058 fix: None,
10059 blocking,
10060 answered,
10061 expected,
10062 clean,
10063 progressed,
10064 vote_split: false,
10065 reconsideration: Vec::new(),
10066 verdict: None,
10067 }
10068 }
10069
10070 #[test]
10071 fn review_conclusion_is_none_when_nothing_has_run() {
10072 assert_eq!(review_conclusion(&[], 3), None);
10073 }
10074
10075 #[test]
10076 fn review_conclusion_is_none_while_rounds_remain() {
10077 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
10078 assert_eq!(review_conclusion(&rounds, 3), None);
10079 }
10080
10081 #[test]
10082 fn review_conclusion_is_gating_once_a_round_is_clean() {
10083 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
10084 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
10085 }
10086
10087 #[test]
10088 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
10089 let rounds = vec![
10090 review_round(false, 1, 2, 2, true, true),
10091 review_round(false, 1, 2, 2, true, true),
10092 ];
10093 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
10094 }
10095
10096 #[test]
10097 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
10098 let rounds = vec![
10099 review_round(false, 1, 2, 2, true, true),
10100 review_round(false, 1, 2, 2, true, false),
10101 ];
10102 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
10103 }
10104
10105 #[test]
10106 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
10107 let mut blocked = review_round(false, 1, 2, 2, true, false);
10114 blocked.e2e[0].resource_blocked = true;
10115 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
10116 assert_eq!(review_conclusion(&rounds, 2), None);
10117 }
10118
10119 #[test]
10120 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
10121 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
10123 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
10124 }
10125
10126 #[test]
10127 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
10128 let rounds = vec![
10129 review_round(false, 1, 2, 2, false, true),
10130 review_round(false, 1, 2, 2, false, true),
10131 ];
10132 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
10133 }
10134
10135 fn secs(n: u64) -> Duration {
10136 Duration::from_secs(n)
10137 }
10138
10139 fn init_repo(dir: &Path) {
10142 let run = |args: &[&str]| {
10143 let out = std::process::Command::new("git")
10144 .args(args)
10145 .current_dir(dir)
10146 .quiet()
10147 .output()
10148 .expect("spawn git");
10149 assert!(
10150 out.status.success(),
10151 "git {args:?} failed: {}",
10152 String::from_utf8_lossy(&out.stderr)
10153 );
10154 };
10155 run(&["init", "-b", "main"]);
10156 run(&["config", "user.name", "magi test"]);
10157 run(&["config", "user.email", "magi@example.com"]);
10158 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
10159 run(&["add", "-A"]);
10160 run(&["commit", "-m", "init"]);
10161 }
10162
10163 fn ask_test_home() {
10171 crate::run::pin_test_home();
10172 }
10173
10174 fn runner_at(status: RunStatus) -> Runner {
10177 let mut state = RunState::new(
10178 PathBuf::from("/nonexistent/repo"),
10179 "main".to_owned(),
10180 "deadbeef".to_owned(),
10181 "task".to_owned(),
10182 Config::default(),
10183 );
10184 state.status = status;
10185 Runner {
10186 state,
10187 roles: ResolvedRoles {
10188 implementers: Vec::new(),
10189 judges: Vec::new(),
10190 reviewers: Vec::new(),
10191 fixer: None,
10192 conductor: conductor(),
10193 implementer_roster: Vec::new(),
10194 judge_roster: Vec::new(),
10195 reviewer_roster: Vec::new(),
10196 },
10197 sem: Arc::new(Semaphore::new(1)),
10198 pause: Pause::new(),
10199 interrupt: Pause::new(),
10200 }
10201 }
10202
10203 #[test]
10207 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
10208 crate::run::pin_test_home();
10209 let mut runner = runner_at(RunStatus::Implementing);
10210 let interrupt = Pause::new();
10211 runner.watch_interrupt(interrupt.clone());
10212
10213 interrupt.park_because("task a1b2 asked to run first");
10214
10215 assert!(runner.park_here().expect("park_here"));
10216 assert!(runner.state.parked);
10217 let last = runner.state.events.last().expect("a park event");
10218 assert_eq!(last.node, "park");
10219 assert!(
10220 last.message.contains("task a1b2 asked to run first"),
10221 "expected the interrupt reason in {:?}",
10222 last.message
10223 );
10224 }
10225
10226 #[test]
10234 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
10235 crate::run::pin_test_home();
10236 let mut runner = runner_at(RunStatus::Implementing);
10237 let shutdown = Pause::new();
10238 runner.on_pause(shutdown.clone());
10239 let interrupt = Pause::new();
10240 runner.watch_interrupt(interrupt.clone());
10241
10242 assert!(!runner.park_here().expect("park_here"));
10244 assert!(!runner.state.parked);
10245
10246 interrupt.park_because("test");
10248 assert!(!shutdown.parked());
10249 assert!(runner.park_here().expect("park_here"));
10250 }
10251
10252 #[tokio::test]
10266 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
10267 crate::run::pin_test_home();
10268 let mut runner = runner_at(RunStatus::Implementing);
10269 let interrupt = Pause::new();
10270 runner.watch_interrupt(interrupt.clone());
10271
10272 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
10273 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
10274
10275 let node = async move {
10279 started_tx.send(()).expect("send started");
10280 finish_rx.await.expect("recv finish");
10281 "node finished"
10282 };
10283
10284 let interrupter = async move {
10285 started_rx.await.expect("recv started");
10286 interrupt.park_because("higher-priority task waiting");
10288 tokio::task::yield_now().await;
10292 finish_tx.send(()).expect("send finish");
10293 };
10294
10295 let (node_result, ()) = tokio::join!(node, interrupter);
10296 assert_eq!(
10297 node_result, "node finished",
10298 "the in-flight call ran to completion"
10299 );
10300
10301 assert!(runner.park_here().expect("park_here"));
10304 assert!(runner.state.parked);
10305 }
10306
10307 #[test]
10313 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
10314 crate::run::pin_test_home();
10315 let mut runner = runner_at(RunStatus::Judging);
10316 runner.state.config.agents = vec![conductor()];
10320 runner.state.candidates = vec![Candidate {
10321 index: 0,
10322 label: 'A',
10323 agent: "alpha".to_owned(),
10324 branch: "magi/x/A".to_owned(),
10325 worktree: PathBuf::from("/nonexistent/worktree"),
10326 summary: "did the thing".to_owned(),
10327 stat: "1 file changed".to_owned(),
10328 files: 1,
10329 commits: 1,
10330 empty: false,
10331 failed: None,
10332 verified_noop: None,
10333 duration_ms: 1234,
10334 folded: false,
10335 }];
10336 let run_id = runner.state.id.clone();
10337
10338 let interrupt = Pause::new();
10339 runner.watch_interrupt(interrupt.clone());
10340 interrupt.park_because("task c3d4 asked to run first");
10341 assert!(runner.park_here().expect("park_here"));
10342
10343 let resumed = Runner::resume(&run_id).expect("resume");
10344 assert_eq!(resumed.state.candidates.len(), 1);
10345 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
10346 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
10347 assert_eq!(resumed.state.status, runner.state.status);
10348 assert!(
10349 resumed.state.parked,
10350 "still parked until `execute` actually walks the graph again"
10351 );
10352 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
10353 }
10354
10355 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
10357 let mut q = ask::Question::new(
10358 run.to_owned(),
10359 "implement".to_owned(),
10360 "impl-A".to_owned(),
10361 "Which storage backend should the cache use?".to_owned(),
10362 String::new(),
10363 vec!["SQLite".to_owned(), "Redis".to_owned()],
10364 );
10365 store.put(&mut q).unwrap();
10366 q
10367 }
10368
10369 #[test]
10370 fn a_failed_runs_open_question_is_abandoned() {
10371 ask_test_home();
10372 let store = ask::Questions::open();
10373 let mut runner = runner_at(RunStatus::Failed);
10374 let run = runner.state.id.clone();
10375 let q = ask_open_question(&store, &run);
10376
10377 runner.settle_questions();
10378
10379 let back = store.get(&q.id).unwrap();
10380 assert!(
10381 !back.status.open(),
10382 "the seat that asked died with the run; nobody is left to read an answer"
10383 );
10384 assert!(
10385 back.detail.contains(&run) && back.detail.contains("failed"),
10386 "the reason names what the run became, not just that it is gone: {}",
10387 back.detail
10388 );
10389 }
10390
10391 #[test]
10392 fn a_merged_runs_open_question_is_abandoned_too() {
10393 ask_test_home();
10394 let store = ask::Questions::open();
10395 for status in [RunStatus::Merged, RunStatus::Ready] {
10398 let mut runner = runner_at(status);
10399 let run = runner.state.id.clone();
10400 let q = ask_open_question(&store, &run);
10401
10402 runner.settle_questions();
10403
10404 let back = store.get(&q.id).unwrap();
10405 assert!(
10406 !back.status.open(),
10407 "{status:?} run's question must not outlive the run"
10408 );
10409 }
10410 }
10411
10412 #[test]
10413 fn a_still_resumable_runs_open_question_is_left_alone() {
10414 ask_test_home();
10415 let store = ask::Questions::open();
10416 for status in [RunStatus::Blocked, RunStatus::Stalled] {
10422 let mut runner = runner_at(status);
10423 let run = runner.state.id.clone();
10424 let q = ask_open_question(&store, &run);
10425
10426 runner.settle_questions();
10427
10428 let back = store.get(&q.id).unwrap();
10429 assert!(
10430 back.status.open(),
10431 "{status:?} is still alive; the question must still be waiting"
10432 );
10433 }
10434 }
10435
10436 #[test]
10437 fn settle_questions_never_touches_an_already_answered_question() {
10438 ask_test_home();
10439 let store = ask::Questions::open();
10440 let mut runner = runner_at(RunStatus::Failed);
10441 let run = runner.state.id.clone();
10442 let mut q = ask_open_question(&store, &run);
10443 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
10444 .unwrap();
10445 store.put(&mut q).unwrap();
10446
10447 runner.settle_questions();
10452 runner.settle_questions();
10453
10454 let back = store.get(&q.id).unwrap();
10455 assert_eq!(
10456 back.status,
10457 ask::QuestionStatus::Answered,
10458 "a real answer is a decision on record, never overwritten by a sweep"
10459 );
10460 }
10461
10462 #[tokio::test]
10473 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10474 crate::run::pin_test_home();
10475 let tmp = tempfile::tempdir().expect("tempdir");
10476 let repo = tmp.path().join("repo");
10477 std::fs::create_dir_all(&repo).unwrap();
10478 init_repo(&repo);
10479
10480 let mut config = Config::default();
10481 config.graph.worktree_root = Some(tmp.path().join("wt"));
10482
10483 let mut state = RunState::new(
10484 repo.clone(),
10485 "main".to_owned(),
10486 "deadbeef".to_owned(),
10487 "task".to_owned(),
10488 config,
10489 );
10490 let root = state.worktree_root();
10491 let wt_a = root.join("cand-A");
10492 let wt_b = root.join("cand-B");
10493 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10494 .await
10495 .expect("worktree A");
10496 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10497 .await
10498 .expect("worktree B");
10499
10500 state.candidates = vec![
10501 Candidate {
10502 index: 0,
10503 label: 'A',
10504 agent: "alpha".to_owned(),
10505 branch: "magi/x/A".to_owned(),
10506 worktree: wt_a.clone(),
10507 summary: String::new(),
10508 stat: String::new(),
10509 files: 0,
10510 commits: 0,
10511 empty: false,
10512 failed: None,
10513 verified_noop: None,
10514 duration_ms: 0,
10515 folded: false,
10516 },
10517 Candidate {
10518 index: 1,
10519 label: 'B',
10520 agent: "beta".to_owned(),
10521 branch: "magi/x/B".to_owned(),
10522 worktree: wt_b.clone(),
10523 summary: String::new(),
10524 stat: String::new(),
10525 files: 0,
10526 commits: 0,
10527 empty: false,
10528 failed: None,
10529 verified_noop: None,
10530 duration_ms: 0,
10531 folded: false,
10532 },
10533 ];
10534 state.tally = Some(Tally {
10535 first_choice: BTreeMap::from([('A', 1)]),
10536 borda: BTreeMap::new(),
10537 winner: 'A',
10538 rankings: 1,
10539 unanimous_initial: true,
10540 deliberated: false,
10541 changed_votes: 0,
10542 unanimous_final: true,
10543 tie_break: None,
10544 judges: 1,
10545 present: 1,
10546 quorum: 1,
10547 met_quorum: true,
10548 uncontested: None,
10549 });
10550 state.status = RunStatus::Ready;
10551
10552 fold_run(&mut state, false, &crate::run::home())
10553 .await
10554 .expect("fold_run");
10555
10556 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10557 assert!(
10558 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10559 "the unmerged winner's branch survives"
10560 );
10561 assert!(
10562 !state.candidates[0].folded,
10563 "the winner is not marked folded"
10564 );
10565
10566 assert!(!wt_b.exists(), "the loser's worktree is removed");
10567 assert!(
10568 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10569 "the loser's branch is removed"
10570 );
10571 assert!(state.candidates[1].folded, "the loser is marked folded");
10572 }
10573
10574 #[tokio::test]
10577 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10578 let tmp = tempfile::tempdir().expect("tempdir");
10579 let repo = tmp.path().join("repo");
10580 std::fs::create_dir_all(&repo).unwrap();
10581 init_repo(&repo);
10582 let home = tmp.path().join("home");
10583
10584 let mut config = Config::default();
10585 config.graph.worktree_root = Some(tmp.path().join("wt"));
10586 let mut state = RunState::new(
10587 repo.clone(),
10588 "main".to_owned(),
10589 "deadbeef".to_owned(),
10590 "task".to_owned(),
10591 config,
10592 );
10593 git::git(&repo, &["branch", "magi/x/A", "main"])
10595 .await
10596 .expect("branch");
10597 state.candidates = vec![Candidate {
10598 index: 0,
10599 label: 'A',
10600 agent: "alpha".to_owned(),
10601 branch: "magi/x/A".to_owned(),
10602 worktree: state.worktree_root().join("cand-A"),
10603 summary: String::new(),
10604 stat: String::new(),
10605 files: 0,
10606 commits: 0,
10607 empty: false,
10608 failed: None,
10609 verified_noop: None,
10610 duration_ms: 0,
10611 folded: true,
10612 }];
10613 state.released_to = Some("20260901-000000-new1".to_owned());
10614 state.released_branches = vec!["magi/x/A".to_owned()];
10615
10616 fold_run(&mut state, true, &home).await.expect("fold_run");
10617
10618 assert!(
10619 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10620 "the handed-over branch survives a fold"
10621 );
10622 }
10623
10624 #[tokio::test]
10628 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10629 let tmp = tempfile::tempdir().expect("tempdir");
10630 let repo = tmp.path().join("repo");
10631 std::fs::create_dir_all(&repo).unwrap();
10632 init_repo(&repo);
10633 let run = |args: &[&str]| {
10634 let out = std::process::Command::new("git")
10635 .quiet()
10636 .args(args)
10637 .current_dir(&repo)
10638 .output()
10639 .expect("spawn git");
10640 assert!(out.status.success(), "git {args:?}");
10641 };
10642 run(&["branch", "magi/x/A"]);
10643 run(&["checkout", "-q", "-b", "magi/x/B"]);
10644 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10645 run(&["add", "-A"]);
10646 run(&["commit", "-q", "-m", "work"]);
10647 run(&["checkout", "-q", "main"]);
10648
10649 let probe = RunState::new(
10653 repo.clone(),
10654 "main".to_owned(),
10655 "deadbeef".to_owned(),
10656 "task".to_owned(),
10657 Config::default(),
10658 );
10659 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10660 let bare = tmp.path().join("origin.git");
10661 let out = std::process::Command::new("git")
10662 .quiet()
10663 .args(["init", "-q", "--bare"])
10664 .arg(&bare)
10665 .output()
10666 .expect("spawn git");
10667 assert!(out.status.success(), "git init --bare");
10668 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10669 run(&["push", "-q", "origin", "main"]);
10670
10671 let mut state = RunState::new(
10672 repo.clone(),
10673 "main".to_owned(),
10674 "deadbeef".to_owned(),
10675 "task".to_owned(),
10676 Config::default(),
10677 );
10678 state.seeds = vec![refs::Seed {
10679 token: "magi/27b2/A".to_owned(),
10680 kind: refs::SeedKind::Unresolved,
10681 sha: String::new(),
10682 branch: true,
10683 detail: "no branch or commit named magi/27b2/A".to_owned(),
10684 }];
10685
10686 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10687 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10688 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10689 let detail = empty_candidate_detail(&state, "main");
10690 assert!(detail.starts_with("empty candidate"), "{detail}");
10691 assert!(detail.contains("magi/27b2/A"), "{detail}");
10692 }
10693
10694 #[tokio::test]
10703 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10704 let tmp = tempfile::tempdir().expect("tempdir");
10705 let repo = tmp.path().join("repo");
10706 std::fs::create_dir_all(&repo).unwrap();
10707 init_repo(&repo);
10708
10709 let mut config = Config::default();
10710 config.merge.mode = MergeMode::Local;
10711
10712 let mut state = RunState::new(
10713 repo.clone(),
10714 "main".to_owned(),
10715 "deadbeef".to_owned(),
10716 "task".to_owned(),
10717 config,
10718 );
10719 state.candidates = vec![Candidate {
10720 index: 0,
10721 label: 'A',
10722 agent: "alpha".to_owned(),
10723 branch: "does-not-exist".to_owned(),
10724 worktree: repo.clone(),
10725 summary: String::new(),
10726 stat: String::new(),
10727 files: 0,
10728 commits: 0,
10729 empty: false,
10730 failed: None,
10731 verified_noop: None,
10732 duration_ms: 0,
10733 folded: false,
10734 }];
10735 state.tally = Some(Tally {
10736 first_choice: BTreeMap::from([('A', 1)]),
10737 borda: BTreeMap::new(),
10738 winner: 'A',
10739 rankings: 1,
10740 unanimous_initial: true,
10741 deliberated: false,
10742 changed_votes: 0,
10743 unanimous_final: true,
10744 tie_break: None,
10745 judges: 0,
10746 present: 0,
10747 quorum: 0,
10748 met_quorum: true,
10749 uncontested: Some("only candidate A produced a change".to_owned()),
10750 });
10751 state.reviews = vec![ReviewRound {
10752 round: 1,
10753 head: "deadbeef".to_owned(),
10754 verified_head: None,
10755 verified_at: None,
10756 reviews: Vec::new(),
10757 e2e: Vec::new(),
10758 fix: None,
10759 blocking: 0,
10760 answered: 0,
10761 expected: 0,
10762 clean: true,
10763 verify_retried: false,
10764 e2e_deferred: false,
10765 e2e_defer_reason: None,
10766 progressed: false,
10767 vote_split: false,
10768 reconsideration: Vec::new(),
10769 verdict: None,
10770 }];
10771 state.gate = vec![CommandOutcome {
10772 command: "test".to_owned(),
10773 code: Some(0),
10774 output_tail: String::new(),
10775 duration_ms: 0,
10776 resource_blocked: false,
10777 }];
10778 state.gate_ran = true;
10779 state.status = RunStatus::Ready;
10784 state.merge = Some(MergeOutcome {
10785 mode: MergeMode::Local,
10786 ok: false,
10787 detail: "already concluded".to_owned(),
10788 empty: false,
10789 });
10790
10791 let mut runner = Runner {
10792 state,
10793 roles: ResolvedRoles {
10794 implementers: Vec::new(),
10795 judges: Vec::new(),
10796 reviewers: Vec::new(),
10797 fixer: None,
10798 conductor: conductor(),
10799 implementer_roster: Vec::new(),
10800 judge_roster: Vec::new(),
10801 reviewer_roster: Vec::new(),
10802 },
10803 sem: Arc::new(Semaphore::new(1)),
10804 pause: Pause::new(),
10805 interrupt: Pause::new(),
10806 };
10807
10808 runner.merge().await.expect("merge");
10809
10810 assert_eq!(
10811 runner.state.status,
10812 RunStatus::Ready,
10813 "a concluded run's status must not change on reentry"
10814 );
10815 assert_eq!(
10816 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10817 Some("already concluded"),
10818 "merge must not run again once the node already recorded an outcome"
10819 );
10820 }
10821
10822 #[tokio::test]
10831 async fn merge_refuses_a_gate_that_has_not_actually_run() {
10832 let tmp = tempfile::tempdir().expect("tempdir");
10833 let repo = tmp.path().join("repo");
10834 std::fs::create_dir_all(&repo).unwrap();
10835 init_repo(&repo);
10836
10837 let mut config = Config::default();
10838 config.merge.mode = MergeMode::Local;
10839
10840 let mut state = RunState::new(
10841 repo.clone(),
10842 "main".to_owned(),
10843 "deadbeef".to_owned(),
10844 "task".to_owned(),
10845 config,
10846 );
10847 state.candidates = vec![Candidate {
10848 index: 0,
10849 label: 'A',
10850 agent: "alpha".to_owned(),
10851 branch: "does-not-exist".to_owned(),
10852 worktree: repo.clone(),
10853 summary: String::new(),
10854 stat: String::new(),
10855 files: 0,
10856 commits: 0,
10857 empty: false,
10858 failed: None,
10859 verified_noop: None,
10860 duration_ms: 0,
10861 folded: false,
10862 }];
10863 state.tally = Some(Tally {
10864 first_choice: BTreeMap::from([('A', 1)]),
10865 borda: BTreeMap::new(),
10866 winner: 'A',
10867 rankings: 1,
10868 unanimous_initial: true,
10869 deliberated: false,
10870 changed_votes: 0,
10871 unanimous_final: true,
10872 tie_break: None,
10873 judges: 0,
10874 present: 0,
10875 quorum: 0,
10876 met_quorum: true,
10877 uncontested: Some("only candidate A produced a change".to_owned()),
10878 });
10879 state.reviews = vec![ReviewRound {
10880 round: 1,
10881 head: "deadbeef".to_owned(),
10882 verified_head: None,
10883 verified_at: None,
10884 reviews: Vec::new(),
10885 e2e: Vec::new(),
10886 fix: None,
10887 blocking: 0,
10888 answered: 0,
10889 expected: 0,
10890 clean: true,
10891 verify_retried: false,
10892 e2e_deferred: false,
10893 e2e_defer_reason: None,
10894 progressed: false,
10895 vote_split: false,
10896 reconsideration: Vec::new(),
10897 verdict: None,
10898 }];
10899 state.gate = Vec::new();
10901 state.gate_ran = false;
10902 state.status = RunStatus::Gating;
10903
10904 let mut runner = Runner {
10905 state,
10906 roles: ResolvedRoles {
10907 implementers: Vec::new(),
10908 judges: Vec::new(),
10909 reviewers: Vec::new(),
10910 fixer: None,
10911 conductor: conductor(),
10912 implementer_roster: Vec::new(),
10913 judge_roster: Vec::new(),
10914 reviewer_roster: Vec::new(),
10915 },
10916 sem: Arc::new(Semaphore::new(1)),
10917 pause: Pause::new(),
10918 interrupt: Pause::new(),
10919 };
10920
10921 runner.merge().await.expect("merge");
10922
10923 assert!(
10924 runner.state.merge.is_none(),
10925 "an empty gate must never be read as a passing one: {:?}",
10926 runner.state.merge
10927 );
10928 }
10929
10930 #[tokio::test]
10937 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10938 let tmp = tempfile::tempdir().expect("tempdir");
10939 let repo = tmp.path().join("repo");
10940 std::fs::create_dir_all(&repo).unwrap();
10941 init_repo(&repo);
10942
10943 let config = Config::default();
10945
10946 let mut state = RunState::new(
10947 repo.clone(),
10948 "main".to_owned(),
10949 "deadbeef".to_owned(),
10950 "task".to_owned(),
10951 config,
10952 );
10953 state.candidates = vec![Candidate {
10954 index: 0,
10955 label: 'A',
10956 agent: "alpha".to_owned(),
10957 branch: "does-not-exist".to_owned(),
10958 worktree: repo.clone(),
10959 summary: String::new(),
10960 stat: String::new(),
10961 files: 0,
10962 commits: 0,
10963 empty: false,
10964 failed: None,
10965 verified_noop: None,
10966 duration_ms: 0,
10967 folded: false,
10968 }];
10969 state.tally = Some(Tally {
10970 first_choice: BTreeMap::from([('A', 1)]),
10971 borda: BTreeMap::new(),
10972 winner: 'A',
10973 rankings: 1,
10974 unanimous_initial: true,
10975 deliberated: false,
10976 changed_votes: 0,
10977 unanimous_final: true,
10978 tie_break: None,
10979 judges: 0,
10980 present: 0,
10981 quorum: 0,
10982 met_quorum: true,
10983 uncontested: Some("only candidate A produced a change".to_owned()),
10984 });
10985 state.reviews = vec![ReviewRound {
10986 round: 1,
10987 head: "deadbeef".to_owned(),
10988 verified_head: None,
10989 verified_at: None,
10990 reviews: Vec::new(),
10991 e2e: Vec::new(),
10992 fix: None,
10993 blocking: 0,
10994 answered: 0,
10995 expected: 0,
10996 clean: true,
10997 verify_retried: false,
10998 e2e_deferred: false,
10999 e2e_defer_reason: None,
11000 progressed: false,
11001 vote_split: false,
11002 reconsideration: Vec::new(),
11003 verdict: None,
11004 }];
11005
11006 let mut runner = Runner {
11007 state,
11008 roles: ResolvedRoles {
11009 implementers: Vec::new(),
11010 judges: Vec::new(),
11011 reviewers: Vec::new(),
11012 fixer: None,
11013 conductor: conductor(),
11014 implementer_roster: Vec::new(),
11015 judge_roster: Vec::new(),
11016 reviewer_roster: Vec::new(),
11017 },
11018 sem: Arc::new(Semaphore::new(1)),
11019 pause: Pause::new(),
11020 interrupt: Pause::new(),
11021 };
11022
11023 runner.gate().await.expect("gate");
11024 assert!(
11025 runner.state.gate_ran,
11026 "zero configured commands is still a real attempt, not an unrun gate"
11027 );
11028 assert!(runner.state.gate.is_empty());
11029 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
11030 assert_ne!(
11031 runner.state.status,
11032 RunStatus::Blocked,
11033 "a gate with nothing to check must not read as failed"
11034 );
11035
11036 runner.merge().await.expect("merge");
11037 assert_eq!(
11038 runner.state.status,
11039 RunStatus::Ready,
11040 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
11041 );
11042 }
11043
11044 #[tokio::test]
11055 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
11056 crate::run::pin_test_home();
11057 let home = crate::run::home();
11058
11059 let tmp = tempfile::tempdir().expect("tempdir");
11060 let repo = tmp.path().join("repo");
11061 std::fs::create_dir_all(&repo).unwrap();
11062 init_repo(&repo);
11063 let cache_dir = tmp.path().join("target");
11066
11067 let mut config = Config::default();
11068 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
11069 config.graph.timeout_verify = Some(2);
11072
11073 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11074 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11075 .expect("no io error acquiring directly")
11076 {
11077 crate::cache::AcquireOutcome::Acquired(g) => g,
11078 crate::cache::AcquireOutcome::Busy(b) => {
11079 panic!("expected the direct acquire to win the lease first: {b:?}")
11080 }
11081 };
11082
11083 let mut state = RunState::new(
11084 repo.clone(),
11085 "main".to_owned(),
11086 "deadbeef".to_owned(),
11087 "task".to_owned(),
11088 config,
11089 );
11090 state.candidates = vec![Candidate {
11091 index: 0,
11092 label: 'A',
11093 agent: "alpha".to_owned(),
11094 branch: "does-not-exist".to_owned(),
11095 worktree: repo.clone(),
11096 summary: String::new(),
11097 stat: String::new(),
11098 files: 0,
11099 commits: 0,
11100 empty: false,
11101 failed: None,
11102 verified_noop: None,
11103 duration_ms: 0,
11104 folded: false,
11105 }];
11106 state.tally = Some(Tally {
11107 first_choice: BTreeMap::from([('A', 1)]),
11108 borda: BTreeMap::new(),
11109 winner: 'A',
11110 rankings: 1,
11111 unanimous_initial: true,
11112 deliberated: false,
11113 changed_votes: 0,
11114 unanimous_final: true,
11115 tie_break: None,
11116 judges: 0,
11117 present: 0,
11118 quorum: 0,
11119 met_quorum: true,
11120 uncontested: Some("only candidate A produced a change".to_owned()),
11121 });
11122 state.reviews = vec![ReviewRound {
11123 round: 1,
11124 head: "deadbeef".to_owned(),
11125 verified_head: None,
11126 verified_at: None,
11127 reviews: Vec::new(),
11128 e2e: Vec::new(),
11129 fix: None,
11130 blocking: 0,
11131 answered: 0,
11132 expected: 0,
11133 clean: true,
11134 verify_retried: false,
11135 e2e_deferred: false,
11136 e2e_defer_reason: None,
11137 progressed: false,
11138 vote_split: false,
11139 reconsideration: Vec::new(),
11140 verdict: None,
11141 }];
11142
11143 let mut runner = Runner {
11144 state,
11145 roles: ResolvedRoles {
11146 implementers: Vec::new(),
11147 judges: Vec::new(),
11148 reviewers: Vec::new(),
11149 fixer: None,
11150 conductor: conductor(),
11151 implementer_roster: Vec::new(),
11152 judge_roster: Vec::new(),
11153 reviewer_roster: Vec::new(),
11154 },
11155 sem: Arc::new(Semaphore::new(1)),
11156 pause: Pause::new(),
11157 interrupt: Pause::new(),
11158 };
11159
11160 let started = std::time::Instant::now();
11161 runner.gate().await.expect("gate");
11162 assert!(
11163 started.elapsed() < Duration::from_secs(1),
11164 "a gate with nothing to run must never wait on a lease it never needed"
11165 );
11166 assert!(
11167 runner.state.gate_ran,
11168 "zero commands is still a real, immediate attempt"
11169 );
11170 assert!(runner.state.gate.is_empty());
11171 assert_ne!(
11172 runner.state.status,
11173 RunStatus::Blocked,
11174 "must not read as resource-blocked on a lease it never asked for"
11175 );
11176 }
11177
11178 #[tokio::test]
11188 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
11189 crate::run::pin_test_home();
11190
11191 let tmp = tempfile::tempdir().expect("tempdir");
11192 let repo = tmp.path().join("repo");
11193 std::fs::create_dir_all(&repo).unwrap();
11194 init_repo(&repo);
11195
11196 let mut config = Config::default();
11197 config.verify.gate = vec![
11198 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
11199 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
11200 .to_owned(),
11201 ];
11202
11203 let mut state = RunState::new(
11204 repo.clone(),
11205 "main".to_owned(),
11206 "deadbeef".to_owned(),
11207 "task".to_owned(),
11208 config,
11209 );
11210 let run_id = state.id.clone();
11211 state.candidates = vec![Candidate {
11212 index: 0,
11213 label: 'A',
11214 agent: "alpha".to_owned(),
11215 branch: "does-not-exist".to_owned(),
11216 worktree: repo.clone(),
11217 summary: String::new(),
11218 stat: String::new(),
11219 files: 0,
11220 commits: 0,
11221 empty: false,
11222 failed: None,
11223 verified_noop: None,
11224 duration_ms: 0,
11225 folded: false,
11226 }];
11227 state.tally = Some(Tally {
11228 first_choice: BTreeMap::from([('A', 1)]),
11229 borda: BTreeMap::new(),
11230 winner: 'A',
11231 rankings: 1,
11232 unanimous_initial: true,
11233 deliberated: false,
11234 changed_votes: 0,
11235 unanimous_final: true,
11236 tie_break: None,
11237 judges: 0,
11238 present: 0,
11239 quorum: 0,
11240 met_quorum: true,
11241 uncontested: Some("only candidate A produced a change".to_owned()),
11242 });
11243 state.reviews = vec![ReviewRound {
11244 round: 1,
11245 head: "deadbeef".to_owned(),
11246 verified_head: None,
11247 verified_at: None,
11248 reviews: Vec::new(),
11249 e2e: Vec::new(),
11250 fix: None,
11251 blocking: 0,
11252 answered: 0,
11253 expected: 0,
11254 clean: true,
11255 verify_retried: false,
11256 e2e_deferred: false,
11257 e2e_defer_reason: None,
11258 progressed: false,
11259 vote_split: false,
11260 reconsideration: Vec::new(),
11261 verdict: None,
11262 }];
11263
11264 let mut runner = Runner {
11265 state,
11266 roles: ResolvedRoles {
11267 implementers: Vec::new(),
11268 judges: Vec::new(),
11269 reviewers: Vec::new(),
11270 fixer: None,
11271 conductor: conductor(),
11272 implementer_roster: Vec::new(),
11273 judge_roster: Vec::new(),
11274 reviewer_roster: Vec::new(),
11275 },
11276 sem: Arc::new(Semaphore::new(1)),
11277 pause: Pause::new(),
11278 interrupt: Pause::new(),
11279 };
11280
11281 let started_marker = repo.join("started.marker");
11282 let release_marker = repo.join("release.marker");
11283 let poller = tokio::spawn(async move {
11284 for _ in 0..100 {
11289 if started_marker.exists()
11290 && let Ok(s) = crate::run::RunState::load(&run_id)
11291 && let Some(a) = s.active.get("gate")
11292 {
11293 std::fs::write(&release_marker, b"go").expect("release marker");
11294 return Some(a.clone());
11295 }
11296 tokio::time::sleep(Duration::from_millis(50)).await;
11297 }
11298 None
11299 });
11300
11301 runner.gate().await.expect("gate");
11302 let captured = poller.await.expect("poller task");
11303 let captured = captured.expect(
11304 "the poller never saw a `gate` task entry in run.json while the command was \
11305 still blocked on its own release marker",
11306 );
11307
11308 assert_eq!(captured.task.as_deref(), Some("gate"));
11309 assert_eq!(captured.node, "gate");
11310 assert_eq!(captured.index, Some(1));
11311 assert_eq!(captured.total, Some(1));
11312 assert!(
11313 captured
11314 .command
11315 .as_deref()
11316 .is_some_and(|c| c.contains("started.marker")),
11317 "{captured:?}"
11318 );
11319
11320 assert!(
11321 runner.state.active.is_empty(),
11322 "the entry must be cleared once the command actually finished: {:?}",
11323 runner.state.active
11324 );
11325 assert!(runner.state.gate_ran);
11326 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
11327 }
11328
11329 #[tokio::test]
11332 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
11333 use crate::verdict::{Finding, ReviewVote, Severity};
11334 crate::run::pin_test_home();
11335 let tmp = tempfile::tempdir().expect("tempdir");
11336 let repo = tmp.path().join("repo");
11337 std::fs::create_dir_all(&repo).unwrap();
11338 init_repo(&repo);
11339
11340 for (severity, vote, expect) in [
11341 (Severity::Major, ReviewVote::Reject, true),
11342 (Severity::Minor, ReviewVote::Reject, false),
11343 (Severity::Major, ReviewVote::Approve, false),
11344 ] {
11345 let mut round = review_round(false, 1, 1, 1, false, true);
11346 round.reviews = vec![ReviewRecord {
11347 reviewer: 1,
11348 agent: "alpha".to_owned(),
11349 summary: String::new(),
11350 findings: vec![Finding {
11351 id: "R1-1-1".to_owned(),
11352 severity,
11353 file: None,
11354 line: None,
11355 title: "t".to_owned(),
11356 detail: String::new(),
11357 }],
11358 vote: Some(vote),
11359 failed: None,
11360 duration_ms: 0,
11361 attempts: 0,
11362 }];
11363 let mut state = RunState::new(
11364 repo.clone(),
11365 "main".to_owned(),
11366 "deadbeef".to_owned(),
11367 "task".to_owned(),
11368 Config::default(),
11369 );
11370 state.reviews = vec![round];
11371 let mut runner = Runner {
11372 state,
11373 roles: ResolvedRoles {
11374 implementers: Vec::new(),
11375 judges: Vec::new(),
11376 reviewers: Vec::new(),
11377 fixer: None,
11378 conductor: conductor(),
11379 implementer_roster: Vec::new(),
11380 judge_roster: Vec::new(),
11381 reviewer_roster: Vec::new(),
11382 },
11383 sem: Arc::new(Semaphore::new(1)),
11384 pause: Pause::new(),
11385 interrupt: Pause::new(),
11386 };
11387 let shell = runner.state.config.shell();
11388 runner
11389 .stop_reviewing("round budget spent", &shell, &repo)
11390 .await
11391 .expect("stop_reviewing");
11392 assert_eq!(runner.state.status, RunStatus::Gating);
11393 assert_eq!(
11394 runner.state.contested_handoff.is_some(),
11395 expect,
11396 "{severity:?} + {vote:?}"
11397 );
11398 }
11399 }
11400
11401 #[tokio::test]
11414 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
11415 crate::run::pin_test_home();
11416 let home = crate::run::home();
11417
11418 let tmp = tempfile::tempdir().expect("tempdir");
11419 let repo = tmp.path().join("repo");
11420 std::fs::create_dir_all(&repo).unwrap();
11421 init_repo(&repo);
11422 let head = crate::git::rev_parse(&repo, "HEAD")
11423 .await
11424 .expect("rev-parse");
11425 let cache_dir = tmp.path().join("target");
11428
11429 let mut config = Config::default();
11430 config.verify.e2e = vec![format!(
11431 "CARGO_TARGET_DIR='{}' test -f README.md",
11432 cache_dir.display()
11433 )];
11434 config.graph.review_rounds = 1;
11435 config.graph.timeout_verify = Some(2);
11438
11439 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11440 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11441 .expect("no io error acquiring directly")
11442 {
11443 crate::cache::AcquireOutcome::Acquired(g) => g,
11444 crate::cache::AcquireOutcome::Busy(b) => {
11445 panic!("expected the direct acquire to win the lease first: {b:?}")
11446 }
11447 };
11448
11449 let mut state = RunState::new(
11450 repo.clone(),
11451 "main".to_owned(),
11452 head.clone(),
11453 "task".to_owned(),
11454 config,
11455 );
11456 state.candidates = vec![Candidate {
11457 index: 0,
11458 label: 'A',
11459 agent: "alpha".to_owned(),
11460 branch: "does-not-exist".to_owned(),
11461 worktree: repo.clone(),
11462 summary: String::new(),
11463 stat: String::new(),
11464 files: 0,
11465 commits: 0,
11466 empty: false,
11467 failed: None,
11468 verified_noop: None,
11469 duration_ms: 0,
11470 folded: false,
11471 }];
11472 state.tally = Some(Tally {
11473 first_choice: BTreeMap::from([('A', 1)]),
11474 borda: BTreeMap::new(),
11475 winner: 'A',
11476 rankings: 1,
11477 unanimous_initial: true,
11478 deliberated: false,
11479 changed_votes: 0,
11480 unanimous_final: true,
11481 tie_break: None,
11482 judges: 0,
11483 present: 0,
11484 quorum: 0,
11485 met_quorum: true,
11486 uncontested: Some("only candidate A produced a change".to_owned()),
11487 });
11488 state.reviews = vec![ReviewRound {
11492 round: 1,
11493 head: head.clone(),
11494 verified_head: None,
11495 verified_at: None,
11496 reviews: Vec::new(),
11497 e2e: Vec::new(),
11498 fix: None,
11499 blocking: 1,
11500 answered: 1,
11501 expected: 1,
11502 clean: false,
11503 verify_retried: false,
11504 e2e_deferred: true,
11505 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11506 progressed: false,
11507 vote_split: false,
11508 reconsideration: Vec::new(),
11509 verdict: None,
11510 }];
11511
11512 let mut runner = Runner {
11513 state,
11514 roles: ResolvedRoles {
11515 implementers: Vec::new(),
11516 judges: Vec::new(),
11517 reviewers: Vec::new(),
11518 fixer: None,
11519 conductor: conductor(),
11520 implementer_roster: Vec::new(),
11521 judge_roster: Vec::new(),
11522 reviewer_roster: Vec::new(),
11523 },
11524 sem: Arc::new(Semaphore::new(1)),
11525 pause: Pause::new(),
11526 interrupt: Pause::new(),
11527 };
11528
11529 let shell = runner.state.config.shell();
11530 runner
11531 .stop_reviewing("round budget spent", &shell, &repo)
11532 .await
11533 .expect("stop_reviewing");
11534
11535 let last = runner.state.reviews.last().expect("round record");
11536 assert_eq!(
11537 last.e2e_status(),
11538 E2eStatus::ResourceBlocked,
11539 "the shared cache is still held; the attempt must read as blocked, not deferred or \
11540 failed: {last:?}"
11541 );
11542 assert_eq!(
11543 last.verified_head.as_deref(),
11544 Some(head.as_str()),
11545 "which commit this attempt targeted is known even though nothing finished checking \
11546 it"
11547 );
11548 let first_attempt_at = last
11549 .verified_at
11550 .expect("when this attempt ran is known too");
11551 assert_ne!(
11552 runner.state.status,
11553 RunStatus::Blocked,
11554 "contention is evidence about the machine, not the patch — it must not settle the \
11555 run as blocked: {:?}",
11556 runner.state.status
11557 );
11558 assert!(
11559 !runner
11560 .state
11561 .events
11562 .iter()
11563 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11564 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11565 runner.state.events
11566 );
11567
11568 runner
11573 .stop_reviewing("round budget spent", &shell, &repo)
11574 .await
11575 .expect("stop_reviewing retry");
11576 assert_eq!(
11577 runner.state.reviews.len(),
11578 1,
11579 "no new round was started: {:?}",
11580 runner.state.reviews
11581 );
11582 let last = runner.state.reviews.last().expect("round record");
11583 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11584 assert!(
11585 last.verified_at.expect("still known") > first_attempt_at,
11586 "a second reentry must be a fresh attempt, not a stale copy of the first"
11587 );
11588 assert_ne!(runner.state.status, RunStatus::Blocked);
11589
11590 held.release();
11591 }
11592
11593 #[tokio::test]
11605 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11606 crate::run::pin_test_home();
11607 let home = crate::run::home();
11608
11609 let tmp = tempfile::tempdir().expect("tempdir");
11610 let repo = tmp.path().join("repo");
11611 std::fs::create_dir_all(&repo).unwrap();
11612 init_repo(&repo);
11613 let head = crate::git::rev_parse(&repo, "HEAD")
11614 .await
11615 .expect("rev-parse");
11616 let cache_dir = tmp.path().join("target");
11617
11618 let mut config = Config::default();
11619 config.verify.e2e = vec![format!(
11620 "CARGO_TARGET_DIR='{}' test -f README.md",
11621 cache_dir.display()
11622 )];
11623 config.graph.review_rounds = 1;
11624 config.graph.timeout_verify = Some(2);
11625
11626 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11627 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11628 .expect("no io error acquiring directly")
11629 {
11630 crate::cache::AcquireOutcome::Acquired(g) => g,
11631 crate::cache::AcquireOutcome::Busy(b) => {
11632 panic!("expected the direct acquire to win the lease first: {b:?}")
11633 }
11634 };
11635
11636 let mut state = RunState::new(
11637 repo.clone(),
11638 "main".to_owned(),
11639 head.clone(),
11640 "task".to_owned(),
11641 config,
11642 );
11643 state.candidates = vec![Candidate {
11644 index: 0,
11645 label: 'A',
11646 agent: "alpha".to_owned(),
11647 branch: "does-not-exist".to_owned(),
11648 worktree: repo.clone(),
11649 summary: String::new(),
11650 stat: String::new(),
11651 files: 0,
11652 commits: 0,
11653 empty: false,
11654 failed: None,
11655 verified_noop: None,
11656 duration_ms: 0,
11657 folded: false,
11658 }];
11659 state.tally = Some(Tally {
11660 first_choice: BTreeMap::from([('A', 1)]),
11661 borda: BTreeMap::new(),
11662 winner: 'A',
11663 rankings: 1,
11664 unanimous_initial: true,
11665 deliberated: false,
11666 changed_votes: 0,
11667 unanimous_final: true,
11668 tie_break: None,
11669 judges: 0,
11670 present: 0,
11671 quorum: 0,
11672 met_quorum: true,
11673 uncontested: Some("only candidate A produced a change".to_owned()),
11674 });
11675 state.reviews = vec![ReviewRound {
11679 round: 1,
11680 head: head.clone(),
11681 verified_head: Some(head.clone()),
11682 verified_at: Some(jiff::Timestamp::now()),
11683 reviews: Vec::new(),
11684 e2e: vec![CommandOutcome {
11685 command: format!(
11686 "CARGO_TARGET_DIR='{}' test -f README.md",
11687 cache_dir.display()
11688 ),
11689 code: None,
11690 output_tail: "waiting for the shared build cache".to_owned(),
11691 duration_ms: 0,
11692 resource_blocked: true,
11693 }],
11694 fix: None,
11695 blocking: 1,
11696 answered: 1,
11697 expected: 1,
11698 clean: false,
11699 verify_retried: false,
11700 e2e_deferred: false,
11701 e2e_defer_reason: None,
11702 progressed: false,
11703 vote_split: false,
11704 reconsideration: Vec::new(),
11705 verdict: None,
11706 }];
11707
11708 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11709 let mut runner = Runner {
11710 state,
11711 roles: ResolvedRoles {
11712 implementers: Vec::new(),
11713 judges: Vec::new(),
11714 reviewers: Vec::new(),
11715 fixer: None,
11716 conductor: conductor(),
11717 implementer_roster: Vec::new(),
11718 judge_roster: Vec::new(),
11719 reviewer_roster: Vec::new(),
11720 },
11721 sem: Arc::new(Semaphore::new(1)),
11722 pause: Pause::new(),
11723 interrupt: Pause::new(),
11724 };
11725
11726 runner.review_loop().await.expect("review_loop");
11731
11732 assert_eq!(
11733 runner.state.reviews.len(),
11734 1,
11735 "no new round was started on top of the unresolved one: {:?}",
11736 runner.state.reviews
11737 );
11738 let last = &runner.state.reviews[0];
11739 assert_eq!(
11740 last.e2e_status(),
11741 E2eStatus::ResourceBlocked,
11742 "still contended: {last:?}"
11743 );
11744 assert!(
11745 last.verified_at.expect("still known") > first_attempt_at,
11746 "review_loop must have actually retried the check, not left it exactly as found"
11747 );
11748 assert_ne!(
11749 runner.state.status,
11750 RunStatus::Blocked,
11751 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11752 runner.state.status
11753 );
11754
11755 held.release();
11756 }
11757
11758 #[tokio::test]
11759 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11760 crate::run::pin_test_home();
11761 let tmp = tempfile::tempdir().expect("tempdir");
11762 let repo = tmp.path().join("repo");
11763 std::fs::create_dir_all(&repo).unwrap();
11764 init_repo(&repo);
11765
11766 let mut config = Config::default();
11767 config.merge.mode = MergeMode::Pr;
11768 config.graph.land = true;
11769 config.graph.land_approval = false;
11770
11771 let mut state = RunState::new(
11772 repo.clone(),
11773 "main".to_owned(),
11774 "deadbeef".to_owned(),
11775 "task".to_owned(),
11776 config,
11777 );
11778 state.candidates = vec![Candidate {
11779 index: 0,
11780 label: 'A',
11781 agent: "alpha".to_owned(),
11782 branch: "does-not-exist".to_owned(),
11783 worktree: repo.clone(),
11784 summary: String::new(),
11785 stat: String::new(),
11786 files: 0,
11787 commits: 0,
11788 empty: false,
11789 failed: None,
11790 verified_noop: None,
11791 duration_ms: 0,
11792 folded: false,
11793 }];
11794 state.tally = Some(Tally {
11795 first_choice: BTreeMap::from([('A', 1)]),
11796 borda: BTreeMap::new(),
11797 winner: 'A',
11798 rankings: 1,
11799 unanimous_initial: true,
11800 deliberated: false,
11801 changed_votes: 0,
11802 unanimous_final: true,
11803 tie_break: None,
11804 judges: 0,
11805 present: 0,
11806 quorum: 0,
11807 met_quorum: true,
11808 uncontested: Some("only candidate A produced a change".to_owned()),
11809 });
11810 state.reviews = vec![ReviewRound {
11811 round: 1,
11812 head: "deadbeef".to_owned(),
11813 verified_head: None,
11814 verified_at: None,
11815 reviews: Vec::new(),
11816 e2e: Vec::new(),
11817 fix: None,
11818 blocking: 0,
11819 answered: 0,
11820 expected: 0,
11821 clean: true,
11822 verify_retried: false,
11823 e2e_deferred: false,
11824 e2e_defer_reason: None,
11825 progressed: false,
11826 vote_split: false,
11827 reconsideration: Vec::new(),
11828 verdict: None,
11829 }];
11830 state.gate = vec![CommandOutcome {
11831 command: "test".to_owned(),
11832 code: Some(0),
11833 output_tail: String::new(),
11834 duration_ms: 0,
11835 resource_blocked: false,
11836 }];
11837 state.gate_ran = true;
11838 state.status = RunStatus::Landing;
11842 state.merge = Some(MergeOutcome {
11843 mode: MergeMode::Pr,
11844 ok: true,
11845 detail: "https://example.invalid/x/y/pull/1".to_owned(),
11846 empty: false,
11847 });
11848
11849 ask_test_home();
11853 state.save().expect("save landing run");
11856 let store = ask::Questions::open();
11857 let q = ask_open_question(&store, &state.id);
11858
11859 let mut runner = Runner {
11860 state,
11861 roles: ResolvedRoles {
11862 implementers: Vec::new(),
11863 judges: Vec::new(),
11864 reviewers: Vec::new(),
11865 fixer: None,
11866 conductor: conductor(),
11867 implementer_roster: Vec::new(),
11868 judge_roster: Vec::new(),
11869 reviewer_roster: Vec::new(),
11870 },
11871 sem: Arc::new(Semaphore::new(1)),
11872 pause: Pause::new(),
11873 interrupt: Pause::new(),
11874 };
11875
11876 runner.execute().await.expect("execute");
11881
11882 assert_eq!(
11883 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11884 Some("https://example.invalid/x/y/pull/1"),
11885 "reentry must not push again or open a second pull request over the \
11886 one `land` is already watching"
11887 );
11888 assert_ne!(
11889 runner.state.status,
11890 RunStatus::Landing,
11891 "land could not actually reach the fake pull request, so it must \
11892 have given up rather than left the run silently parked forever"
11893 );
11894 assert_eq!(runner.state.status, RunStatus::Blocked);
11898 assert!(
11899 store.get(&q.id).unwrap().status.open(),
11900 "Blocked is still alive; settle_questions must have been a no-op here"
11901 );
11902 }
11903
11904 fn state_with_round(round: ReviewRound) -> RunState {
11905 let mut s = RunState::new(
11906 PathBuf::from("/repo"),
11907 "main".to_owned(),
11908 "abc1234".to_owned(),
11909 "add retries".to_owned(),
11910 Config::default(),
11911 );
11912 s.reviews = vec![round];
11913 s
11914 }
11915
11916 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11917 crate::verdict::Finding {
11918 id: id.to_owned(),
11919 severity,
11920 file: None,
11921 line: None,
11922 title: title.to_owned(),
11923 detail: String::new(),
11924 }
11925 }
11926
11927 #[test]
11928 fn pr_body_names_open_findings_and_declined_ones() {
11929 let round = ReviewRound {
11930 round: 2,
11931 head: "deadbee".to_owned(),
11932 verified_head: None,
11933 verified_at: None,
11934 reviews: vec![ReviewRecord {
11935 attempts: 0,
11936 reviewer: 1,
11937 agent: "alpha".to_owned(),
11938 summary: String::new(),
11939 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11940 vote: None,
11941 failed: None,
11942 duration_ms: 0,
11943 }],
11944 e2e: vec![CommandOutcome {
11945 command: "cargo test".to_owned(),
11946 code: Some(0),
11947 output_tail: String::new(),
11948 duration_ms: 0,
11949 resource_blocked: false,
11950 }],
11951 verify_retried: false,
11952 e2e_deferred: false,
11953 e2e_defer_reason: None,
11954 fix: Some(FixRecord {
11955 agent: "alpha".to_owned(),
11956 addressed: Vec::new(),
11957 rejected: vec![crate::verdict::Rejection {
11958 id: "R1-1-1".to_owned(),
11959 why: "not reachable from any caller".to_owned(),
11960 }],
11961 notes: String::new(),
11962 committed: true,
11963 failed: None,
11964 duration_ms: 0,
11965 continuation: None,
11966 }),
11967 blocking: 0,
11968 answered: 1,
11969 expected: 1,
11970 clean: false,
11971 progressed: true,
11972 vote_split: false,
11973 reconsideration: Vec::new(),
11974 verdict: None,
11975 };
11976 let state = state_with_round(round);
11977 let body = pr_message(&state, 'A').body;
11978
11979 assert!(body.contains("add retries"), "the task must still be there");
11980 assert!(body.contains("R2-1-1"), "{body}");
11981 assert!(body.contains("unused import"), "{body}");
11982 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11983 assert!(
11984 body.contains("not reachable from any caller"),
11985 "the reason it was declined: {body}"
11986 );
11987 }
11988
11989 #[test]
11990 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11991 let round = ReviewRound {
11992 round: 1,
11993 head: "deadbee".to_owned(),
11994 verified_head: None,
11995 verified_at: None,
11996 reviews: vec![ReviewRecord {
11997 attempts: 0,
11998 reviewer: 1,
11999 agent: "alpha".to_owned(),
12000 summary: String::new(),
12001 findings: Vec::new(),
12002 vote: None,
12003 failed: None,
12004 duration_ms: 0,
12005 }],
12006 e2e: Vec::new(),
12007 verify_retried: false,
12008 e2e_deferred: false,
12009 e2e_defer_reason: None,
12010 fix: None,
12011 blocking: 0,
12012 answered: 1,
12013 expected: 1,
12014 clean: true,
12015 progressed: false,
12016 vote_split: false,
12017 reconsideration: Vec::new(),
12018 verdict: None,
12019 };
12020 let state = state_with_round(round);
12021 let body = pr_message(&state, 'A').body;
12022 assert!(!body.contains("Open review findings"), "{body}");
12023 assert!(!body.contains("Declined"), "{body}");
12024 }
12025
12026 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
12027 let mut state = RunState::new(
12028 PathBuf::from("/repo"),
12029 "main".to_owned(),
12030 "abc1234".to_owned(),
12031 instruction.to_owned(),
12032 Config::default(),
12033 );
12034 state.candidates.push(Candidate {
12035 index: 0,
12036 label: 'A',
12037 agent: "alpha".to_owned(),
12038 branch: "magi/x/A".to_owned(),
12039 worktree: PathBuf::from("/wt"),
12040 summary: summary.to_owned(),
12041 stat: String::new(),
12042 files: 1,
12043 commits: 1,
12044 empty: false,
12045 failed: None,
12046 verified_noop: None,
12047 folded: false,
12048 duration_ms: 0,
12049 });
12050 state
12051 }
12052
12053 fn review_state(subjects: &[&str]) -> RunState {
12054 let mut state = state_with_summary(
12055 "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",
12056 "",
12057 );
12058 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
12059 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
12060 state
12061 }
12062
12063 #[tokio::test]
12066 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
12067 ask_test_home();
12068 let tmp = tempfile::tempdir().unwrap();
12069 let repo = tmp.path().join("repo");
12070 std::fs::create_dir_all(&repo).unwrap();
12071 init_repo(&repo);
12072 let origin = tmp.path().join("origin.git");
12073 let g = |dir: &Path, args: &[&str]| {
12074 let out = std::process::Command::new("git")
12075 .args(args)
12076 .current_dir(dir)
12077 .quiet()
12078 .output()
12079 .expect("spawn git");
12080 assert!(
12081 out.status.success(),
12082 "git {args:?}: {}",
12083 String::from_utf8_lossy(&out.stderr)
12084 );
12085 };
12086 g(
12087 tmp.path(),
12088 &[
12089 "clone",
12090 "--bare",
12091 "-q",
12092 repo.to_str().unwrap(),
12093 origin.to_str().unwrap(),
12094 ],
12095 );
12096 g(
12097 &repo,
12098 &["remote", "add", "origin", origin.to_str().unwrap()],
12099 );
12100 let c1 = git::rev_parse(&repo, "main").await.unwrap();
12101
12102 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
12104 g(&repo, &["add", "-A"]);
12105 g(
12106 &repo,
12107 &["commit", "-q", "-m", "chore(deps): update a crate"],
12108 );
12109 g(&repo, &["push", "-q", "origin", "main"]);
12110 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
12111 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
12112 g(&repo, &["add", "-A"]);
12113 g(
12114 &repo,
12115 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
12116 );
12117 g(&repo, &["checkout", "-q", "main"]);
12118
12119 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
12120 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
12121 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
12123 assert_eq!(fallback, c1);
12124
12125 let mut state = review_state(&[
12126 "chore(deps): update a crate",
12127 "fix(daemon): apply a chosen action",
12128 ]);
12129 state.repo = repo.clone();
12130 state.base_branch = "main".to_owned();
12131 state.base_commit = c1;
12132 refresh_reviewed_commits(&mut state, "feat/own").await;
12133 assert_eq!(
12134 state.reviewed_commits,
12135 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
12136 );
12137 assert_eq!(
12138 review_title(&state).as_deref(),
12139 Some("fix(daemon): apply a chosen action")
12140 );
12141 assert_eq!(
12142 leaked_subjects(&state, "feat/own").await,
12143 Some(vec!["chore(deps): update a crate".to_owned()])
12144 );
12145 state.config.merge.remote = "nowhere".to_owned();
12148 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
12149 }
12150
12151 #[test]
12152 fn pr_message_review_single_commit_uses_its_subject() {
12153 let state = review_state(&["feat(nats): per-role user"]);
12154 let m = pr_message(&state, 'A');
12155 assert_eq!(m.title, "feat(nats): per-role user");
12156 assert!(!m.body.contains("Review the work already"), "{}", m.body);
12157 assert!(m.body.contains("## Commits under review"), "{}", m.body);
12158 }
12159
12160 #[test]
12161 fn pr_message_review_multi_commit_takes_the_oldest() {
12162 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
12163 let m = pr_message(&state, 'A');
12164 assert_eq!(m.title, "feat: the change");
12165 for s in ["feat: the change", "fix: typo", "fix: again"] {
12166 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
12167 }
12168 }
12169
12170 #[test]
12171 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
12172 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
12173 let mut state = review_state(&[first, "fix: later fixup"]);
12174 state.candidates[0].branch = "機能/ログイン".to_owned();
12175 let m = pr_message(&state, 'A');
12176 assert!(
12177 m.title.starts_with("chore: land candidate A of run"),
12178 "{}",
12179 m.title
12180 );
12181 }
12182 }
12183
12184 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
12185 BranchFacts {
12186 commits: commits
12187 .iter()
12188 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
12189 .collect(),
12190 stat: stat.to_owned(),
12191 }
12192 }
12193
12194 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
12195 let mut r = review_round(true, 0, 1, 1, true, true);
12196 r.round = round;
12197 r.fix = notes.map(|n| FixRecord {
12198 agent: "fixer".to_owned(),
12199 addressed: Vec::new(),
12200 rejected: Vec::new(),
12201 notes: n.to_owned(),
12202 committed: true,
12203 failed: None,
12204 duration_ms: 0,
12205 continuation: None,
12206 });
12207 r
12208 }
12209
12210 #[test]
12211 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
12212 let state = review_state(&["ignored"]);
12213 let f = facts(
12214 &[
12215 (
12216 "fix(login): resolve PATH on macOS",
12217 "Login shells skip rc files.",
12218 ),
12219 ("fix: address review", ""),
12220 ],
12221 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
12222 );
12223 let m = pr_message_with(&state, 'A', Some(&f));
12224 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
12225 assert!(
12226 m.body
12227 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
12228 "{}",
12229 m.body
12230 );
12231 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
12232 assert!(m.body.contains("## Diff stat"), "{}", m.body);
12233 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
12234 for banned in [
12235 "Review the work already",
12236 "no task statement",
12237 "Original task",
12238 ] {
12239 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
12240 }
12241 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
12242 }
12243
12244 #[test]
12245 fn pr_message_review_truncates_a_huge_first_commit_body() {
12246 let state = review_state(&["ignored"]);
12247 let f = facts(
12248 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
12249 "s",
12250 );
12251 let m = pr_message_with(&state, 'A', Some(&f));
12252 assert!(m.body.len() < 30_000, "{}", m.body.len());
12253 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
12254 assert!(
12255 m.body.contains("- ... 1 more commit(s)"),
12256 "{}",
12257 m.body.len()
12258 );
12259 assert!(m.body.ends_with("magi:candidate-a\n"));
12260 }
12261
12262 #[test]
12263 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
12264 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
12265 assert_eq!(m.title, "feat: x");
12266 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
12267 assert!(!m.body.contains("Diff stat"), "{}", m.body);
12268 assert!(!m.body.contains("no task statement"), "{}", m.body);
12269 }
12270
12271 #[test]
12272 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
12273 let mut state = review_state(&["日本語の件名"]);
12274 state.candidates[0].branch = "fix/macos-login-path".to_owned();
12275 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
12276 state.candidates[0].branch = "機能/ログイン".to_owned();
12277 assert!(
12278 pr_message(&state, 'A')
12279 .title
12280 .starts_with("chore: land candidate A")
12281 );
12282 }
12283
12284 #[test]
12285 fn pr_message_review_fixes_survive_a_clean_final_round() {
12286 let mut state = review_state(&["feat: x"]);
12287 state.reviews = vec![
12288 round_with_notes(1, Some("handled the PATH case")),
12289 round_with_notes(2, None),
12290 ];
12291 let body = pr_message(&state, 'A').body;
12292 assert!(
12293 body.contains("## Review fixes\n\nhandled the PATH case\n"),
12294 "{body}"
12295 );
12296 assert!(!body.contains("### Round"), "{body}");
12297
12298 state.reviews = vec![
12299 round_with_notes(1, Some("first fix")),
12300 round_with_notes(2, Some("")),
12301 round_with_notes(3, Some("second fix")),
12302 round_with_notes(4, None),
12303 ];
12304 let body = pr_message(&state, 'A').body;
12305 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
12306 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
12307 assert!(!body.contains("### Round 2"), "{body}");
12308 }
12309
12310 #[test]
12311 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
12312 let state = state_with_summary(
12313 "add retries to the client",
12314 "TITLE: feat: retries\n\nDid it.",
12315 );
12316 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
12317 assert_eq!(m.title, "feat: retries");
12318 assert!(
12319 m.body.contains("<summary>Original task</summary>"),
12320 "{}",
12321 m.body
12322 );
12323 assert!(!m.body.contains("Commits under review"), "{}", m.body);
12324 assert!(
12325 m.body
12326 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
12327 "{}",
12328 m.body
12329 );
12330 }
12331
12332 #[test]
12333 fn pr_message_review_bounds_a_long_english_subject() {
12334 let long = format!("feat: {}", "word ".repeat(100));
12335 let m = pr_message(&review_state(&[&long]), 'A');
12336 assert!(m.title.starts_with("feat: word"), "{}", m.title);
12337 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12338 }
12339
12340 #[test]
12341 fn pr_message_implementation_run_is_unchanged_by_review_support() {
12342 let state = state_with_summary("add retries\n\ndetails", "- did some things");
12343 let m = pr_message(&state, 'A');
12344 assert_eq!(m.title, "add retries");
12345 assert!(m.body.contains("<summary>Original task</summary>"));
12346 assert!(!m.body.contains("Commits under review"));
12347 assert_eq!(landing_subject_source(&state), state.instruction);
12348 }
12349
12350 #[test]
12351 fn review_run_squash_subject_is_the_change_not_the_prompt() {
12352 let state = review_state(&["feat: the change", "fix: typo"]);
12353 let source = landing_subject_source(&state);
12354 assert_eq!(land::merge_subject("", &source), "feat: the change");
12355 assert_eq!(
12356 land::merge_subject("magi: candidate A (uncommitted work)", &source),
12357 "feat: the change"
12358 );
12359 assert_eq!(
12361 land::merge_subject("feat: renamed by hand", &source),
12362 "feat: renamed by hand"
12363 );
12364 let blank = review_state(&["日本語"]);
12365 assert!(
12366 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
12367 );
12368 }
12369
12370 #[test]
12371 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
12372 let state = review_state(&["feat: the change"]);
12373 let source = landing_subject_source(&state);
12374 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
12375 assert_eq!(
12376 land::merge_subject(landing_title(&state, old), &source),
12377 "feat: the change"
12378 );
12379 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
12380 let task = state_with_summary("add retries", "");
12381 assert_eq!(landing_title(&task, old), old);
12382 }
12383
12384 #[test]
12385 fn pr_message_describes_the_change_not_the_task() {
12386 let state = state_with_summary(
12387 "今回やってほしいこと: results projector を直す",
12388 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
12389 );
12390 let m = pr_message(&state, 'A');
12391 assert_eq!(m.title, "fix(web): batch the runs list reads");
12392 assert!(
12393 m.body.starts_with("## Summary\n\n- reads run.json once"),
12394 "{}",
12395 m.body
12396 );
12397 assert!(!m.body.contains("TITLE:"), "{}", m.body);
12398 let task_at = m.body.find("今回やってほしいこと").unwrap();
12399 let details_at = m.body.find("<details>").unwrap();
12400 assert!(
12401 details_at < task_at,
12402 "the task lives inside <details>: {}",
12403 m.body
12404 );
12405 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
12406 assert!(m.body.contains("magi:candidate-a"));
12407 }
12408
12409 #[test]
12410 fn pr_message_falls_back_to_the_task_without_a_title_line() {
12411 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
12412 let m = pr_message(&state, 'A');
12413 assert_eq!(m.title, "add retries");
12414 assert!(
12415 m.body.contains("## Summary\n\n- did some things"),
12416 "{}",
12417 m.body
12418 );
12419
12420 let none = RunState::new(
12421 PathBuf::from("/repo"),
12422 "main".to_owned(),
12423 "abc1234".to_owned(),
12424 "add retries".to_owned(),
12425 Config::default(),
12426 );
12427 let m = pr_message(&none, 'A');
12428 assert_eq!(m.title, "add retries");
12429 assert!(!m.body.contains("## Summary"), "{}", m.body);
12430 }
12431
12432 #[test]
12433 fn pr_message_refuses_the_candidate_commit_subject() {
12434 for bad in [
12435 "TITLE: magi: candidate A (uncommitted work)",
12436 "TITLE: chore: stuff (uncommitted work)",
12437 "TITLE: ",
12438 ] {
12439 let state = state_with_summary("add retries", bad);
12440 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
12441 }
12442 }
12443
12444 #[test]
12445 fn pr_message_bounds_a_very_long_task_and_title() {
12446 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
12447 let state = state_with_summary(&long, "- nothing");
12448 let m = pr_message(&state, 'A');
12449 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12450 assert!(!m.title.contains('\n'));
12451
12452 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12453 let m = pr_message(&state, 'A');
12454 assert!(m.title.starts_with("feat: "));
12455 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12456 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12457 }
12458
12459 fn long_title_of(instruction: &str) -> String {
12460 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12461 }
12462
12463 #[test]
12464 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12465 let first = "Make the landing path keep a readable title for long tasks";
12466 let line = format!(
12467 "{first}. {}",
12468 "And then keep going with more words ".repeat(20)
12469 );
12470 let t = long_title_of(&line);
12471 assert_eq!(t, first);
12472 assert!(!t.starts_with("chore: land"));
12473 }
12474
12475 #[test]
12476 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12477 let line = "word ".repeat(200);
12478 let t = long_title_of(&line);
12479 assert!(t.ends_with("word..."), "{t}");
12480 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12481 }
12482
12483 #[test]
12484 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12485 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12486 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12487 }
12488 }
12489
12490 #[test]
12491 fn pr_message_title_limit_is_exact() {
12492 let at = "a".repeat(PR_TITLE_MAX);
12493 assert_eq!(long_title_of(&at), at);
12494 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12495 assert!(over.ends_with("..."), "{over}");
12496 assert_eq!(over.chars().count(), PR_TITLE_MAX);
12497 }
12498
12499 #[test]
12500 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12501 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12502 let t = long_title_of(&line);
12503 assert!(t.ends_with("..."), "{t}");
12504 assert!(t.is_ascii(), "{t}");
12505 }
12506
12507 #[test]
12508 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12509 let line = format!(
12510 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12511 "plus more filler words ".repeat(20)
12512 );
12513 let t = long_title_of(&line);
12514 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12515 }
12516
12517 #[test]
12518 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12519 let line = format!("\n\n# {}", "title words ".repeat(40));
12520 let state = state_with_summary(&line, "- nothing");
12521 let m = pr_message(&state, 'A');
12522 assert!(m.title.starts_with("title words"), "{}", m.title);
12523 assert_eq!(
12524 land::merge_subject(&m.title, &landing_subject_source(&state)),
12525 m.title
12526 );
12527 assert_eq!(
12529 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12530 "feat: renamed by hand"
12531 );
12532 }
12533
12534 #[tokio::test]
12535 async fn github_text_rewrite_is_bounded_and_falls_back() {
12536 crate::run::pin_test_home();
12537 for (reply, accepted) in [
12538 (
12539 "TITLE: fix: retries\nAdd retries for failed requests.",
12540 true,
12541 ),
12542 (
12543 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12544 false,
12545 ),
12546 ("TITLE: fix: retries\nUse token=secret", false),
12547 ] {
12548 let dir = tempfile::tempdir().unwrap();
12549 let mut runner = runner_at(RunStatus::Gating);
12550 runner.state = state_with_summary(
12551 "add retries",
12552 "TITLE: fix: retries\n日本語の説明を書きます。",
12553 );
12554 runner.state.repo = dir.path().to_owned();
12555 runner.state.candidates[0].worktree = dir.path().to_owned();
12556 let mut author = spec("alpha");
12557 author.command = vec![
12558 "sh".into(),
12559 "-c".into(),
12560 "printf '%s' \"$REWRITE_REPLY\"".into(),
12561 ];
12562 author.env.insert("REWRITE_REPLY".into(), reply.into());
12563 runner.state.config.agents = vec![author];
12564 let winner = runner.state.candidates[0].clone();
12565 let first = runner
12566 .guarded_pr_message(&winner, None, true)
12567 .await
12568 .unwrap();
12569 let message = if accepted {
12570 first.expect("a passing rewrite never asks")
12571 } else {
12572 assert!(first.is_none());
12574 assert!(runner.state.parked);
12575 let store = ask::Questions::open();
12576 let asked: Vec<_> = store
12577 .list()
12578 .into_iter()
12579 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12580 .collect();
12581 assert_eq!(asked.len(), 1);
12582 assert!(!asked[0].detail.contains("secret"));
12583 store
12586 .update(&asked[0].id, |q| {
12587 q.abandon("test");
12588 Ok(())
12589 })
12590 .unwrap();
12591 let again = runner
12592 .guarded_pr_message(&winner, None, true)
12593 .await
12594 .unwrap()
12595 .expect("an abandoned question falls back");
12596 let n = store
12597 .list()
12598 .into_iter()
12599 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12600 .count();
12601 assert_eq!(n, 1, "asked once per rejected text");
12602 again
12603 };
12604 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12605 assert_eq!(
12606 message.body.contains("Add retries for failed requests."),
12607 accepted
12608 );
12609 assert_eq!(
12610 runner.state.seats["impl-A"].turns, 1,
12611 "only one rewrite invocation"
12612 );
12613 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12614 if !accepted {
12615 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12616 assert!(
12617 message
12618 .body
12619 .contains(&format!("magi:run/{}", runner.state.id))
12620 );
12621 }
12622 }
12623 }
12624
12625 #[tokio::test]
12626 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12627 crate::run::pin_test_home();
12628 let dir = tempfile::tempdir().unwrap();
12629 let mut runner = runner_at(RunStatus::Gating);
12630 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12631 runner.state.repo = dir.path().to_owned();
12632 runner.state.candidates[0].worktree = dir.path().to_owned();
12633 let winner = runner.state.candidates[0].clone();
12634 assert!(
12635 runner
12636 .guarded_pr_message(&winner, None, true)
12637 .await
12638 .unwrap()
12639 .is_none()
12640 );
12641 let store = ask::Questions::open();
12642 let find = |run: &str| {
12643 store
12644 .list()
12645 .into_iter()
12646 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12647 .unwrap()
12648 };
12649 let run = runner.state.id.clone();
12650 let q = find(&run);
12651 let fp = runner
12652 .state
12653 .github_text
12654 .as_ref()
12655 .unwrap()
12656 .fingerprint
12657 .clone();
12658 let name = crate::github_text::artifact_name(&fp);
12659 let path = crate::run::artifact_path(&runner.state, &name);
12660 assert!(q.detail.contains(&path.display().to_string()));
12661 assert!(q.detail.contains("title-language: title"));
12662 let saved = std::fs::read_to_string(&path).expect("artifact written");
12663 assert!(saved.contains("日本語のタイトル"));
12664 assert!(q.cwd.is_none());
12665 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
12666 let say = |id: &str, text: &str, choice: &str| {
12668 store
12669 .update(id, |q| {
12670 q.thread.push(ask::Turn {
12671 who: ask::Who::Operator,
12672 body: text.to_owned(),
12673 at: jiff::Timestamp::now(),
12674 note: None,
12675 });
12676 q.answer(ask::Answer::Choice(choice.to_owned()))
12677 })
12678 .unwrap();
12679 };
12680 say(&q.id, "まだ日本語", "use my text");
12681 assert!(
12682 runner
12683 .guarded_pr_message(&winner, None, true)
12684 .await
12685 .unwrap()
12686 .is_none()
12687 );
12688 let second = find(&run);
12689 assert_ne!(second.id, q.id);
12690 assert!(second.detail.contains("did not pass"));
12691 assert!(second.detail.contains("日本語のタイトル"));
12694 assert!(!second.detail.contains("まだ日本語"));
12695 assert!(second.detail.contains(&path.display().to_string()));
12696 say(&second.id, "fix: retry failed requests", "use my text");
12697 let message = runner
12698 .guarded_pr_message(&winner, None, true)
12699 .await
12700 .unwrap()
12701 .expect("a vetted replacement is posted");
12702 assert_eq!(message.title, "fix: retry failed requests");
12703 assert_eq!(
12704 runner
12705 .state
12706 .github_text
12707 .as_ref()
12708 .unwrap()
12709 .chosen_title
12710 .as_deref(),
12711 Some("fix: retry failed requests")
12712 );
12713 assert!(runner.state.github_text.as_ref().unwrap().resolved);
12714 let again = runner
12716 .guarded_pr_message(&winner, None, true)
12717 .await
12718 .unwrap()
12719 .unwrap();
12720 assert_eq!(again.title, message.title);
12721 assert_eq!(
12722 store
12723 .list()
12724 .into_iter()
12725 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12726 .count(),
12727 2
12728 );
12729 }
12730
12731 #[test]
12732 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12733 let mut state = state_with_summary(
12737 "add retries",
12738 "TITLE: fix(web): batch reads\n- reads run.json once",
12739 );
12740 state.config.graph.language = "ja".to_owned();
12741 let m = pr_message(&state, 'A');
12742 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
12743 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
12744
12745 let task = "今回やってほしいこと: results projector を直す";
12748 let mut state = state_with_summary(task, "- no title line");
12749 state.config.graph.language = "ja".to_owned();
12750 let m = pr_message(&state, 'A');
12751 assert_eq!(
12752 m.title,
12753 format!("chore: land candidate A of run {}", state.id)
12754 );
12755 assert!(
12756 m.body.contains(&format!(
12757 "<summary>Original task</summary>\n\n{task}\n\n</details>"
12758 )),
12759 "{}",
12760 m.body
12761 );
12762 }
12763
12764 #[test]
12765 fn pr_message_scrubs_home_paths_and_addresses() {
12766 let state = state_with_summary(
12767 "fix it in /Users/someone/src/x",
12768 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
12769 );
12770 let m = pr_message(&state, 'A');
12771 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
12772 assert!(!m.body.contains(leak), "{}", m.body);
12773 }
12774 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
12775 }
12776
12777 #[test]
12778 fn pr_message_survives_a_task_that_closes_details() {
12779 let state = state_with_summary("a </details> b", "TITLE: fix: x");
12780 let m = pr_message(&state, 'A');
12781 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
12782 }
12783
12784 #[test]
12785 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
12786 let cmd = manual_merge_command(
12787 MergeStyle::Squash,
12788 Path::new("/repo"),
12789 "b",
12790 "fix: \"quoted\" $(x) `y`\n\nbody",
12791 );
12792 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
12793 }
12794
12795 #[test]
12796 fn manual_merge_command_matches_the_configured_style() {
12797 let repo = Path::new("/repo");
12798 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
12799
12800 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
12801 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
12802
12803 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
12804 assert_eq!(
12805 squash,
12806 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
12807 \"Merge magi run 0832 (candidate A)\""
12808 );
12809
12810 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
12811 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
12812 }
12813
12814 #[test]
12815 fn a_nudge_gets_a_quarter_of_the_budget() {
12816 assert_eq!(retry_budget(secs(1200), true), secs(300));
12818 assert_eq!(retry_budget(secs(3600), true), secs(900));
12819 }
12820
12821 #[test]
12822 fn a_resent_prompt_keeps_the_whole_budget() {
12823 assert_eq!(retry_budget(secs(1200), false), secs(1200));
12826 assert_eq!(retry_budget(secs(60), false), secs(60));
12827 }
12828
12829 #[test]
12830 fn the_floor_never_exceeds_the_original_budget() {
12831 assert_eq!(retry_budget(secs(60), true), secs(60));
12835 assert_eq!(retry_budget(secs(480), true), secs(120));
12836 assert_eq!(retry_budget(secs(0), true), secs(0));
12837 }
12838
12839 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12840 agent::CommandEvidence {
12841 id: "item1".to_owned(),
12842 description: "cargo test".to_owned(),
12843 exit_code,
12844 result_summary: String::new(),
12845 source: "codex".to_owned(),
12846 }
12847 }
12848
12849 #[test]
12850 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12851 assert!(!has_unconfirmed_command(&[]));
12855 }
12856
12857 #[test]
12858 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12859 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12863 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12864 assert!(!has_unconfirmed_command(&[
12865 evidence(Some(0)),
12866 evidence(Some(101))
12867 ]));
12868 }
12869
12870 #[test]
12871 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12872 assert!(has_unconfirmed_command(&[
12873 evidence(Some(0)),
12874 evidence(None)
12875 ]));
12876 }
12877
12878 #[test]
12879 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12880 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12881 assert_eq!(
12882 verified_noop_claim(true, &[], text).as_deref(),
12883 Some("already fixed by b32cfc4, on main.")
12884 );
12885 }
12886
12887 #[test]
12888 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12889 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12892 assert!(verified_noop_claim(false, &[], text).is_none());
12893 }
12894
12895 #[test]
12896 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12897 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12898 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12899 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12901 }
12902
12903 #[test]
12904 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12905 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12906 }
12907
12908 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12911 runner.state.candidates = shape
12912 .iter()
12913 .enumerate()
12914 .map(|(i, &(empty, verified))| Candidate {
12915 index: i,
12916 label: (b'A' + i as u8) as char,
12917 agent: "sonnet".to_owned(),
12918 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12919 worktree: PathBuf::from(format!("/wt/{i}")),
12920 summary: String::new(),
12921 stat: String::new(),
12922 files: 0,
12923 commits: 0,
12924 empty,
12925 failed: None,
12926 verified_noop: verified.map(str::to_owned),
12927 duration_ms: 0,
12928 folded: false,
12929 })
12930 .collect();
12931 }
12932
12933 #[test]
12934 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12935 ask_test_home();
12936 let mut runner = runner_at(RunStatus::Implementing);
12937 set_candidates(
12938 &mut runner,
12939 &[
12940 (true, Some("already on main at b32cfc4")),
12941 (true, Some("same fix, see the existing test")),
12942 ],
12943 );
12944
12945 runner
12946 .after_implement()
12947 .expect("a verified no-op is not an error");
12948
12949 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12950 }
12951
12952 #[test]
12953 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12954 ask_test_home();
12955 let mut runner = runner_at(RunStatus::Implementing);
12956 set_candidates(
12960 &mut runner,
12961 &[(true, Some("already on main at b32cfc4")), (true, None)],
12962 );
12963
12964 let err = runner
12965 .after_implement()
12966 .expect_err("an unverified empty candidate must still fail the run");
12967
12968 assert!(
12969 err.to_string().contains("no candidate produced a change"),
12970 "{err}"
12971 );
12972 assert_eq!(runner.state.status, RunStatus::Failed);
12973 }
12974
12975 #[test]
12976 fn after_implement_still_fails_an_ordinary_all_empty_run() {
12977 ask_test_home();
12978 let mut runner = runner_at(RunStatus::Implementing);
12979 set_candidates(&mut runner, &[(true, None), (true, None)]);
12980
12981 let err = runner
12982 .after_implement()
12983 .expect_err("no candidate declared anything; this is an ordinary failure");
12984
12985 assert!(
12986 err.to_string().contains("no candidate produced a change"),
12987 "{err}"
12988 );
12989 assert_eq!(runner.state.status, RunStatus::Failed);
12990 }
12991}
12992
12993#[derive(Debug)]
12996struct GraftRestoreFailed(String);
12997
12998impl std::fmt::Display for GraftRestoreFailed {
12999 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13000 write!(
13001 f,
13002 "could not restore the winner to its pre-graft commit: {}",
13003 self.0
13004 )
13005 }
13006}
13007
13008impl std::error::Error for GraftRestoreFailed {}