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 let mut rejected_rewrite = None;
6436 if has_language
6437 && let Some(spec) = self
6438 .state
6439 .config
6440 .agents
6441 .iter()
6442 .find(|a| a.id == winner.agent)
6443 .cloned()
6444 {
6445 let key = format!("impl-{}", winner.label);
6446 let seat = self.seat(&key, &spec.id);
6447 let prompt = format!(
6448 "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{}",
6449 pr.title, pr.body
6450 );
6451 let job = SeatJob {
6452 spec,
6453 seat,
6454 cwd: winner.worktree.clone(),
6455 prompt,
6456 timeout: retry_budget(
6457 Duration::from_secs(self.state.config.graph.timeout_implement),
6458 true,
6459 ),
6460 allow_write: false,
6461 sessions: self.state.config.graph.sessions,
6462 artifacts: agent::artifacts_dir(&self.state.dir()),
6463 stem: "github-text-rewrite".to_owned(),
6464 handover: None,
6465 };
6466 let prompts = self.state.config.prompts.clone();
6467 let cache = self.state.config.cache_dir();
6468 let run = self.state.id.clone();
6469 let ctx = WaveCtx {
6470 run: &run,
6471 node: "github-text",
6472 prompts: &prompts,
6473 cache: cache.as_deref(),
6474 round: None,
6475 carry_seats: false,
6476 };
6477 let (seat, outcome) =
6478 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6479 self.state.seats.insert(seat.key.clone(), seat);
6480 if let AgentOutcome::Ok(output) = outcome
6481 && let Some(title) = summary_title(&output.text)
6482 {
6483 let raw_body = summary_without_title(&output.text);
6486 let clean_title = crate::scrub::scrub(&title, &identity);
6487 let mut body = crate::scrub::scrub(&raw_body, &identity);
6488 let redacted = clean_title != title || body != raw_body;
6489 let title = clean_title;
6490 let marker = format!("magi:run/{}", self.state.id);
6491 if !body.trim().is_empty() && !body.contains(&marker) {
6492 body.push_str(&format!("\n\n{marker}"));
6493 }
6494 let rewrite = PrMessage {
6495 title: title.clone(),
6496 body: body.clone(),
6497 };
6498 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6499 let mut failed = crate::github_text::check_with(&title, &body, redecision);
6500 if (!crate::github_text::shareable(&title)
6501 || !crate::github_text::shareable(&body))
6502 && !failed.contains(&crate::github_text::Violation::SensitiveData)
6503 {
6504 failed.push(crate::github_text::Violation::SensitiveData);
6505 }
6506 if !title.trim().is_empty() && !body.trim().is_empty() && !failed.is_empty() {
6507 rejected_rewrite = Some(crate::run::GithubTextRewrite {
6508 title: title.clone(),
6509 body: body.clone(),
6510 failed: failed
6511 .iter()
6512 .map(|v| crate::github_text::category(*v).to_owned())
6513 .collect(),
6514 });
6515 }
6516 if !title.trim().is_empty() && !body.trim().is_empty() && failed.is_empty() {
6517 decision = redecision;
6518 pr = PrMessage { title, body };
6519 rewritten = true;
6520 self.state
6521 .event("github-text", "description rewrite passed");
6522 if redacted {
6523 self.state.event(
6524 "github-text",
6525 "sensitive data removed from description rewrite",
6526 );
6527 }
6528 }
6529 }
6530 }
6531 if !rewritten {
6532 self.state.event(
6533 "github-text",
6534 "description rewrite unavailable or rejected; asking the owner",
6535 );
6536 if let Some(w) =
6537 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6538 {
6539 self.retire_github_text_question("the withheld text changed");
6542 let record = crate::run::GithubTextAsk {
6543 rewrite: rejected_rewrite,
6544 fingerprint: raw_fp,
6545 title: w.title,
6546 body: w.body,
6547 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6548 question: None,
6549 asks: 0,
6550 resolved: false,
6551 chosen_title: None,
6552 choice: None,
6553 };
6554 return self.settle_withheld(winner, &pr, record).await;
6555 }
6556 }
6557 }
6558 let (title, body) =
6559 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6560 Ok(Some(PrMessage { title, body }))
6561 }
6562
6563 fn retire_github_text_question(&mut self, why: &str) {
6565 let Some(id) = self
6566 .state
6567 .github_text
6568 .as_ref()
6569 .and_then(|g| g.question.clone())
6570 else {
6571 return;
6572 };
6573 let _ = ask::Questions::open().update(&id, |q| {
6574 q.abandon(why);
6575 Ok(())
6576 });
6577 }
6578
6579 async fn settle_withheld(
6583 &mut self,
6584 winner: &Candidate,
6585 pr: &PrMessage,
6586 mut g: crate::run::GithubTextAsk,
6587 ) -> Result<Option<PrMessage>> {
6588 use crate::github_text as gt;
6589 let store = ask::Questions::open();
6590 let timeout = self.state.config.graph.answer_timeout;
6591 if !g.resolved {
6592 let mut standing = g
6593 .question
6594 .as_ref()
6595 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6596 if let Some(q) = standing.as_ref()
6597 && q.status.open()
6598 && gt::expired(q, timeout)
6599 {
6600 let why = format!("no answer within {}s of asking", timeout.max(1));
6601 standing = store
6602 .update(&q.id, |q| {
6603 q.abandon(&why);
6604 Ok(())
6605 })
6606 .ok()
6607 .map(|(q, ())| q);
6608 }
6609 let mut ask_again: Option<Option<Vec<String>>> = None;
6610 match standing {
6611 None if g.asks <= 1 => {
6614 g.asks = 0;
6615 ask_again = Some(None);
6616 }
6617 None => {
6618 g.resolved = true;
6619 g.chosen_title = None;
6620 g.choice = Some(GithubTextChoice::Fallback);
6621 }
6622 Some(q) if q.status.open() => {
6623 self.park_on_github_text(&g)?;
6624 return Ok(None);
6625 }
6626 Some(q) => match gt::read_reply(&q) {
6627 gt::Reply::Fallback => {
6628 g.resolved = true;
6629 g.chosen_title = None;
6630 g.choice = Some(GithubTextChoice::Fallback);
6631 }
6632 gt::Reply::Original => {
6633 g.resolved = true;
6634 g.chosen_title = None;
6635 g.choice = Some(GithubTextChoice::Original);
6636 }
6637 gt::Reply::Rewrite => {
6638 g.resolved = true;
6639 g.chosen_title = None;
6640 g.choice = Some(if g.rewrite.is_some() {
6641 GithubTextChoice::Rewrite
6642 } else {
6643 GithubTextChoice::Fallback
6644 });
6645 }
6646 gt::Reply::Title(text) => {
6647 let first = text
6648 .lines()
6649 .map(str::trim)
6650 .find(|l| !l.is_empty())
6651 .unwrap_or("");
6652 let decision = if first.is_empty() {
6653 None
6654 } else {
6655 let candidate = PrMessage {
6656 title: first.to_owned(),
6657 body: String::new(),
6658 };
6659 self.judge_pr_language(&winner.worktree, &candidate).await
6660 };
6661 match gt::vet_title(&text, decision) {
6662 Ok(title) => {
6663 g.resolved = true;
6664 g.chosen_title = Some(title);
6665 g.choice = Some(GithubTextChoice::MyTitle);
6666 }
6667 Err(cats) if g.asks < 2 => {
6668 ask_again = Some(Some(
6669 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
6670 ))
6671 }
6672 Err(_) => {
6673 g.resolved = true;
6674 g.chosen_title = None;
6675 g.choice = Some(GithubTextChoice::Fallback);
6676 }
6677 }
6678 }
6679 },
6680 }
6681 if let Some(rejected) = ask_again {
6682 let w = gt::Withheld {
6685 title: g.title,
6686 body: g.body,
6687 categories: g.categories.clone(),
6688 };
6689 let artifact = match crate::run::write_artifact(
6692 &self.state,
6693 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
6694 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
6695 ) {
6696 Ok(p) => Some(p),
6697 Err(e) => {
6698 tracing::warn!("could not write the withheld-text artifact: {e:#}");
6699 None
6700 }
6701 };
6702 let mut q = ask::Question::new(
6703 self.state.id.clone(),
6704 gt::ASK_NODE.to_owned(),
6705 gt::ASK_SEAT.to_owned(),
6706 gt::question_summary(&self.state.config.graph.language, &w),
6707 gt::question_detail(
6708 &w,
6709 &pr.title,
6710 &pr.body,
6711 &self.state.id,
6712 rejected.as_deref(),
6713 artifact.as_deref(),
6714 g.rewrite.as_ref(),
6715 ),
6716 gt::question_choices(&w, g.rewrite.as_ref()),
6717 );
6718 q.answer_timeout = timeout;
6719 g.question = Some(q.id.clone());
6722 g.asks += 1;
6723 self.state.github_text = Some(g.clone());
6724 self.state.event(
6725 "github-text",
6726 format!("asking the owner about the withheld text ({})", q.short()),
6727 );
6728 self.state.save()?;
6729 store
6730 .put(&mut q)
6731 .context("file the posting-gate question")?;
6732 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6733 tracing::warn!(
6734 "could not notify about posting-gate question {}: {e:#}",
6735 q.short()
6736 );
6737 }
6738 self.park_on_github_text(&g)?;
6739 return Ok(None);
6740 }
6741 }
6742 let resolved = g.chosen_title.clone();
6743 let original = g.choice == Some(GithubTextChoice::Original);
6744 self.state.event(
6746 "github-text",
6747 if original {
6748 format!(
6749 "posting the original text for: {}",
6750 match (g.title, g.body) {
6751 (true, true) => "title, description",
6752 (true, false) => "title",
6753 _ => "description",
6754 }
6755 )
6756 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
6757 "posting the saved rewrite title and description".to_owned()
6758 } else if resolved.is_some() {
6759 "posting the owner's replacement title".to_owned()
6760 } else {
6761 "posting the neutral text for the withheld fields".to_owned()
6762 },
6763 );
6764 self.state.github_text = Some(g.clone());
6765 self.state.save()?;
6766 if g.choice == Some(GithubTextChoice::Rewrite)
6767 && let Some(r) = g.rewrite.as_ref()
6768 {
6769 return Ok(Some(PrMessage {
6770 title: r.title.clone(),
6771 body: r.body.clone(),
6772 }));
6773 }
6774 let w = gt::Withheld {
6775 title: g.title,
6776 body: g.body,
6777 categories: g.categories.clone(),
6778 };
6779 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
6780 if let Some(t) = resolved {
6781 title = t;
6782 }
6783 let english = gt::LanguageDecision {
6785 title_english: true,
6786 body_english: true,
6787 body_complete: true,
6788 };
6789 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6790 if original {
6791 if g.title {
6794 title = pr.title.clone();
6795 }
6796 if g.body {
6797 body = pr.body.clone();
6798 }
6799 }
6800 Ok(Some(PrMessage { title, body }))
6801 }
6802
6803 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6804 self.state.github_text = Some(g.clone());
6805 self.state.parked = true;
6806 self.state.event(
6807 "github-text",
6808 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6809 );
6810 self.state.save()
6811 }
6812
6813 async fn judge_pr_language(
6816 &mut self,
6817 cwd: &Path,
6818 pr: &PrMessage,
6819 ) -> Option<crate::github_text::LanguageDecision> {
6820 self.state.config.roles.language_judge.as_ref()?;
6821 let decision =
6822 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6823 self.state.event(
6824 "github-text",
6825 if decision.is_some() {
6826 "language decided by the language judge"
6827 } else {
6828 "language judge unavailable; using built-in heuristics"
6829 },
6830 );
6831 decision
6832 }
6833
6834 async fn merge(&mut self) -> Result<()> {
6835 if self
6850 .state
6851 .base_sync
6852 .as_ref()
6853 .is_some_and(|s| s.conflict.is_some())
6854 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6855 != Some(RunStatus::Gating)
6856 || !self.state.gate_status().ok()
6865 {
6866 return Ok(());
6867 }
6868 if self.state.merge.is_some() && !self.state.pr_publication_pending {
6877 return Ok(());
6878 }
6879 let Some(winner) = self.state.winner().cloned() else {
6880 return Ok(());
6881 };
6882 let repo = self.state.repo.clone();
6883 let base = self.state.base_branch.clone();
6884 let mode = self.state.config.merge.mode;
6885 let style = self.state.config.merge.style;
6886 let facts = if is_review_run(&self.state) {
6887 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6888 let start = review_base(
6889 &repo,
6890 &self.state.config.merge.remote,
6891 &base,
6892 &self.state.base_commit,
6893 &winner.branch,
6894 )
6895 .await;
6896 branch_facts(&repo, &start, &winner.branch).await
6897 } else {
6898 None
6899 };
6900 let leaked = if is_review_run(&self.state) {
6903 leaked_subjects(&self.state, &winner.branch).await
6904 } else {
6905 Some(Vec::new())
6906 };
6907 let Some(pr) = self
6911 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6912 .await?
6913 else {
6914 return Ok(());
6915 };
6916 let message = pr.commit_message();
6917
6918 let outcome = match mode {
6919 MergeMode::None => MergeOutcome {
6920 mode,
6921 ok: true,
6922 detail: manual_merge_command(style, &repo, &winner.branch, &message),
6923 empty: false,
6924 },
6925 MergeMode::Pr | MergeMode::Local
6926 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6927 {
6928 MergeOutcome {
6929 mode,
6930 ok: false,
6931 detail: empty_candidate_detail(&self.state, &base),
6932 empty: true,
6933 }
6934 }
6935 MergeMode::Local => {
6936 let on = git::current_branch(&repo).await?;
6937 if on.as_deref() != Some(base.as_str()) {
6938 MergeOutcome {
6939 mode,
6940 ok: false,
6941 detail: format!(
6942 "{} has {} checked out, not the base branch {base}",
6943 repo.display(),
6944 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6945 ),
6946 empty: false,
6947 }
6948 } else if !git::is_clean(&repo).await? {
6949 MergeOutcome {
6950 mode,
6951 ok: false,
6952 detail: format!("{} is dirty; refusing to merge", repo.display()),
6953 empty: false,
6954 }
6955 } else {
6956 let out = match style {
6957 MergeStyle::Merge => {
6958 git::merge_no_ff(&repo, &winner.branch, &message).await?
6959 }
6960 MergeStyle::Squash => {
6961 git::merge_squash(&repo, &winner.branch, &message).await?
6962 }
6963 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6964 };
6965 MergeOutcome {
6966 mode,
6967 ok: out.ok(),
6968 detail: if out.ok() { out.stdout } else { out.stderr },
6969 empty: false,
6970 }
6971 }
6972 }
6973 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
6974 self.state.pr_publication_pending = true;
6975 self.state.save()?;
6976 let out =
6977 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
6978 self.state.pr_publication_pending = out.is_err();
6979 MergeOutcome {
6980 mode,
6981 ok: out.is_ok(),
6982 detail: match out {
6983 Ok(url) => url,
6984 Err(e) => format!("{e:#}"),
6985 },
6986 empty: false,
6987 }
6988 }
6989 MergeMode::Pr => {
6990 let remote = self.state.config.merge.remote.clone();
6991 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6992 if !pushed.ok() {
6993 MergeOutcome {
6994 mode,
6995 ok: false,
6996 detail: pushed.stderr,
6997 empty: false,
6998 }
6999 } else {
7000 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7006 let out = match pr_merge_plan(found) {
7007 PrPlan::Create => {
7008 gh_pr_create(
7009 &winner.worktree,
7010 &base,
7011 &winner.branch,
7012 &pr.title,
7013 &pr.body,
7014 )
7015 .await
7016 }
7017 PrPlan::Adopt { url, title } => {
7018 self.state
7019 .event("merge", format!("Pr: adopted open pull request {url}"));
7020 if self.state.github_text.as_ref().is_some_and(|g| {
7021 g.resolved
7022 && g.choice == Some(GithubTextChoice::Rewrite)
7023 && g.rewrite.is_some()
7024 }) {
7025 land::set_pr_text_verbatim(
7028 &winner.worktree,
7029 &url,
7030 &pr.title,
7031 &pr.body,
7032 )
7033 .await
7034 .map(|()| url)
7035 } else {
7036 if title != pr.title
7037 && (!is_review_run(&self.state)
7038 || leaked
7039 .as_deref()
7040 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7041 && let Err(e) =
7042 if self.state.github_text.as_ref().is_some_and(|g| {
7043 g.resolved
7044 && g.title
7045 && g.choice == Some(GithubTextChoice::Original)
7046 }) {
7047 land::set_pr_title_verbatim(
7049 &winner.worktree,
7050 &url,
7051 &pr.title,
7052 )
7053 .await
7054 } else {
7055 land::set_pr_title(
7056 &mut self.state,
7057 &winner.worktree,
7058 &url,
7059 &pr.title,
7060 )
7061 .await
7062 }
7063 {
7064 tracing::warn!("could not refresh title of {url}: {e:#}");
7065 self.state
7066 .event("merge", format!("Pr: title refresh failed: {e:#}"));
7067 }
7068 Ok(url)
7069 }
7070 }
7071 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7072 };
7073 match out {
7074 Ok(url) => MergeOutcome {
7075 mode,
7076 ok: true,
7077 detail: url,
7078 empty: false,
7079 },
7080 Err(e) => MergeOutcome {
7081 mode,
7082 ok: false,
7083 detail: e.to_string(),
7084 empty: false,
7085 },
7086 }
7087 }
7088 }
7089 };
7090
7091 self.state.status = match (mode, outcome.ok) {
7092 (MergeMode::None, _) => RunStatus::Ready,
7093 (_, true) => RunStatus::Merged,
7094 (_, false) => RunStatus::Blocked,
7095 };
7096 self.state.event(
7097 "merge",
7098 format!(
7099 "{:?}: {}",
7100 mode,
7101 outcome.detail.lines().next().unwrap_or("")
7102 ),
7103 );
7104 self.state.merge = Some(outcome);
7105 self.state.save()?;
7106
7107 if self.state.config.graph.land
7113 && mode == MergeMode::Pr
7114 && self.state.status == RunStatus::Merged
7115 {
7116 self.run_land().await?;
7117 }
7118 self.settle_questions();
7123 Ok(())
7124 }
7125
7126 async fn run_land(&mut self) -> Result<()> {
7137 crate::pr_head::ensure_owner(&self.state)?;
7138 let url = self
7139 .state
7140 .merge
7141 .as_ref()
7142 .map(|m| m.detail.clone())
7143 .unwrap_or_default();
7144 let url = url.lines().next().unwrap_or("").trim().to_owned();
7145 if !url.starts_with("http") {
7146 return Ok(());
7147 }
7148 match land::land(&mut self.state, &url).await {
7151 Ok(pr) if self.state.parked => {
7152 let _ = pr;
7156 }
7157 Ok(pr) => {
7158 self.state.status = match pr.state {
7159 land::PrLifecycle::Merged => RunStatus::Merged,
7160 _ => RunStatus::Blocked,
7161 };
7162 if bump::should_release_bump(self.state.status)
7169 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7170 {
7171 self.state
7177 .event("bump", format!("release bump skipped: {e:#}"));
7178 }
7179 if self.state.status == RunStatus::Merged {
7182 crate::followup::after_merge(&mut self.state, &pr.url).await;
7183 }
7184 self.state.save()?;
7185 }
7186 Err(e) => {
7187 self.state.status = RunStatus::Blocked;
7188 self.state.event("land", format!("gave up: {e}"));
7189 self.state.save()?;
7190 }
7191 }
7192 Ok(())
7193 }
7194
7195 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7199 if let Some(existing) = self.state.seats.get(key)
7200 && existing.agent == agent
7201 {
7202 return existing.clone();
7203 }
7204 let fresh = if self.state.seats.contains_key(key) {
7207 handover_seat(key, agent, self.state.next_seat_seed())
7208 } else {
7209 SeatState::new(key, agent, self.state.seed)
7210 };
7211 self.state.seats.insert(key.to_owned(), fresh.clone());
7212 fresh
7213 }
7214
7215 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7221 match self.state.seats.get(key) {
7222 Some(s) if s.agent != spec.id => {
7223 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7224 }
7225 _ => spec,
7226 }
7227 }
7228
7229 fn view(&self, c: &Candidate) -> CandidateView {
7231 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7232 .unwrap_or_default();
7233 let (patch, _) = blind::sanitize_patch(
7234 &format!("candidate {} patch", c.label),
7235 &raw,
7236 &self.state.config.blind,
7237 );
7238 CandidateView {
7239 label: c.label,
7240 branch: c.branch.clone(),
7241 summary: c.summary.clone(),
7242 stat: c.stat.clone(),
7243 patch,
7244 }
7245 }
7246
7247 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7249 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7250 prompt::judge(
7251 "(see above)",
7252 &views,
7253 self.roles.judges.len(),
7254 base_short,
7255 "en",
7256 )
7257 }
7258
7259 fn vote_prompt_full(
7265 &self,
7266 j: usize,
7267 labels: &[char],
7268 language: &str,
7269 candidates: &[Candidate],
7270 base_short: &str,
7271 ) -> String {
7272 let mut text = format!(
7273 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7274 prompt::final_vote(labels, language),
7275 self.state.instruction,
7276 self.candidate_block(candidates, base_short)
7277 );
7278 if let Some(own) = self
7279 .state
7280 .judgements
7281 .get(j)
7282 .filter(|r| !r.ranking.is_empty())
7283 {
7284 let reasons = own
7285 .reasons
7286 .iter()
7287 .map(|(k, v)| format!("- {k}: {v}"))
7288 .collect::<Vec<_>>()
7289 .join("\n");
7290 text.push_str(&format!(
7291 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
7292 own.ranking.iter().collect::<String>(),
7293 if reasons.is_empty() {
7294 ""
7295 } else {
7296 ", because:\n"
7297 }
7298 ));
7299 }
7300 if !self.state.deliberation.is_empty() {
7301 text.push_str("\n# What was argued before this vote\n");
7302 for t in self.transcript(&[], j) {
7303 text.push_str(&format!(
7304 "\n## {}{}\n\n{}\n",
7305 t.who,
7306 if t.is_self { " (you)" } else { "" },
7307 t.body.trim()
7308 ));
7309 }
7310 }
7311 text
7312 }
7313
7314 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
7321 let mut turns = Vec::new();
7322 for j in &self.state.judgements {
7323 if j.ranking.is_empty() {
7324 continue;
7325 }
7326 let reasons = j
7327 .reasons
7328 .iter()
7329 .map(|(k, v)| format!("- {k}: {v}"))
7330 .collect::<Vec<_>>()
7331 .join("\n");
7332 turns.push(Turn {
7333 who: format!("Judge {} (opening ranking)", j.judge),
7334 is_self: j.judge == self_idx + 1,
7335 body: format!(
7336 "Ranked {}{}{reasons}",
7337 j.ranking.iter().collect::<String>(),
7338 if reasons.is_empty() {
7339 ""
7340 } else {
7341 ", because:\n"
7342 }
7343 ),
7344 });
7345 }
7346 for t in self
7347 .state
7348 .deliberation
7349 .iter()
7350 .flat_map(|r| r.turns.iter())
7351 .chain(current)
7352 {
7353 turns.push(Turn {
7354 who: format!("Judge {}", t.judge),
7355 is_self: t.judge == self_idx + 1,
7356 body: t.body.clone(),
7357 });
7358 }
7359 turns
7360 }
7361}
7362
7363fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
7365 agent::has_session(spec.kind, seat, sessions)
7366}
7367
7368fn next_untried_in_roster<'a>(
7389 roster: &'a [AgentSpec],
7390 start: usize,
7391 tried: &BTreeSet<String>,
7392) -> Option<&'a AgentSpec> {
7393 roster
7394 .get(start + 1..)?
7395 .iter()
7396 .find(|s| !tried.contains(&s.id))
7397}
7398
7399fn pick_successor<'a>(
7420 roster: &'a [AgentSpec],
7421 start: usize,
7422 tried: &BTreeSet<String>,
7423 carried: Option<&BTreeSet<String>>,
7424 others: &BTreeSet<String>,
7425) -> Option<&'a AgentSpec> {
7426 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
7427 roster
7428 .get(start + 1..)
7429 .and_then(|tail| {
7430 tail.iter()
7431 .filter(free)
7432 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
7433 })
7434 .or_else(|| {
7435 carried
7436 .is_some()
7437 .then(|| roster.iter().find(free))
7438 .flatten()
7439 })
7440 .or_else(|| match carried {
7441 Some(c) => next_for_seat(roster, start, tried, c),
7442 None => next_untried_in_roster(roster, start, tried),
7443 })
7444}
7445
7446fn next_for_seat<'a>(
7456 roster: &'a [AgentSpec],
7457 start: usize,
7458 round_tried: &BTreeSet<String>,
7459 carried_failed: &BTreeSet<String>,
7460) -> Option<&'a AgentSpec> {
7461 roster
7462 .get(start + 1..)?
7463 .iter()
7464 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
7465 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
7466}
7467
7468fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
7474 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
7475 return spec;
7476 };
7477 let ok = |id: &str| !h.failed.contains(id);
7478 if let Some(last) = h.last_ok.as_deref()
7479 && ok(last)
7480 && let Some(s) = roster.iter().find(|s| s.id == last)
7481 {
7482 return s.clone();
7483 }
7484 if ok(&spec.id) {
7485 return spec;
7486 }
7487 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
7488 roster
7489 .get(from + 1..)
7490 .into_iter()
7491 .flatten()
7492 .chain(roster.iter())
7493 .find(|s| ok(&s.id))
7494 .cloned()
7495 .unwrap_or(spec)
7496}
7497
7498pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
7502 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
7503}
7504
7505const TIMED_OUT: &str = "timed out";
7508
7509impl FailClass {
7510 fn of(out: &AgentOutcome) -> Option<Self> {
7512 match out {
7513 AgentOutcome::Ok(_) => None,
7514 AgentOutcome::Quota(_) => Some(Self::Quota),
7515 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7516 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7517 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7518 }
7519 }
7520
7521 fn stem_word(&self) -> &'static str {
7523 match self {
7524 Self::Quota => "quota",
7525 _ => "handover",
7526 }
7527 }
7528}
7529
7530fn failure_signature(msg: &str) -> String {
7534 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7535 let mut out = Vec::new();
7536 for word in line.split_whitespace() {
7537 if word.contains('/') || word.contains('\\') {
7538 out.push("<path>".to_owned());
7539 continue;
7540 }
7541 let mut w = String::new();
7542 let mut in_digits = false;
7543 for c in word.chars() {
7544 if c.is_ascii_digit() {
7545 if !in_digits {
7546 w.push('#');
7547 }
7548 in_digits = true;
7549 } else {
7550 in_digits = false;
7551 w.push(c);
7552 }
7553 }
7554 out.push(w);
7555 }
7556 out.join(" ").chars().take(120).collect()
7557}
7558
7559fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7566 match cur {
7567 FailClass::Quota | FailClass::Timeout => true,
7568 FailClass::Other(_) => prev != Some(cur),
7569 }
7570}
7571
7572fn fail_reason(out: &AgentOutcome) -> String {
7574 match out {
7575 AgentOutcome::Ok(_) => String::new(),
7576 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7577 AgentOutcome::Dropped(o) => format!(
7578 "the CLI dropped the stream ({})",
7579 o.dropped
7580 .as_ref()
7581 .map(|d| d.why.as_str())
7582 .unwrap_or("it ended without delivering its answer")
7583 ),
7584 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7585 }
7586}
7587
7588pub(crate) fn record_handover(
7591 state: &mut RunState,
7592 node: &str,
7593 seat: &str,
7594 from: &str,
7595 to: &str,
7596 class: &FailClass,
7597 reason: &str,
7598) {
7599 let message = if *class == FailClass::Quota {
7600 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7601 } else {
7602 format!("{seat}: handed over {from} -> {to} ({reason})")
7603 };
7604 state.event(node, message);
7605 state.handovers.push(Handover {
7606 at: Timestamp::now(),
7607 node: node.to_owned(),
7608 seat: seat.to_owned(),
7609 from: from.to_owned(),
7610 to: to.to_owned(),
7611 reason: reason.to_owned(),
7612 });
7613}
7614
7615fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7629 commands.iter().any(|c| c.exit_code.is_none())
7630}
7631
7632fn verified_noop_claim(
7645 usable: bool,
7646 commands: &[agent::CommandEvidence],
7647 text: &str,
7648) -> Option<String> {
7649 (usable && !has_unconfirmed_command(commands))
7650 .then(|| verdict::verified_noop(text))
7651 .flatten()
7652}
7653
7654fn short(commit: &str) -> String {
7655 commit.chars().take(7).collect()
7656}
7657
7658fn make_executable(path: &Path) -> Result<()> {
7659 #[cfg(unix)]
7660 {
7661 use std::os::unix::fs::PermissionsExt as _;
7662 let mut perms = std::fs::metadata(path)?.permissions();
7663 perms.set_mode(0o755);
7664 std::fs::set_permissions(path, perms)?;
7665 }
7666 #[cfg(not(unix))]
7667 {
7668 let _ = path;
7669 }
7670 Ok(())
7671}
7672
7673struct WaveCtx<'a> {
7680 run: &'a str,
7683 node: &'a str,
7685 prompts: &'a Prompts,
7686 cache: Option<&'a Path>,
7688 round: Option<usize>,
7691 carry_seats: bool,
7695}
7696
7697async fn run_one(
7699 job: SeatJob,
7700 sem: Arc<Semaphore>,
7701 ctx: &WaveCtx<'_>,
7702 state: &mut RunState,
7703 attempt: usize,
7704) -> (SeatState, AgentOutcome) {
7705 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7706 .await
7707 .pop()
7708 .expect("one job in, one result out");
7709 (seat, out)
7710}
7711
7712async fn wave(
7718 jobs: Vec<SeatJob>,
7719 sem: Arc<Semaphore>,
7720 ctx: &WaveCtx<'_>,
7721 state: &mut RunState,
7722 attempt: usize,
7723) -> Vec<(usize, SeatState, AgentOutcome)> {
7724 let WaveCtx {
7725 run,
7726 node,
7727 prompts,
7728 cache,
7729 round,
7730 carry_seats: _,
7731 } = *ctx;
7732 for job in &jobs {
7733 state.seat_started(node, &job.seat.key, job.timeout, attempt);
7734 }
7735 if let Err(e) = state.save() {
7736 tracing::warn!("could not persist in-progress seats: {e:#}");
7741 }
7742 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7758 let wait_started = Instant::now();
7759 let cache_guard = if let Some(cache_dir) = cache {
7760 if jobs_had_a_writer {
7761 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7762 let budget = jobs
7763 .iter()
7764 .map(|j| j.timeout)
7765 .max()
7766 .unwrap_or(Duration::from_secs(60));
7767 acquire_cache_lease(state, cache_dir, &owner, budget, node)
7768 .await
7769 .ok()
7770 } else {
7771 None
7772 }
7773 } else {
7774 None
7775 };
7776 let waited_for_lease = wait_started.elapsed();
7783 let mut set = tokio::task::JoinSet::new();
7784 let overlay = prompts.overlay(node);
7785 for (i, mut job) in jobs.into_iter().enumerate() {
7786 job.timeout = job.timeout.saturating_sub(waited_for_lease);
7787 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7788 if cache.is_some() {
7789 job.prompt.push('\n');
7790 job.prompt
7791 .push_str(&prompt::build_cache_note(node, job.allow_write));
7792 }
7793 let sem = Arc::clone(&sem);
7794 let run = run.to_owned();
7795 let node = node.to_owned();
7796 let attachments = if node == "implement" {
7799 state.attachments.clone()
7800 } else {
7801 Vec::new()
7802 };
7803 let cache = cache
7814 .filter(|_| job.allow_write && cache_guard.is_some())
7815 .map(Path::to_path_buf);
7816 set.spawn(async move {
7817 let _permit = sem.acquire().await;
7818 let mut seat = job.seat;
7819 let out = agent::invoke(
7820 &job.spec,
7821 &mut seat,
7822 &Invocation {
7823 cwd: &job.cwd,
7824 prompt: &job.prompt,
7825 timeout: job.timeout,
7826 allow_write: job.allow_write,
7827 unsandboxed: false,
7828 sessions: job.sessions,
7829 artifacts: &job.artifacts,
7830 stem: &job.stem,
7831 run: &run,
7832 node: &node,
7833 cache_dir: cache.as_deref(),
7834 attachments: &attachments,
7835 writable: &[],
7836 },
7837 )
7838 .await;
7839 let out = match out {
7840 Ok(o) if o.usable() => AgentOutcome::Ok(o),
7841 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7842 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7850 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7851 Ok(o) => AgentOutcome::Failed(format!(
7852 "exited with {:?} and no usable output",
7853 o.exit_code
7854 )),
7855 Err(e) => AgentOutcome::Failed(e.to_string()),
7856 };
7857 (i, seat, out)
7858 });
7859 }
7860 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7861 while let Some(joined) = set.join_next().await {
7862 let (i, seat, out) = match joined {
7863 Ok(v) => v,
7864 Err(e) => {
7868 tracing::error!("agent task panicked: {e}");
7869 continue;
7870 }
7871 };
7872 state.seat_finished(&seat.key);
7873 record_jobs(state, node, round, &seat.key, &out);
7874 if let Err(e) = state.save() {
7875 tracing::warn!("could not persist a seat's completion: {e:#}");
7876 }
7877 if collected.len() <= i {
7878 collected.resize_with(i + 1, || None);
7879 }
7880 collected[i] = Some((i, seat, out));
7881 }
7882 if state
7888 .active
7889 .values()
7890 .any(|a| a.node == node && a.attempt == attempt)
7891 {
7892 state
7893 .active
7894 .retain(|_, a| !(a.node == node && a.attempt == attempt));
7895 if let Err(e) = state.save() {
7896 tracing::warn!("could not persist the end of a wave: {e:#}");
7897 }
7898 }
7899 if let Some(cache_dir) = cache
7906 && jobs_had_a_writer
7907 {
7908 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7909 }
7910 if let Some(guard) = cache_guard {
7911 guard.release();
7912 }
7913 collected.into_iter().flatten().collect()
7914}
7915
7916fn record_jobs(
7927 state: &mut RunState,
7928 node: &str,
7929 round: Option<usize>,
7930 seat: &str,
7931 out: &AgentOutcome,
7932) {
7933 let commands: &[agent::CommandEvidence] = match out {
7934 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7935 AgentOutcome::Failed(_) => &[],
7936 };
7937 let checked_at = Timestamp::now();
7938 for c in commands {
7939 state.jobs.push(JobRecord {
7940 node: node.to_owned(),
7941 round,
7942 seat: seat.to_owned(),
7943 id: c.id.clone(),
7944 description: c.description.clone(),
7945 checked_at,
7946 status: match c.exit_code {
7947 Some(0) => JobStatus::Completed,
7948 Some(_) => JobStatus::Failed,
7949 None => JobStatus::Unknown,
7950 },
7951 exit_code: c.exit_code,
7952 result_summary: c.result_summary.clone(),
7953 source: c.source.clone(),
7954 });
7955 }
7956}
7957
7958fn round_is_clean(
7979 blocking: usize,
7980 e2e_ok: bool,
7981 answered: usize,
7982 expected: usize,
7983 quota_missing: usize,
7984 policy: IncompleteReviewPolicy,
7985) -> bool {
7986 if blocking != 0 || !e2e_ok {
7987 return false;
7988 }
7989 if answered == expected || policy == IncompleteReviewPolicy::Warn {
7990 return true;
7991 }
7992 answered > 0 && expected - answered <= quota_missing
7993}
7994
7995fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
8019 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
8020 return Some(RunStatus::Gating);
8021 }
8022 let last = reviews.last()?;
8023 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
8024 if reviews.len() < max_rounds && !stagnant {
8025 return None;
8026 }
8027 if last.incomplete() && last.blocking == 0 {
8028 return Some(RunStatus::Blocked);
8029 }
8030 if last.e2e_status() == E2eStatus::ResourceBlocked {
8031 return None;
8032 }
8033 Some(if last.e2e.iter().all(CommandOutcome::ok) {
8034 RunStatus::Gating
8035 } else {
8036 RunStatus::Blocked
8037 })
8038}
8039
8040pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
8055 if nudged {
8056 (full / 4).max(Duration::from_secs(120)).min(full)
8057 } else {
8058 full
8059 }
8060}
8061
8062#[allow(clippy::too_many_arguments)]
8091async fn ask_json_wave<T>(
8092 jobs: Vec<SeatJob>,
8093 sem: Arc<Semaphore>,
8094 retries: usize,
8095 roster: &[AgentSpec],
8096 ctx: &WaveCtx<'_>,
8097 losses: &mut Vec<QuotaLoss>,
8098 state: &mut RunState,
8099 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8100) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8101where
8102 T: serde::de::DeserializeOwned + Send + 'static,
8103{
8104 ask_wave_with(
8105 jobs,
8106 sem,
8107 retries,
8108 roster,
8109 ctx,
8110 losses,
8111 state,
8112 &|text: &str| {
8113 let v = verdict::extract_json::<T>(text)?;
8114 validate(&v)?;
8115 Ok(v)
8116 },
8117 )
8118 .await
8119}
8120
8121#[allow(clippy::too_many_arguments)]
8130async fn ask_wave_with<T>(
8131 jobs: Vec<SeatJob>,
8132 sem: Arc<Semaphore>,
8133 retries: usize,
8134 roster: &[AgentSpec],
8135 ctx: &WaveCtx<'_>,
8136 losses: &mut Vec<QuotaLoss>,
8137 state: &mut RunState,
8138 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8139) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8140where
8141 T: Send + 'static,
8142{
8143 let n = jobs.len();
8144 let originals: Vec<SeatJob> = jobs;
8145 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8146 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8147 let mut nudges: Vec<usize> = vec![0; n];
8154 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8158 let mut tried: Vec<BTreeSet<String>> = specs
8159 .iter()
8160 .map(|s| BTreeSet::from([s.id.clone()]))
8161 .collect();
8162 let starts: Vec<usize> = specs
8163 .iter()
8164 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8165 .collect();
8166 let carry = ctx.carry_seats && !roster.is_empty();
8167 let carried: Vec<BTreeSet<String>> = originals
8170 .iter()
8171 .map(|j| {
8172 state
8173 .seat_history
8174 .get(&j.seat.key)
8175 .filter(|_| carry)
8176 .map(|h| h.failed.clone())
8177 .unwrap_or_default()
8178 })
8179 .collect();
8180 let mut prev: Vec<Option<FailClass>> = originals
8181 .iter()
8182 .map(|j| {
8183 state
8184 .seat_history
8185 .get(&j.seat.key)
8186 .filter(|_| carry)
8187 .and_then(|h| h.last_fail.clone())
8188 })
8189 .collect();
8190 let next_agent =
8191 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
8192 let others: BTreeSet<String> = specs
8193 .iter()
8194 .enumerate()
8195 .filter(|(j, _)| *j != i)
8196 .map(|(_, s)| s.id.clone())
8197 .collect();
8198 pick_successor(
8199 roster,
8200 starts[i],
8201 tried,
8202 carry.then(|| &carried[i]),
8203 &others,
8204 )
8205 .cloned()
8206 };
8207 let mut fresh: Vec<Option<String>> = vec![None; n];
8208 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
8209 let mut pending: Vec<usize> = (0..n).collect();
8210
8211 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
8216 for round in 0..max_rounds {
8217 if pending.is_empty() {
8218 break;
8219 }
8220 let mut batch = Vec::with_capacity(pending.len());
8221 let mut renudged: Vec<&str> = Vec::new();
8222 for &i in &pending {
8223 let src = &originals[i];
8224 let full: &str = match &src.handover {
8229 Some(h) if specs[i].id != src.spec.id => h,
8230 _ => &src.prompt,
8231 };
8232 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
8235 (
8236 full.to_owned(),
8237 src.timeout,
8238 format!("{}-{word}-{}", src.stem, specs[i].id),
8239 )
8240 } else if nudges[i] == 0 {
8241 (full.to_owned(), src.timeout, src.stem.clone())
8242 } else {
8243 renudged.push(src.seat.key.as_str());
8244 let why = done[i]
8245 .as_ref()
8246 .and_then(|r| r.as_ref().err().map(ToString::to_string))
8247 .unwrap_or_else(|| "no parsable answer".to_owned());
8248 let nudge = prompt::nudge(&why);
8249 let nudged = has_context(&specs[i], &seats[i], src.sessions);
8250 let prompt = if nudged {
8251 nudge
8252 } else {
8253 format!("{full}\n\n---\n\n{nudge}")
8254 };
8255 (
8256 prompt,
8257 retry_budget(src.timeout, nudged),
8258 format!("{}-retry{}", src.stem, nudges[i]),
8259 )
8260 };
8261 batch.push(SeatJob {
8262 spec: specs[i].clone(),
8263 seat: seats[i].clone(),
8264 cwd: src.cwd.clone(),
8265 prompt,
8266 timeout,
8267 allow_write: src.allow_write,
8268 sessions: src.sessions,
8269 artifacts: src.artifacts.clone(),
8270 stem,
8271 handover: None,
8272 });
8273 }
8274
8275 if !renudged.is_empty() {
8276 state.event(
8277 ctx.node,
8278 format!("retry {round}: re-asking {}", renudged.join(", ")),
8279 );
8280 }
8281 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
8282 let mut still = Vec::new();
8283 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
8284 seats[i] = seat;
8285 let class = FailClass::of(&out);
8286 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
8289 && nudges[i] < retries
8290 && !roster.is_empty();
8291 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
8292 let next = should_hand_over(prev[i].as_ref(), &cur)
8293 .then(|| next_agent(i, &tried[i], &specs))
8294 .flatten();
8295 if carry {
8296 let h = state
8297 .seat_history
8298 .entry(originals[i].seat.key.clone())
8299 .or_default();
8300 h.failed.insert(specs[i].id.clone());
8301 h.last_fail = Some(cur.clone());
8302 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
8303 h.last_ok = None;
8304 }
8305 if let Err(e) = state.save() {
8308 tracing::warn!("could not persist a seat's failure history: {e:#}");
8309 }
8310 }
8311 if let Some(next) = next {
8312 record_handover(
8313 state,
8314 ctx.node,
8315 &originals[i].seat.key,
8316 &specs[i].id,
8317 &next.id,
8318 &cur,
8319 &fail_reason(&out),
8320 );
8321 tried[i].insert(next.id.clone());
8322 prev[i] = Some(cur.clone());
8323 seats[i] =
8324 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
8325 specs[i] = next;
8326 fresh[i] = Some(cur.stem_word().to_owned());
8327 nudges[i] = 0;
8328 done[i] = Some(Err(anyhow::anyhow!(
8329 "handed over after: {}",
8330 fail_reason(&out)
8331 )));
8332 still.push(i);
8333 continue;
8334 }
8335 }
8336 if carry
8337 && !nudge_first
8338 && let Some(cur) = class.clone()
8339 {
8340 let h = state
8342 .seat_history
8343 .entry(originals[i].seat.key.clone())
8344 .or_default();
8345 h.failed.insert(specs[i].id.clone());
8346 h.last_fail = Some(cur);
8347 }
8348 let parsed = match out {
8349 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
8350 AgentOutcome::Quota(o) => {
8351 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
8352 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
8353 }
8354 AgentOutcome::Dropped(o) => {
8358 let why = o
8359 .dropped
8360 .as_ref()
8361 .map(|d| d.why.as_str())
8362 .unwrap_or("the CLI ended the stream without delivering its answer");
8363 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
8364 }
8365 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
8366 };
8367 let quota = class == Some(FailClass::Quota);
8368 let failed = parsed.is_err();
8369 done[i] = Some(parsed);
8370 if carry && !failed {
8371 let h = state
8372 .seat_history
8373 .entry(originals[i].seat.key.clone())
8374 .or_default();
8375 h.failed.remove(&specs[i].id);
8376 h.last_ok = Some(specs[i].id.clone());
8377 h.last_fail = None;
8378 }
8379 let agent_failure = class.is_some()
8387 && !nudge_first
8388 && !roster.is_empty()
8389 && next_agent(i, &tried[i], &specs).is_some();
8390 if failed && !quota && !agent_failure && nudges[i] < retries {
8391 nudges[i] += 1;
8392 still.push(i);
8393 }
8394 }
8395 pending = still;
8396 }
8397
8398 for (i, q) in last_quota.into_iter().enumerate() {
8401 if let Some(reset) = q {
8402 losses.push(QuotaLoss {
8403 seat: originals[i].seat.key.clone(),
8404 node: ctx.node.to_owned(),
8405 at: Timestamp::now(),
8406 reset,
8407 });
8408 }
8409 }
8410
8411 seats
8412 .into_iter()
8413 .zip(done)
8414 .zip(nudges)
8415 .map(|((seat, res), attempts)| {
8416 (
8417 seat,
8418 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
8419 attempts,
8420 )
8421 })
8422 .collect()
8423}
8424
8425async fn acquire_cache_lease(
8438 state: &mut RunState,
8439 cache_dir: &Path,
8440 owner: &crate::cache::Owner,
8441 budget: Duration,
8442 context: &str,
8443) -> Result<crate::cache::Guard> {
8444 let home = crate::run::home();
8445 let started = Instant::now();
8446 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
8447 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
8448 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
8449 Err(e) => {
8450 state.event(
8451 "verify",
8452 format!("{context}: could not check the shared build cache: {e:#}"),
8453 );
8454 if let Err(e2) = state.save() {
8455 tracing::warn!("could not persist a cache-check failure: {e2:#}");
8456 }
8457 return Err(e);
8458 }
8459 };
8460 state.event(
8461 "verify",
8462 format!(
8463 "{context}: waiting for the shared build cache at {} ({})",
8464 cache_dir.display(),
8465 busy.describe()
8466 ),
8467 );
8468 if let Err(e) = state.save() {
8469 tracing::warn!("could not persist a cache wait: {e:#}");
8470 }
8471 let remaining = budget.saturating_sub(started.elapsed());
8472 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
8473 Ok(g) => Ok(g),
8474 Err(e) => {
8475 state.event("verify", format!("{context}: {e:#}"));
8476 if let Err(e2) = state.save() {
8477 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
8478 }
8479 Err(e)
8480 }
8481 }
8482}
8483
8484#[allow(clippy::too_many_arguments)]
8505async fn with_cache_lease<'s, F, Fut>(
8506 state: &'s mut RunState,
8507 cache_dir: Option<&Path>,
8508 node: &str,
8509 seat: &str,
8510 worktree: &Path,
8511 head: &str,
8512 budget: Duration,
8513 context: &str,
8514 body: F,
8515) -> (Vec<CommandOutcome>, bool)
8516where
8517 F: FnOnce(&'s mut RunState, Duration) -> Fut,
8518 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8519{
8520 let Some(cache_dir) = cache_dir else {
8521 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8522 return (outcomes, retried);
8523 };
8524 let home = crate::run::home();
8525 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8526 let started = Instant::now();
8527 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8528 Ok(g) => g,
8529 Err(e) => {
8530 return (
8531 vec![CommandOutcome {
8532 command: "(waiting for the shared build cache)".to_owned(),
8533 code: None,
8534 output_tail: e.to_string(),
8535 duration_ms: started.elapsed().as_millis() as u64,
8536 resource_blocked: true,
8537 }],
8538 false,
8539 );
8540 }
8541 };
8542 let identity = crate::cache::Identity::new(worktree, head);
8543 if let Err(e) = crate::cache::ensure_fresh(
8544 &home,
8545 cache_dir,
8546 &identity,
8547 &state.config.verify.cargo_manifests(),
8548 ) {
8549 state.event(
8557 "verify",
8558 format!(
8559 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8560 worktree.display(),
8561 short(head)
8562 ),
8563 );
8564 guard.release();
8565 return (
8566 vec![CommandOutcome {
8567 command: "(confirming the shared build cache is fresh)".to_owned(),
8568 code: None,
8569 output_tail: e.to_string(),
8570 duration_ms: started.elapsed().as_millis() as u64,
8571 resource_blocked: true,
8572 }],
8573 false,
8574 );
8575 }
8576 let remaining = budget.saturating_sub(started.elapsed());
8577 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8578 if !timed_out_pids.is_empty() {
8583 wait_for_timed_out_children_to_die(&timed_out_pids).await;
8584 }
8585 guard.release();
8586 (outcomes, retried)
8587}
8588
8589async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8601 wait_for_pids_with(
8602 pids,
8603 crate::proc::pid_alive,
8604 LEASE_RELEASE_POLL,
8605 LEASE_RELEASE_MAX_WAIT,
8606 )
8607 .await;
8608}
8609
8610async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8616 pids: &[u32],
8617 alive: F,
8618 poll: Duration,
8619 max_wait: Duration,
8620) {
8621 let deadline = Instant::now() + max_wait;
8622 loop {
8623 if pids.iter().all(|&pid| !alive(pid)) {
8624 return;
8625 }
8626 if Instant::now() >= deadline {
8627 return;
8628 }
8629 tokio::time::sleep(poll).await;
8630 }
8631}
8632
8633fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8640 outcomes.iter().any(|o| o.resource_blocked)
8641}
8642
8643enum GateFix {
8645 Retry,
8647 Stop,
8650 Defer,
8653}
8654
8655fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8663 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8664 red.peek().is_some()
8665 && red.all(|o| {
8666 !o.resource_blocked
8667 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8668 && !o.output_tail.trim().is_empty()
8669 })
8670}
8671
8672fn e2e_outcome_label(o: &CommandOutcome) -> String {
8676 if o.ok() {
8677 return "pass".to_owned();
8678 }
8679 let reason = if o.build_failed() {
8680 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8681 } else {
8682 format!("FAIL ({:?})", o.code)
8683 };
8684 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8685}
8686
8687async fn run_e2e_with_retry(
8695 state: &mut RunState,
8696 shell: &[String],
8697 commands: &[String],
8698 worktree: &Path,
8699 timeout: Duration,
8700 context: &str,
8701) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8702 let (mut e2e, mut timed_out_pids) = run_commands(
8703 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8704 )
8705 .await;
8706 for o in &e2e {
8707 state.event(
8708 "verify",
8709 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8710 );
8711 }
8712 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8716 if verify_retried {
8717 state.event(
8718 "verify",
8719 format!(
8720 "{context}: verify could not build/link, not a test result — retrying once \
8721 before concluding"
8722 ),
8723 );
8724 let retried = run_commands(
8725 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8726 )
8727 .await;
8728 e2e = retried.0;
8729 timed_out_pids.extend(retried.1);
8732 for o in &e2e {
8733 state.event(
8734 "verify",
8735 format!(
8736 "{context}: retry `{}` -> {}",
8737 o.command,
8738 e2e_outcome_label(o)
8739 ),
8740 );
8741 }
8742 }
8743 (e2e, verify_retried, timed_out_pids)
8744}
8745
8746#[allow(clippy::too_many_arguments)]
8762async fn run_commands(
8763 state: &mut RunState,
8764 node: &str,
8765 task: &str,
8766 attempt: usize,
8767 shell: &[String],
8768 commands: &[String],
8769 cwd: &Path,
8770 timeout: Duration,
8771) -> (Vec<CommandOutcome>, Vec<u32>) {
8772 if commands.is_empty() {
8773 return (Vec::new(), Vec::new());
8778 }
8779 let mut out = Vec::new();
8780 let mut timed_out_pids = Vec::new();
8781 let total = commands.len();
8782 for (idx, command) in commands.iter().enumerate() {
8783 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8784 if let Err(e) = state.save() {
8785 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8786 }
8787 let started = Instant::now();
8788 let mut cmd = tokio::process::Command::new(&shell[0]);
8789 cmd.quiet();
8790 cmd.args(&shell[1..])
8791 .arg(command)
8792 .current_dir(cwd)
8793 .stdin(std::process::Stdio::null())
8794 .stdout(std::process::Stdio::piped())
8795 .stderr(std::process::Stdio::piped())
8796 .kill_on_drop(true);
8797 let spawned = cmd.spawn();
8798 let (code, body) = match spawned {
8799 Ok(child) => {
8800 let pid = child.id();
8805 match tokio::time::timeout(timeout, child.wait_with_output()).await {
8806 Ok(Ok(o)) => {
8807 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8808 body.push_str(&String::from_utf8_lossy(&o.stderr));
8809 (o.status.code(), body)
8810 }
8811 Ok(Err(e)) => (None, format!("failed to run: {e}")),
8812 Err(_) => {
8813 if let Some(pid) = pid {
8814 timed_out_pids.push(pid);
8815 }
8816 (None, format!("timed out after {}s", timeout.as_secs()))
8817 }
8818 }
8819 }
8820 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8821 };
8822 out.push(CommandOutcome {
8823 command: command.clone(),
8824 code,
8825 output_tail: tail(&body, OUTPUT_TAIL),
8826 duration_ms: started.elapsed().as_millis() as u64,
8827 resource_blocked: false,
8828 });
8829 }
8830 state.task_finished(task);
8831 if let Err(e) = state.save() {
8832 tracing::warn!("could not persist the end of {task}: {e:#}");
8833 }
8834 (out, timed_out_pids)
8835}
8836
8837fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8849 let repo = repo.display();
8850 match style {
8851 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8852 MergeStyle::Squash => {
8853 let subject = message
8856 .lines()
8857 .next()
8858 .unwrap_or(branch)
8859 .replace(['\\', '"', '$', '`'], "");
8860 format!(
8861 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8862 )
8863 }
8864 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8865 }
8866}
8867
8868const PR_TITLE_MAX: usize = 240;
8879
8880fn english_title(text: &str, max: usize) -> Option<String> {
8891 let line = queue::first_line(text)?;
8892 let chars: Vec<char> = line.chars().collect();
8893 let (kept, mark) = if chars.len() <= max {
8894 (line.to_owned(), "")
8895 } else if let Some(end) = sentence_end(&chars, max) {
8896 (chars[..end].iter().collect::<String>(), "")
8897 } else {
8898 let room = max.saturating_sub(3);
8899 let cut = if chars[room].is_whitespace() {
8902 room
8903 } else {
8904 chars[..room]
8905 .iter()
8906 .rposition(|c| c.is_whitespace())
8907 .unwrap_or(room)
8908 };
8909 let head: String = chars[..cut].iter().collect();
8910 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8911 (head.to_owned(), "...")
8912 };
8913 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8914 return None;
8915 }
8916 Some(format!("{kept}{mark}"))
8917}
8918
8919fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8923 const MIN: usize = 20;
8924 for i in MIN..max.min(chars.len()) {
8925 if !matches!(chars[i], '.' | '!' | '?') {
8926 continue;
8927 }
8928 let Some(&next) = chars.get(i + 1) else {
8929 continue;
8930 };
8931 if !next.is_whitespace() {
8932 continue;
8933 }
8934 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8935 if after.is_some_and(|c| c.is_ascii_lowercase()) {
8936 continue;
8937 }
8938 let word: String = chars[..i]
8939 .iter()
8940 .rev()
8941 .take_while(|c| !c.is_whitespace())
8942 .collect::<Vec<_>>()
8943 .into_iter()
8944 .rev()
8945 .collect();
8946 let word = word.to_ascii_lowercase();
8947 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8948 continue;
8949 }
8950 let end = chars[..i]
8951 .iter()
8952 .rposition(|c| !c.is_whitespace())
8953 .map_or(i, |p| p + 1);
8954 return Some(end);
8955 }
8956 None
8957}
8958
8959struct PrMessage {
8964 title: String,
8965 body: String,
8966}
8967
8968impl PrMessage {
8969 fn commit_message(&self) -> String {
8973 format!("{}\n\n{}", self.title, self.body)
8974 }
8975}
8976
8977fn title_marker(line: &str) -> Option<&str> {
8979 let line = line.trim();
8980 let head = line.get(..6)?;
8981 head.eq_ignore_ascii_case("title:")
8982 .then(|| line[6..].trim())
8983}
8984
8985fn summary_title(summary: &str) -> Option<String> {
8990 let first = summary.lines().find(|l| !l.trim().is_empty())?;
8991 let raw = title_marker(first)?;
8992 if raw.is_empty() {
8993 return None;
8994 }
8995 let title = queue::title_from(raw, PR_TITLE_MAX);
8996 let lower = title.to_ascii_lowercase();
8997 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8998 return None;
8999 }
9000 Some(title)
9001}
9002
9003const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
9005
9006const EXISTING_BRANCH: &str = "(existing branch)";
9008
9009fn is_review_run(state: &RunState) -> bool {
9013 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
9014}
9015
9016fn review_title(state: &RunState) -> Option<String> {
9021 english_subject(state.reviewed_commits.as_ref()?.first()?)
9022}
9023
9024fn english_subject(raw: &str) -> Option<String> {
9027 let raw = raw.trim();
9028 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
9029 return None;
9030 }
9031 let title = queue::title_from(raw, PR_TITLE_MAX);
9032 let lower = title.to_ascii_lowercase();
9033 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9034 return None;
9035 }
9036 Some(title)
9037}
9038
9039#[derive(Debug, Clone, PartialEq, Eq)]
9042struct BranchFacts {
9043 commits: Vec<(String, String)>,
9045 stat: String,
9047}
9048
9049const STAT_FILE_LINES: usize = 25;
9051const COMMITS_MAX_CHARS: usize = 20_000;
9053
9054async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
9057 let commits = git::commit_log(repo, base, branch).await.ok()?;
9058 if commits.is_empty() {
9059 return None;
9060 }
9061 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
9062 let lines: Vec<&str> = stat.lines().collect();
9063 let stat = if lines.len() > STAT_FILE_LINES + 1 {
9064 let omitted = lines.len() - 1 - STAT_FILE_LINES;
9065 let more = format!(" ... {omitted} more file(s)");
9066 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
9067 kept.push(&more);
9068 kept.push(lines[lines.len() - 1]);
9069 kept.join("\n")
9070 } else {
9071 lines.join("\n")
9072 };
9073 Some(BranchFacts { commits, stat })
9074}
9075
9076fn markdown_safe(text: &str) -> String {
9079 text.replace("</details>", "</details>")
9080 .replace("\x60\x60\x60", "~~~")
9081}
9082
9083fn neutral_title(state: &RunState, winner: char) -> String {
9084 format!(
9085 "chore: land candidate {} of run {}",
9086 winner.to_ascii_uppercase(),
9087 state.id
9088 )
9089}
9090
9091pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
9097 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
9098 ""
9099 } else {
9100 pr_title
9101 }
9102}
9103
9104pub fn landing_subject_source(state: &RunState) -> String {
9108 if is_review_run(state) {
9109 let winner = state.candidates.first().map_or('A', |c| c.label);
9110 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
9111 }
9112 state.instruction.clone()
9113}
9114
9115fn summary_without_title(summary: &str) -> String {
9118 let mut lines = summary.trim().lines().peekable();
9119 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
9120 lines.next();
9121 }
9122 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
9123}
9124
9125#[cfg(test)]
9139fn pr_message(state: &RunState, winner: char) -> PrMessage {
9140 pr_message_with(state, winner, None)
9141}
9142
9143#[cfg(test)]
9146fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9147 let raw = pr_message_raw(state, winner, facts);
9148 let id = crate::scrub::Identity::current();
9149 PrMessage {
9150 title: crate::scrub::scrub(&raw.title, &id),
9151 body: crate::scrub::scrub(&raw.body, &id),
9152 }
9153}
9154
9155fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9156 let summary = state
9157 .candidates
9158 .iter()
9159 .find(|c| c.label == winner)
9160 .map(|c| c.summary.as_str())
9161 .unwrap_or_default();
9162 let review = is_review_run(state);
9165 let title = if review {
9166 facts
9167 .and_then(|f| english_subject(&f.commits.first()?.0))
9168 .or_else(|| review_title(state))
9169 .or_else(|| {
9170 state
9171 .candidates
9172 .iter()
9173 .find(|c| c.label == winner)
9174 .filter(|c| !c.branch.starts_with("magi/"))
9175 .and_then(|c| english_subject(&c.branch))
9176 })
9177 .unwrap_or_else(|| neutral_title(state, winner))
9178 } else {
9179 summary_title(summary).unwrap_or_else(|| {
9180 english_title(&state.instruction, PR_TITLE_MAX)
9181 .unwrap_or_else(|| neutral_title(state, winner))
9182 })
9183 };
9184
9185 let mut body = String::new();
9186 let what = summary_without_title(summary);
9187 if !what.is_empty() {
9188 body.push_str("## Summary\n\n");
9189 body.push_str(&what);
9190 body.push_str("\n\n");
9191 }
9192
9193 let notes: Vec<(usize, &str)> = state
9196 .reviews
9197 .iter()
9198 .filter_map(|r| {
9199 let n = r.fix.as_ref()?.notes.trim();
9200 (!n.is_empty()).then_some((r.round, n))
9201 })
9202 .collect();
9203 if !notes.is_empty() {
9204 body.push_str("## Review fixes\n\n");
9205 if let [(_, only)] = notes.as_slice() {
9206 body.push_str(only);
9207 body.push_str("\n\n");
9208 } else {
9209 for (round, n) in ¬es {
9210 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
9211 }
9212 }
9213 }
9214 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
9215
9216 let open = state.open_findings();
9217 if !open.is_empty() {
9218 body.push_str("## Open review findings\n\n");
9219 for f in &open {
9220 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
9221 }
9222 body.push('\n');
9223 }
9224
9225 if let Some(fix) = fix
9226 && !fix.rejected.is_empty()
9227 {
9228 body.push_str("## Declined by the fixer\n\n");
9229 for r in &fix.rejected {
9230 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
9231 }
9232 body.push('\n');
9233 }
9234
9235 if review {
9236 body.push_str("## Commits under review\n\n");
9238 if let Some(facts) = facts {
9239 let mut left = COMMITS_MAX_CHARS;
9240 for (i, (subject, text)) in facts.commits.iter().enumerate() {
9241 let mut entry = format!("- {}\n", markdown_safe(subject));
9242 for l in markdown_safe(text).lines() {
9243 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
9244 }
9245 if left == 0 {
9246 body.push_str(&format!(
9247 "- ... {} more commit(s)\n",
9248 facts.commits.len() - i
9249 ));
9250 break;
9251 }
9252 if entry.len() > left {
9253 let mut end = left;
9256 while !entry.is_char_boundary(end) {
9257 end -= 1;
9258 }
9259 entry.truncate(end);
9260 entry.push_str("\n ... (truncated)\n");
9261 left = 0;
9262 } else {
9263 left -= entry.len();
9264 }
9265 body.push_str(&entry);
9266 }
9267 if !facts.stat.trim().is_empty() {
9268 body.push_str(&format!(
9269 "\n## Diff stat\n\n```\n{}\n```\n",
9270 markdown_safe(facts.stat.trim())
9271 ));
9272 }
9273 } else {
9274 match &state.reviewed_commits {
9275 Some(subjects) => {
9276 for s in subjects {
9277 body.push_str(&format!("- {}\n", s.trim()));
9278 }
9279 }
9280 None => {
9281 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
9284 body.push_str(rest.trim());
9285 body.push('\n');
9286 }
9287 }
9288 }
9289 } else {
9290 let task = state.instruction.trim();
9291 let task = if task.is_empty() {
9292 "(empty task)"
9293 } else {
9294 task
9295 };
9296 body.push_str(&format!(
9297 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
9298 task.replace("</details>", "</details>")
9299 ));
9300 }
9301
9302 body.push_str(&format!(
9303 "\n---\nmagi:run/{} magi:candidate-{}\n",
9304 state.id,
9305 winner.to_ascii_lowercase()
9306 ));
9307
9308 PrMessage { title, body }
9309}
9310
9311fn seeded_instruction(state: &RunState) -> String {
9315 match refs::describe(&state.seeds) {
9316 Some(facts) => format!(
9317 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
9318 Candidates start from the unmerged branch named above, when there \
9319 is one, and carry any unmerged commit named by sha as a \
9320 cherry-pick. Check that this is what the task meant before \
9321 building on it.",
9322 state.instruction
9323 ),
9324 None => state.instruction.clone(),
9325 }
9326}
9327
9328async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
9332 let base = &state.base_branch;
9333 let mut against = base.clone();
9334 if mode == MergeMode::Pr {
9335 let remote = &state.config.merge.remote;
9339 let tracking = format!("{remote}/{base}");
9340 let fetched = git::fetch(repo, remote, base).await;
9341 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
9342 against = tracking;
9343 } else {
9344 return false;
9345 }
9346 }
9347 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
9348}
9349
9350fn empty_candidate_detail(state: &RunState, base: &str) -> String {
9353 let mut detail = format!(
9354 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
9355 nothing to open a pull request for"
9356 );
9357 match refs::describe(&state.seeds) {
9358 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
9359 None => detail.push_str(
9360 "\nThe task names no existing branch or commit; if it means to land work \
9361 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
9362 ),
9363 }
9364 detail
9365}
9366
9367#[derive(Debug, PartialEq, Eq)]
9370enum PrPlan {
9371 Create,
9372 Adopt { url: String, title: String },
9373 Stop(String),
9374}
9375
9376fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
9379 match found {
9380 Ok(land::OpenPr::None) => PrPlan::Create,
9381 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
9382 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
9383 "several open pull requests exist for this branch, not picking one: {}",
9384 urls.join(" ")
9385 )),
9386 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
9387 }
9388}
9389
9390pub(crate) async fn gh_pr_create(
9392 cwd: &Path,
9393 base: &str,
9394 head: &str,
9395 title: &str,
9396 body: &str,
9397) -> Result<String> {
9398 let out = tokio::process::Command::new("gh")
9399 .args([
9400 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
9401 ])
9402 .current_dir(cwd)
9403 .quiet()
9404 .stdin(std::process::Stdio::null())
9405 .output()
9406 .await
9407 .context("spawn gh")?;
9408 if out.status.success() {
9409 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
9410 } else {
9411 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
9412 }
9413}
9414
9415pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
9424 let repo = state.repo.clone();
9425 let root = state.worktree_root();
9426 let winner = state.tally.as_ref().map(|t| t.winner);
9427 let mut removed = Vec::new();
9428
9429 for i in 0..state.candidates.len() {
9430 let c = state.candidates[i].clone();
9431 let is_winner = Some(c.label) == winner;
9432 if is_winner && !drop_winner {
9433 continue;
9434 }
9435 if c.worktree.exists() {
9436 git::worktree_remove(&repo, &c.worktree).await.ok();
9437 removed.push(c.worktree.to_string_lossy().into_owned());
9438 }
9439 let handed_over = state.released_branches.contains(&c.branch);
9442 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
9443 git::branch_delete(&repo, &c.branch).await.ok();
9444 removed.push(c.branch.clone());
9445 }
9446 state.candidates[i].folded = true;
9447 }
9448
9449 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
9450 let path = name.path();
9451 let keep = !drop_winner
9452 && winner.is_some_and(|w| {
9453 path.file_name()
9454 .is_some_and(|n| n == format!("cand-{w}").as_str())
9455 });
9456 if keep {
9457 continue;
9458 }
9459 git::worktree_remove(&repo, &path).await.ok();
9460 removed.push(path.to_string_lossy().into_owned());
9461 }
9462
9463 remove_if_empty(&root);
9472
9473 if state.enabled_worktree_config && drop_winner {
9474 git::release_worktree_config(&repo).await.ok();
9478 state.enabled_worktree_config = false;
9479 }
9480 state.save_under(home)?;
9481 Ok(removed)
9482}
9483
9484fn remove_if_empty(dir: &Path) {
9495 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
9496 std::fs::remove_dir(dir).ok();
9497 }
9498}
9499
9500pub fn worst_open(state: &RunState) -> Option<Severity> {
9502 state
9503 .reviews
9504 .last()?
9505 .reviews
9506 .iter()
9507 .flat_map(|r| r.findings.iter())
9508 .map(|f| f.severity)
9509 .max()
9510}
9511
9512async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9516 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9517 git::acquire_worktree_config(repo).await?;
9518 state.enabled_worktree_config = true;
9519 }
9520 Ok(())
9521}
9522
9523#[cfg(test)]
9524mod tests {
9525 #[test]
9526 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9527 let leaked = vec!["chore(deps): update a crate".to_owned()];
9528 let own = "fix(daemon): apply a chosen action";
9529 assert!(should_retitle("", own, &leaked));
9530 assert!(should_retitle(
9531 &format!("{REVIEW_PROMPT_OPENING} `x`"),
9532 own,
9533 &leaked
9534 ));
9535 assert!(should_retitle(
9536 "chore: land candidate A of run 1",
9537 own,
9538 &leaked
9539 ));
9540 assert!(should_retitle(
9541 "magi: candidate A (uncommitted work)",
9542 own,
9543 &leaked
9544 ));
9545 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9546 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9547 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9548 }
9549
9550 #[test]
9551 fn pr_merge_plan_creates_adopts_or_stops() {
9552 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9553 assert_eq!(
9554 pr_merge_plan(Ok(land::OpenPr::One {
9555 url: "u".into(),
9556 title: "t".into()
9557 })),
9558 PrPlan::Adopt {
9559 url: "u".into(),
9560 title: "t".into()
9561 }
9562 );
9563 let PrPlan::Stop(many) =
9564 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9565 else {
9566 panic!("many must stop");
9567 };
9568 assert!(many.contains('a') && many.contains('b'));
9569 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9570 panic!("a failed lookup must stop");
9571 };
9572 assert!(err.contains("bad token"));
9573 }
9574
9575 use super::*;
9576 use crate::run::GateStatus;
9577 use std::collections::BTreeMap;
9578 use std::time::Duration;
9579
9580 fn conductor() -> AgentSpec {
9581 AgentSpec {
9582 id: "conductor".to_owned(),
9583 kind: crate::config::AgentKind::Command,
9584 model: None,
9585 command: vec!["true".to_owned()],
9586 extra_args: Vec::new(),
9587 env: BTreeMap::new(),
9588 prompt_delivery: None,
9589 }
9590 }
9591
9592 fn spec(id: &str) -> AgentSpec {
9593 AgentSpec {
9594 id: id.to_owned(),
9595 kind: crate::config::AgentKind::Command,
9596 model: None,
9597 command: vec!["true".to_owned()],
9598 extra_args: Vec::new(),
9599 env: BTreeMap::new(),
9600 prompt_delivery: None,
9601 }
9602 }
9603
9604 fn ids(xs: &[&str]) -> BTreeSet<String> {
9605 xs.iter().map(|s| (*s).to_owned()).collect()
9606 }
9607
9608 #[test]
9609 fn pick_successor_skips_an_agent_another_seat_holds() {
9610 let roster = [spec("a"), spec("b"), spec("c")];
9612 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9613 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9614 }
9615
9616 #[test]
9617 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9618 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9620 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9621 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9622 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9623 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9624 }
9625
9626 #[test]
9627 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9628 let roster = [spec("a"), spec("b")];
9629 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9630 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9631 let carried = ids(&["b"]);
9632 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9633 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9634 }
9635
9636 #[test]
9637 fn pick_successor_returns_none_without_any_untried_successor() {
9638 let roster = [spec("a"), spec("b")];
9639 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9640 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9641 let carried = ids(&["a"]);
9642 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9643 }
9644
9645 #[test]
9646 fn pick_successor_rescue_avoids_another_seats_occupant() {
9647 let roster = [spec("a"), spec("b"), spec("c")];
9649 let carried = ids(&["b", "c"]);
9650 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9651 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9652 }
9653
9654 #[test]
9655 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9656 let roster = [spec("a"), spec("b"), spec("c")];
9657 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9658 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9659 }
9660
9661 #[test]
9662 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9663 let roster = [spec("a"), spec("b"), spec("c")];
9664 let failed = ids(&["a", "b", "c"]);
9665 let mut tried = ids(&["b"]);
9667 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9668 assert_eq!(first.id, "a");
9669 tried.insert(first.id.clone());
9670 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9671 assert_eq!(second.id, "c");
9672 tried.insert(second.id.clone());
9673 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9674 }
9675
9676 #[test]
9677 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9678 let roster = [spec("a"), spec("b")];
9679 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9680 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9681 }
9682
9683 #[test]
9684 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9685 let roster = [spec("a"), spec("b"), spec("c")];
9686 let h = SeatHistory {
9687 failed: ids(&["a"]),
9688 last_ok: Some("b".to_owned()),
9689 last_fail: None,
9690 };
9691 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9692 let gone = SeatHistory {
9694 last_ok: Some("zzz".to_owned()),
9695 ..h.clone()
9696 };
9697 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9698 let failed = SeatHistory {
9699 failed: ids(&["a", "b"]),
9700 last_ok: Some("b".to_owned()),
9701 last_fail: None,
9702 };
9703 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9704 }
9705
9706 #[test]
9707 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9708 let first = SeatState::new("review-1", "alpha", 7);
9709 let next = handover_seat("review-1", "gamma", 7);
9710 assert_ne!(first.claude_session, next.claude_session);
9711 }
9712
9713 #[test]
9714 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9715 let mut state = state_with_summary("x", "y");
9716 let a = handover_seat("review-1", "beta", state.next_seat_seed());
9717 let b = handover_seat("review-1", "beta", state.next_seat_seed());
9718 assert_ne!(a.claude_session, b.claude_session);
9719 }
9720
9721 #[test]
9722 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9723 let roster = [spec("a"), spec("b")];
9724 let h = SeatHistory {
9725 failed: ids(&["a", "b"]),
9726 ..SeatHistory::default()
9727 };
9728 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9729 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9730 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9731 }
9732
9733 #[test]
9740 fn failure_signature_ignores_numbers_and_paths() {
9741 assert_eq!(
9742 failure_signature("exited with Some(2) and no usable output"),
9743 failure_signature("exited with Some(137) and no usable output")
9744 );
9745 assert_eq!(
9746 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9747 failure_signature("cannot open /var/x.txt: denied")
9748 );
9749 assert_ne!(failure_signature("boom"), failure_signature("bang"));
9750 }
9751
9752 #[test]
9753 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9754 let other = FailClass::Other("x".into());
9755 assert!(should_hand_over(None, &FailClass::Quota));
9756 assert!(should_hand_over(Some(&other), &FailClass::Quota));
9757 assert!(should_hand_over(
9758 Some(&FailClass::Timeout),
9759 &FailClass::Timeout
9760 ));
9761 assert!(should_hand_over(None, &other));
9762 assert!(!should_hand_over(Some(&other), &other));
9763 assert!(should_hand_over(
9764 Some(&other),
9765 &FailClass::Other("y".into())
9766 ));
9767 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9769 assert!(should_hand_over(Some(&FailClass::Quota), &other));
9770 }
9771
9772 #[test]
9773 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9774 let a = SeatState::new("judge-1", "alpha", 7);
9775 let b = handover_seat("judge-1", "beta", 7);
9776 assert_ne!(a.claude_session, b.claude_session);
9777 assert_eq!(b.turns, 0);
9778 }
9779
9780 #[test]
9781 fn a_timeout_is_classified_apart_from_other_failures() {
9782 assert_eq!(
9783 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9784 Some(FailClass::Timeout)
9785 );
9786 assert!(matches!(
9787 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9788 Some(FailClass::Other(_))
9789 ));
9790 }
9791
9792 #[test]
9793 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9794 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9795 let tried = BTreeSet::from(["beta".to_owned()]);
9796 let next = next_untried_in_roster(&roster, 1, &tried);
9799 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9800 }
9801
9802 #[test]
9803 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9804 let roster = vec![spec("alpha"), spec("beta")];
9805 let tried = BTreeSet::from(["beta".to_owned()]);
9806 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9810 }
9811
9812 #[test]
9813 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9814 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9815 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9816 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9820 }
9821
9822 #[test]
9823 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9824 let roster = vec![spec("a"), spec("a"), spec("b")];
9825 let tried = BTreeSet::from(["a".to_owned()]);
9826 let next = next_untried_in_roster(&roster, 0, &tried);
9827 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9828 }
9829
9830 #[test]
9831 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9832 let roster = vec![spec("a"), spec("b")];
9833 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9834 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9835 }
9836
9837 #[test]
9838 fn remove_if_empty_only_ever_takes_a_bare_directory() {
9839 let dir = tempfile::tempdir().unwrap();
9840 let bay = dir.path().join("ffff");
9841
9842 remove_if_empty(&bay);
9844 assert!(!bay.exists());
9845
9846 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9849 remove_if_empty(&bay);
9850 assert!(bay.exists(), "non-empty directory must survive");
9851
9852 std::fs::remove_dir(bay.join("cand-A")).unwrap();
9854 remove_if_empty(&bay);
9855 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9856 }
9857
9858 #[test]
9867 fn a_full_panel_that_found_nothing_is_clean() {
9868 assert!(round_is_clean(
9869 0,
9870 true,
9871 2,
9872 2,
9873 0,
9874 IncompleteReviewPolicy::Block
9875 ));
9876 }
9877
9878 #[test]
9879 fn a_missing_seat_is_never_clean_under_the_default_policy() {
9880 assert!(!round_is_clean(
9881 0,
9882 true,
9883 1,
9884 2,
9885 0,
9886 IncompleteReviewPolicy::Block
9887 ));
9888 }
9889
9890 #[test]
9891 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9892 assert!(!round_is_clean(
9893 1,
9894 true,
9895 1,
9896 2,
9897 0,
9898 IncompleteReviewPolicy::Warn
9899 ));
9900 }
9901
9902 #[test]
9903 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9904 assert!(round_is_clean(
9905 0,
9906 true,
9907 1,
9908 2,
9909 0,
9910 IncompleteReviewPolicy::Warn
9911 ));
9912 }
9913
9914 #[test]
9915 fn a_full_panel_with_an_open_finding_is_not_clean() {
9916 assert!(!round_is_clean(
9917 1,
9918 true,
9919 2,
9920 2,
9921 0,
9922 IncompleteReviewPolicy::Block
9923 ));
9924 }
9925
9926 #[test]
9927 fn a_full_panel_with_a_red_e2e_is_not_clean() {
9928 assert!(!round_is_clean(
9929 0,
9930 false,
9931 2,
9932 2,
9933 0,
9934 IncompleteReviewPolicy::Block
9935 ));
9936 }
9937
9938 #[test]
9945 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9946 assert!(round_is_clean(
9949 0,
9950 true,
9951 1,
9952 2,
9953 1,
9954 IncompleteReviewPolicy::Block
9955 ));
9956 }
9957
9958 #[test]
9959 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9960 assert!(!round_is_clean(
9963 0,
9964 true,
9965 1,
9966 2,
9967 0,
9968 IncompleteReviewPolicy::Block
9969 ));
9970 }
9971
9972 #[test]
9973 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9974 assert!(!round_is_clean(
9975 1,
9976 true,
9977 1,
9978 2,
9979 1,
9980 IncompleteReviewPolicy::Block
9981 ));
9982 assert!(!round_is_clean(
9983 0,
9984 false,
9985 1,
9986 2,
9987 1,
9988 IncompleteReviewPolicy::Block
9989 ));
9990 }
9991
9992 #[test]
9993 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9994 assert!(!round_is_clean(
9998 0,
9999 true,
10000 0,
10001 2,
10002 2,
10003 IncompleteReviewPolicy::Block
10004 ));
10005 }
10006
10007 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
10008 CommandOutcome {
10009 command: "test".to_owned(),
10010 code,
10011 output_tail: String::new(),
10012 duration_ms: 0,
10013 resource_blocked,
10014 }
10015 }
10016
10017 #[test]
10018 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
10019 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
10020 assert!(
10021 !verify_inconclusive(&[outcome(Some(1), false)]),
10022 "an ordinary failure is still evidence about the patch"
10023 );
10024 assert!(verify_inconclusive(&[outcome(None, true)]));
10025 assert!(
10026 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
10027 "one inconclusive outcome taints the whole batch"
10028 );
10029 assert!(!verify_inconclusive(&[]));
10030 }
10031
10032 #[tokio::test]
10033 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
10034 let calls = std::sync::atomic::AtomicUsize::new(0);
10038 let started = Instant::now();
10039 wait_for_pids_with(
10040 &[123],
10041 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
10042 Duration::from_millis(5),
10043 Duration::from_secs(5),
10044 )
10045 .await;
10046 assert!(
10047 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
10048 "must keep checking rather than deciding on the first answer"
10049 );
10050 assert!(
10051 started.elapsed() < Duration::from_secs(1),
10052 "must return the moment it is confirmed dead, not wait out the ceiling"
10053 );
10054 }
10055
10056 #[tokio::test]
10057 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
10058 let started = Instant::now();
10059 wait_for_pids_with(
10060 &[123],
10061 |_| true, Duration::from_millis(5),
10063 Duration::from_millis(30),
10064 )
10065 .await;
10066 let elapsed = started.elapsed();
10067 assert!(
10068 elapsed >= Duration::from_millis(30),
10069 "must not give up before its own ceiling: {elapsed:?}"
10070 );
10071 assert!(
10072 elapsed < Duration::from_secs(1),
10073 "must not wait past its own ceiling either: {elapsed:?}"
10074 );
10075 }
10076
10077 #[tokio::test]
10078 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
10079 let started = Instant::now();
10080 wait_for_pids_with(
10081 &[],
10082 |_| true,
10083 Duration::from_secs(5),
10084 Duration::from_secs(5),
10085 )
10086 .await;
10087 assert!(
10088 started.elapsed() < Duration::from_millis(200),
10089 "an empty pid list has nothing to confirm"
10090 );
10091 }
10092
10093 fn review_round(
10099 clean: bool,
10100 blocking: usize,
10101 answered: usize,
10102 expected: usize,
10103 progressed: bool,
10104 e2e_ok: bool,
10105 ) -> ReviewRound {
10106 ReviewRound {
10107 round: 1,
10108 head: "h".to_owned(),
10109 verified_head: None,
10110 verified_at: None,
10111 reviews: Vec::new(),
10112 e2e: vec![CommandOutcome {
10113 command: "test".to_owned(),
10114 code: Some(if e2e_ok { 0 } else { 1 }),
10115 output_tail: String::new(),
10116 duration_ms: 0,
10117 resource_blocked: false,
10118 }],
10119 verify_retried: false,
10120 e2e_deferred: false,
10121 e2e_defer_reason: None,
10122 fix: None,
10123 blocking,
10124 answered,
10125 expected,
10126 clean,
10127 progressed,
10128 vote_split: false,
10129 reconsideration: Vec::new(),
10130 verdict: None,
10131 }
10132 }
10133
10134 #[test]
10135 fn review_conclusion_is_none_when_nothing_has_run() {
10136 assert_eq!(review_conclusion(&[], 3), None);
10137 }
10138
10139 #[test]
10140 fn review_conclusion_is_none_while_rounds_remain() {
10141 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
10142 assert_eq!(review_conclusion(&rounds, 3), None);
10143 }
10144
10145 #[test]
10146 fn review_conclusion_is_gating_once_a_round_is_clean() {
10147 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
10148 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
10149 }
10150
10151 #[test]
10152 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
10153 let rounds = vec![
10154 review_round(false, 1, 2, 2, true, true),
10155 review_round(false, 1, 2, 2, true, true),
10156 ];
10157 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
10158 }
10159
10160 #[test]
10161 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
10162 let rounds = vec![
10163 review_round(false, 1, 2, 2, true, true),
10164 review_round(false, 1, 2, 2, true, false),
10165 ];
10166 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
10167 }
10168
10169 #[test]
10170 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
10171 let mut blocked = review_round(false, 1, 2, 2, true, false);
10178 blocked.e2e[0].resource_blocked = true;
10179 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
10180 assert_eq!(review_conclusion(&rounds, 2), None);
10181 }
10182
10183 #[test]
10184 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
10185 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
10187 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
10188 }
10189
10190 #[test]
10191 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
10192 let rounds = vec![
10193 review_round(false, 1, 2, 2, false, true),
10194 review_round(false, 1, 2, 2, false, true),
10195 ];
10196 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
10197 }
10198
10199 fn secs(n: u64) -> Duration {
10200 Duration::from_secs(n)
10201 }
10202
10203 fn init_repo(dir: &Path) {
10206 let run = |args: &[&str]| {
10207 let out = std::process::Command::new("git")
10208 .args(args)
10209 .current_dir(dir)
10210 .quiet()
10211 .output()
10212 .expect("spawn git");
10213 assert!(
10214 out.status.success(),
10215 "git {args:?} failed: {}",
10216 String::from_utf8_lossy(&out.stderr)
10217 );
10218 };
10219 run(&["init", "-b", "main"]);
10220 run(&["config", "user.name", "magi test"]);
10221 run(&["config", "user.email", "magi@example.com"]);
10222 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
10223 run(&["add", "-A"]);
10224 run(&["commit", "-m", "init"]);
10225 }
10226
10227 fn ask_test_home() {
10235 crate::run::pin_test_home();
10236 }
10237
10238 fn runner_at(status: RunStatus) -> Runner {
10241 let mut state = RunState::new(
10242 PathBuf::from("/nonexistent/repo"),
10243 "main".to_owned(),
10244 "deadbeef".to_owned(),
10245 "task".to_owned(),
10246 Config::default(),
10247 );
10248 state.status = status;
10249 Runner {
10250 state,
10251 roles: ResolvedRoles {
10252 implementers: Vec::new(),
10253 judges: Vec::new(),
10254 reviewers: Vec::new(),
10255 fixer: None,
10256 conductor: conductor(),
10257 implementer_roster: Vec::new(),
10258 judge_roster: Vec::new(),
10259 reviewer_roster: Vec::new(),
10260 },
10261 sem: Arc::new(Semaphore::new(1)),
10262 pause: Pause::new(),
10263 interrupt: Pause::new(),
10264 }
10265 }
10266
10267 #[test]
10271 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
10272 crate::run::pin_test_home();
10273 let mut runner = runner_at(RunStatus::Implementing);
10274 let interrupt = Pause::new();
10275 runner.watch_interrupt(interrupt.clone());
10276
10277 interrupt.park_because("task a1b2 asked to run first");
10278
10279 assert!(runner.park_here().expect("park_here"));
10280 assert!(runner.state.parked);
10281 let last = runner.state.events.last().expect("a park event");
10282 assert_eq!(last.node, "park");
10283 assert!(
10284 last.message.contains("task a1b2 asked to run first"),
10285 "expected the interrupt reason in {:?}",
10286 last.message
10287 );
10288 }
10289
10290 #[test]
10298 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
10299 crate::run::pin_test_home();
10300 let mut runner = runner_at(RunStatus::Implementing);
10301 let shutdown = Pause::new();
10302 runner.on_pause(shutdown.clone());
10303 let interrupt = Pause::new();
10304 runner.watch_interrupt(interrupt.clone());
10305
10306 assert!(!runner.park_here().expect("park_here"));
10308 assert!(!runner.state.parked);
10309
10310 interrupt.park_because("test");
10312 assert!(!shutdown.parked());
10313 assert!(runner.park_here().expect("park_here"));
10314 }
10315
10316 #[tokio::test]
10330 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
10331 crate::run::pin_test_home();
10332 let mut runner = runner_at(RunStatus::Implementing);
10333 let interrupt = Pause::new();
10334 runner.watch_interrupt(interrupt.clone());
10335
10336 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
10337 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
10338
10339 let node = async move {
10343 started_tx.send(()).expect("send started");
10344 finish_rx.await.expect("recv finish");
10345 "node finished"
10346 };
10347
10348 let interrupter = async move {
10349 started_rx.await.expect("recv started");
10350 interrupt.park_because("higher-priority task waiting");
10352 tokio::task::yield_now().await;
10356 finish_tx.send(()).expect("send finish");
10357 };
10358
10359 let (node_result, ()) = tokio::join!(node, interrupter);
10360 assert_eq!(
10361 node_result, "node finished",
10362 "the in-flight call ran to completion"
10363 );
10364
10365 assert!(runner.park_here().expect("park_here"));
10368 assert!(runner.state.parked);
10369 }
10370
10371 #[test]
10377 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
10378 crate::run::pin_test_home();
10379 let mut runner = runner_at(RunStatus::Judging);
10380 runner.state.config.agents = vec![conductor()];
10384 runner.state.candidates = vec![Candidate {
10385 index: 0,
10386 label: 'A',
10387 agent: "alpha".to_owned(),
10388 branch: "magi/x/A".to_owned(),
10389 worktree: PathBuf::from("/nonexistent/worktree"),
10390 summary: "did the thing".to_owned(),
10391 stat: "1 file changed".to_owned(),
10392 files: 1,
10393 commits: 1,
10394 empty: false,
10395 failed: None,
10396 verified_noop: None,
10397 duration_ms: 1234,
10398 folded: false,
10399 }];
10400 let run_id = runner.state.id.clone();
10401
10402 let interrupt = Pause::new();
10403 runner.watch_interrupt(interrupt.clone());
10404 interrupt.park_because("task c3d4 asked to run first");
10405 assert!(runner.park_here().expect("park_here"));
10406
10407 let resumed = Runner::resume(&run_id).expect("resume");
10408 assert_eq!(resumed.state.candidates.len(), 1);
10409 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
10410 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
10411 assert_eq!(resumed.state.status, runner.state.status);
10412 assert!(
10413 resumed.state.parked,
10414 "still parked until `execute` actually walks the graph again"
10415 );
10416 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
10417 }
10418
10419 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
10421 let mut q = ask::Question::new(
10422 run.to_owned(),
10423 "implement".to_owned(),
10424 "impl-A".to_owned(),
10425 "Which storage backend should the cache use?".to_owned(),
10426 String::new(),
10427 vec!["SQLite".to_owned(), "Redis".to_owned()],
10428 );
10429 store.put(&mut q).unwrap();
10430 q
10431 }
10432
10433 #[test]
10434 fn a_failed_runs_open_question_is_abandoned() {
10435 ask_test_home();
10436 let store = ask::Questions::open();
10437 let mut runner = runner_at(RunStatus::Failed);
10438 let run = runner.state.id.clone();
10439 let q = ask_open_question(&store, &run);
10440
10441 runner.settle_questions();
10442
10443 let back = store.get(&q.id).unwrap();
10444 assert!(
10445 !back.status.open(),
10446 "the seat that asked died with the run; nobody is left to read an answer"
10447 );
10448 assert!(
10449 back.detail.contains(&run) && back.detail.contains("failed"),
10450 "the reason names what the run became, not just that it is gone: {}",
10451 back.detail
10452 );
10453 }
10454
10455 #[test]
10456 fn a_merged_runs_open_question_is_abandoned_too() {
10457 ask_test_home();
10458 let store = ask::Questions::open();
10459 for status in [RunStatus::Merged, RunStatus::Ready] {
10462 let mut runner = runner_at(status);
10463 let run = runner.state.id.clone();
10464 let q = ask_open_question(&store, &run);
10465
10466 runner.settle_questions();
10467
10468 let back = store.get(&q.id).unwrap();
10469 assert!(
10470 !back.status.open(),
10471 "{status:?} run's question must not outlive the run"
10472 );
10473 }
10474 }
10475
10476 #[test]
10477 fn a_still_resumable_runs_open_question_is_left_alone() {
10478 ask_test_home();
10479 let store = ask::Questions::open();
10480 for status in [RunStatus::Blocked, RunStatus::Stalled] {
10486 let mut runner = runner_at(status);
10487 let run = runner.state.id.clone();
10488 let q = ask_open_question(&store, &run);
10489
10490 runner.settle_questions();
10491
10492 let back = store.get(&q.id).unwrap();
10493 assert!(
10494 back.status.open(),
10495 "{status:?} is still alive; the question must still be waiting"
10496 );
10497 }
10498 }
10499
10500 #[test]
10501 fn settle_questions_never_touches_an_already_answered_question() {
10502 ask_test_home();
10503 let store = ask::Questions::open();
10504 let mut runner = runner_at(RunStatus::Failed);
10505 let run = runner.state.id.clone();
10506 let mut q = ask_open_question(&store, &run);
10507 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
10508 .unwrap();
10509 store.put(&mut q).unwrap();
10510
10511 runner.settle_questions();
10516 runner.settle_questions();
10517
10518 let back = store.get(&q.id).unwrap();
10519 assert_eq!(
10520 back.status,
10521 ask::QuestionStatus::Answered,
10522 "a real answer is a decision on record, never overwritten by a sweep"
10523 );
10524 }
10525
10526 #[tokio::test]
10537 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10538 crate::run::pin_test_home();
10539 let tmp = tempfile::tempdir().expect("tempdir");
10540 let repo = tmp.path().join("repo");
10541 std::fs::create_dir_all(&repo).unwrap();
10542 init_repo(&repo);
10543
10544 let mut config = Config::default();
10545 config.graph.worktree_root = Some(tmp.path().join("wt"));
10546
10547 let mut state = RunState::new(
10548 repo.clone(),
10549 "main".to_owned(),
10550 "deadbeef".to_owned(),
10551 "task".to_owned(),
10552 config,
10553 );
10554 let root = state.worktree_root();
10555 let wt_a = root.join("cand-A");
10556 let wt_b = root.join("cand-B");
10557 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10558 .await
10559 .expect("worktree A");
10560 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10561 .await
10562 .expect("worktree B");
10563
10564 state.candidates = vec![
10565 Candidate {
10566 index: 0,
10567 label: 'A',
10568 agent: "alpha".to_owned(),
10569 branch: "magi/x/A".to_owned(),
10570 worktree: wt_a.clone(),
10571 summary: String::new(),
10572 stat: String::new(),
10573 files: 0,
10574 commits: 0,
10575 empty: false,
10576 failed: None,
10577 verified_noop: None,
10578 duration_ms: 0,
10579 folded: false,
10580 },
10581 Candidate {
10582 index: 1,
10583 label: 'B',
10584 agent: "beta".to_owned(),
10585 branch: "magi/x/B".to_owned(),
10586 worktree: wt_b.clone(),
10587 summary: String::new(),
10588 stat: String::new(),
10589 files: 0,
10590 commits: 0,
10591 empty: false,
10592 failed: None,
10593 verified_noop: None,
10594 duration_ms: 0,
10595 folded: false,
10596 },
10597 ];
10598 state.tally = Some(Tally {
10599 first_choice: BTreeMap::from([('A', 1)]),
10600 borda: BTreeMap::new(),
10601 winner: 'A',
10602 rankings: 1,
10603 unanimous_initial: true,
10604 deliberated: false,
10605 changed_votes: 0,
10606 unanimous_final: true,
10607 tie_break: None,
10608 judges: 1,
10609 present: 1,
10610 quorum: 1,
10611 met_quorum: true,
10612 uncontested: None,
10613 });
10614 state.status = RunStatus::Ready;
10615
10616 fold_run(&mut state, false, &crate::run::home())
10617 .await
10618 .expect("fold_run");
10619
10620 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10621 assert!(
10622 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10623 "the unmerged winner's branch survives"
10624 );
10625 assert!(
10626 !state.candidates[0].folded,
10627 "the winner is not marked folded"
10628 );
10629
10630 assert!(!wt_b.exists(), "the loser's worktree is removed");
10631 assert!(
10632 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10633 "the loser's branch is removed"
10634 );
10635 assert!(state.candidates[1].folded, "the loser is marked folded");
10636 }
10637
10638 #[tokio::test]
10641 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10642 let tmp = tempfile::tempdir().expect("tempdir");
10643 let repo = tmp.path().join("repo");
10644 std::fs::create_dir_all(&repo).unwrap();
10645 init_repo(&repo);
10646 let home = tmp.path().join("home");
10647
10648 let mut config = Config::default();
10649 config.graph.worktree_root = Some(tmp.path().join("wt"));
10650 let mut state = RunState::new(
10651 repo.clone(),
10652 "main".to_owned(),
10653 "deadbeef".to_owned(),
10654 "task".to_owned(),
10655 config,
10656 );
10657 git::git(&repo, &["branch", "magi/x/A", "main"])
10659 .await
10660 .expect("branch");
10661 state.candidates = vec![Candidate {
10662 index: 0,
10663 label: 'A',
10664 agent: "alpha".to_owned(),
10665 branch: "magi/x/A".to_owned(),
10666 worktree: state.worktree_root().join("cand-A"),
10667 summary: String::new(),
10668 stat: String::new(),
10669 files: 0,
10670 commits: 0,
10671 empty: false,
10672 failed: None,
10673 verified_noop: None,
10674 duration_ms: 0,
10675 folded: true,
10676 }];
10677 state.released_to = Some("20260901-000000-new1".to_owned());
10678 state.released_branches = vec!["magi/x/A".to_owned()];
10679
10680 fold_run(&mut state, true, &home).await.expect("fold_run");
10681
10682 assert!(
10683 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10684 "the handed-over branch survives a fold"
10685 );
10686 }
10687
10688 #[tokio::test]
10692 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10693 let tmp = tempfile::tempdir().expect("tempdir");
10694 let repo = tmp.path().join("repo");
10695 std::fs::create_dir_all(&repo).unwrap();
10696 init_repo(&repo);
10697 let run = |args: &[&str]| {
10698 let out = std::process::Command::new("git")
10699 .quiet()
10700 .args(args)
10701 .current_dir(&repo)
10702 .output()
10703 .expect("spawn git");
10704 assert!(out.status.success(), "git {args:?}");
10705 };
10706 run(&["branch", "magi/x/A"]);
10707 run(&["checkout", "-q", "-b", "magi/x/B"]);
10708 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10709 run(&["add", "-A"]);
10710 run(&["commit", "-q", "-m", "work"]);
10711 run(&["checkout", "-q", "main"]);
10712
10713 let probe = RunState::new(
10717 repo.clone(),
10718 "main".to_owned(),
10719 "deadbeef".to_owned(),
10720 "task".to_owned(),
10721 Config::default(),
10722 );
10723 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10724 let bare = tmp.path().join("origin.git");
10725 let out = std::process::Command::new("git")
10726 .quiet()
10727 .args(["init", "-q", "--bare"])
10728 .arg(&bare)
10729 .output()
10730 .expect("spawn git");
10731 assert!(out.status.success(), "git init --bare");
10732 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10733 run(&["push", "-q", "origin", "main"]);
10734
10735 let mut state = RunState::new(
10736 repo.clone(),
10737 "main".to_owned(),
10738 "deadbeef".to_owned(),
10739 "task".to_owned(),
10740 Config::default(),
10741 );
10742 state.seeds = vec![refs::Seed {
10743 token: "magi/27b2/A".to_owned(),
10744 kind: refs::SeedKind::Unresolved,
10745 sha: String::new(),
10746 branch: true,
10747 detail: "no branch or commit named magi/27b2/A".to_owned(),
10748 }];
10749
10750 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10751 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10752 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10753 let detail = empty_candidate_detail(&state, "main");
10754 assert!(detail.starts_with("empty candidate"), "{detail}");
10755 assert!(detail.contains("magi/27b2/A"), "{detail}");
10756 }
10757
10758 #[tokio::test]
10767 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10768 let tmp = tempfile::tempdir().expect("tempdir");
10769 let repo = tmp.path().join("repo");
10770 std::fs::create_dir_all(&repo).unwrap();
10771 init_repo(&repo);
10772
10773 let mut config = Config::default();
10774 config.merge.mode = MergeMode::Local;
10775
10776 let mut state = RunState::new(
10777 repo.clone(),
10778 "main".to_owned(),
10779 "deadbeef".to_owned(),
10780 "task".to_owned(),
10781 config,
10782 );
10783 state.candidates = vec![Candidate {
10784 index: 0,
10785 label: 'A',
10786 agent: "alpha".to_owned(),
10787 branch: "does-not-exist".to_owned(),
10788 worktree: repo.clone(),
10789 summary: String::new(),
10790 stat: String::new(),
10791 files: 0,
10792 commits: 0,
10793 empty: false,
10794 failed: None,
10795 verified_noop: None,
10796 duration_ms: 0,
10797 folded: false,
10798 }];
10799 state.tally = Some(Tally {
10800 first_choice: BTreeMap::from([('A', 1)]),
10801 borda: BTreeMap::new(),
10802 winner: 'A',
10803 rankings: 1,
10804 unanimous_initial: true,
10805 deliberated: false,
10806 changed_votes: 0,
10807 unanimous_final: true,
10808 tie_break: None,
10809 judges: 0,
10810 present: 0,
10811 quorum: 0,
10812 met_quorum: true,
10813 uncontested: Some("only candidate A produced a change".to_owned()),
10814 });
10815 state.reviews = vec![ReviewRound {
10816 round: 1,
10817 head: "deadbeef".to_owned(),
10818 verified_head: None,
10819 verified_at: None,
10820 reviews: Vec::new(),
10821 e2e: Vec::new(),
10822 fix: None,
10823 blocking: 0,
10824 answered: 0,
10825 expected: 0,
10826 clean: true,
10827 verify_retried: false,
10828 e2e_deferred: false,
10829 e2e_defer_reason: None,
10830 progressed: false,
10831 vote_split: false,
10832 reconsideration: Vec::new(),
10833 verdict: None,
10834 }];
10835 state.gate = vec![CommandOutcome {
10836 command: "test".to_owned(),
10837 code: Some(0),
10838 output_tail: String::new(),
10839 duration_ms: 0,
10840 resource_blocked: false,
10841 }];
10842 state.gate_ran = true;
10843 state.status = RunStatus::Ready;
10848 state.merge = Some(MergeOutcome {
10849 mode: MergeMode::Local,
10850 ok: false,
10851 detail: "already concluded".to_owned(),
10852 empty: false,
10853 });
10854
10855 let mut runner = Runner {
10856 state,
10857 roles: ResolvedRoles {
10858 implementers: Vec::new(),
10859 judges: Vec::new(),
10860 reviewers: Vec::new(),
10861 fixer: None,
10862 conductor: conductor(),
10863 implementer_roster: Vec::new(),
10864 judge_roster: Vec::new(),
10865 reviewer_roster: Vec::new(),
10866 },
10867 sem: Arc::new(Semaphore::new(1)),
10868 pause: Pause::new(),
10869 interrupt: Pause::new(),
10870 };
10871
10872 runner.merge().await.expect("merge");
10873
10874 assert_eq!(
10875 runner.state.status,
10876 RunStatus::Ready,
10877 "a concluded run's status must not change on reentry"
10878 );
10879 assert_eq!(
10880 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10881 Some("already concluded"),
10882 "merge must not run again once the node already recorded an outcome"
10883 );
10884 }
10885
10886 #[tokio::test]
10895 async fn merge_refuses_a_gate_that_has_not_actually_run() {
10896 let tmp = tempfile::tempdir().expect("tempdir");
10897 let repo = tmp.path().join("repo");
10898 std::fs::create_dir_all(&repo).unwrap();
10899 init_repo(&repo);
10900
10901 let mut config = Config::default();
10902 config.merge.mode = MergeMode::Local;
10903
10904 let mut state = RunState::new(
10905 repo.clone(),
10906 "main".to_owned(),
10907 "deadbeef".to_owned(),
10908 "task".to_owned(),
10909 config,
10910 );
10911 state.candidates = vec![Candidate {
10912 index: 0,
10913 label: 'A',
10914 agent: "alpha".to_owned(),
10915 branch: "does-not-exist".to_owned(),
10916 worktree: repo.clone(),
10917 summary: String::new(),
10918 stat: String::new(),
10919 files: 0,
10920 commits: 0,
10921 empty: false,
10922 failed: None,
10923 verified_noop: None,
10924 duration_ms: 0,
10925 folded: false,
10926 }];
10927 state.tally = Some(Tally {
10928 first_choice: BTreeMap::from([('A', 1)]),
10929 borda: BTreeMap::new(),
10930 winner: 'A',
10931 rankings: 1,
10932 unanimous_initial: true,
10933 deliberated: false,
10934 changed_votes: 0,
10935 unanimous_final: true,
10936 tie_break: None,
10937 judges: 0,
10938 present: 0,
10939 quorum: 0,
10940 met_quorum: true,
10941 uncontested: Some("only candidate A produced a change".to_owned()),
10942 });
10943 state.reviews = vec![ReviewRound {
10944 round: 1,
10945 head: "deadbeef".to_owned(),
10946 verified_head: None,
10947 verified_at: None,
10948 reviews: Vec::new(),
10949 e2e: Vec::new(),
10950 fix: None,
10951 blocking: 0,
10952 answered: 0,
10953 expected: 0,
10954 clean: true,
10955 verify_retried: false,
10956 e2e_deferred: false,
10957 e2e_defer_reason: None,
10958 progressed: false,
10959 vote_split: false,
10960 reconsideration: Vec::new(),
10961 verdict: None,
10962 }];
10963 state.gate = Vec::new();
10965 state.gate_ran = false;
10966 state.status = RunStatus::Gating;
10967
10968 let mut runner = Runner {
10969 state,
10970 roles: ResolvedRoles {
10971 implementers: Vec::new(),
10972 judges: Vec::new(),
10973 reviewers: Vec::new(),
10974 fixer: None,
10975 conductor: conductor(),
10976 implementer_roster: Vec::new(),
10977 judge_roster: Vec::new(),
10978 reviewer_roster: Vec::new(),
10979 },
10980 sem: Arc::new(Semaphore::new(1)),
10981 pause: Pause::new(),
10982 interrupt: Pause::new(),
10983 };
10984
10985 runner.merge().await.expect("merge");
10986
10987 assert!(
10988 runner.state.merge.is_none(),
10989 "an empty gate must never be read as a passing one: {:?}",
10990 runner.state.merge
10991 );
10992 }
10993
10994 #[tokio::test]
11001 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
11002 let tmp = tempfile::tempdir().expect("tempdir");
11003 let repo = tmp.path().join("repo");
11004 std::fs::create_dir_all(&repo).unwrap();
11005 init_repo(&repo);
11006
11007 let config = Config::default();
11009
11010 let mut state = RunState::new(
11011 repo.clone(),
11012 "main".to_owned(),
11013 "deadbeef".to_owned(),
11014 "task".to_owned(),
11015 config,
11016 );
11017 state.candidates = vec![Candidate {
11018 index: 0,
11019 label: 'A',
11020 agent: "alpha".to_owned(),
11021 branch: "does-not-exist".to_owned(),
11022 worktree: repo.clone(),
11023 summary: String::new(),
11024 stat: String::new(),
11025 files: 0,
11026 commits: 0,
11027 empty: false,
11028 failed: None,
11029 verified_noop: None,
11030 duration_ms: 0,
11031 folded: false,
11032 }];
11033 state.tally = Some(Tally {
11034 first_choice: BTreeMap::from([('A', 1)]),
11035 borda: BTreeMap::new(),
11036 winner: 'A',
11037 rankings: 1,
11038 unanimous_initial: true,
11039 deliberated: false,
11040 changed_votes: 0,
11041 unanimous_final: true,
11042 tie_break: None,
11043 judges: 0,
11044 present: 0,
11045 quorum: 0,
11046 met_quorum: true,
11047 uncontested: Some("only candidate A produced a change".to_owned()),
11048 });
11049 state.reviews = vec![ReviewRound {
11050 round: 1,
11051 head: "deadbeef".to_owned(),
11052 verified_head: None,
11053 verified_at: None,
11054 reviews: Vec::new(),
11055 e2e: Vec::new(),
11056 fix: None,
11057 blocking: 0,
11058 answered: 0,
11059 expected: 0,
11060 clean: true,
11061 verify_retried: false,
11062 e2e_deferred: false,
11063 e2e_defer_reason: None,
11064 progressed: false,
11065 vote_split: false,
11066 reconsideration: Vec::new(),
11067 verdict: None,
11068 }];
11069
11070 let mut runner = Runner {
11071 state,
11072 roles: ResolvedRoles {
11073 implementers: Vec::new(),
11074 judges: Vec::new(),
11075 reviewers: Vec::new(),
11076 fixer: None,
11077 conductor: conductor(),
11078 implementer_roster: Vec::new(),
11079 judge_roster: Vec::new(),
11080 reviewer_roster: Vec::new(),
11081 },
11082 sem: Arc::new(Semaphore::new(1)),
11083 pause: Pause::new(),
11084 interrupt: Pause::new(),
11085 };
11086
11087 runner.gate().await.expect("gate");
11088 assert!(
11089 runner.state.gate_ran,
11090 "zero configured commands is still a real attempt, not an unrun gate"
11091 );
11092 assert!(runner.state.gate.is_empty());
11093 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
11094 assert_ne!(
11095 runner.state.status,
11096 RunStatus::Blocked,
11097 "a gate with nothing to check must not read as failed"
11098 );
11099
11100 runner.merge().await.expect("merge");
11101 assert_eq!(
11102 runner.state.status,
11103 RunStatus::Ready,
11104 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
11105 );
11106 }
11107
11108 #[tokio::test]
11119 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
11120 crate::run::pin_test_home();
11121 let home = crate::run::home();
11122
11123 let tmp = tempfile::tempdir().expect("tempdir");
11124 let repo = tmp.path().join("repo");
11125 std::fs::create_dir_all(&repo).unwrap();
11126 init_repo(&repo);
11127 let cache_dir = tmp.path().join("target");
11130
11131 let mut config = Config::default();
11132 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
11133 config.graph.timeout_verify = Some(2);
11136
11137 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11138 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11139 .expect("no io error acquiring directly")
11140 {
11141 crate::cache::AcquireOutcome::Acquired(g) => g,
11142 crate::cache::AcquireOutcome::Busy(b) => {
11143 panic!("expected the direct acquire to win the lease first: {b:?}")
11144 }
11145 };
11146
11147 let mut state = RunState::new(
11148 repo.clone(),
11149 "main".to_owned(),
11150 "deadbeef".to_owned(),
11151 "task".to_owned(),
11152 config,
11153 );
11154 state.candidates = vec![Candidate {
11155 index: 0,
11156 label: 'A',
11157 agent: "alpha".to_owned(),
11158 branch: "does-not-exist".to_owned(),
11159 worktree: repo.clone(),
11160 summary: String::new(),
11161 stat: String::new(),
11162 files: 0,
11163 commits: 0,
11164 empty: false,
11165 failed: None,
11166 verified_noop: None,
11167 duration_ms: 0,
11168 folded: false,
11169 }];
11170 state.tally = Some(Tally {
11171 first_choice: BTreeMap::from([('A', 1)]),
11172 borda: BTreeMap::new(),
11173 winner: 'A',
11174 rankings: 1,
11175 unanimous_initial: true,
11176 deliberated: false,
11177 changed_votes: 0,
11178 unanimous_final: true,
11179 tie_break: None,
11180 judges: 0,
11181 present: 0,
11182 quorum: 0,
11183 met_quorum: true,
11184 uncontested: Some("only candidate A produced a change".to_owned()),
11185 });
11186 state.reviews = vec![ReviewRound {
11187 round: 1,
11188 head: "deadbeef".to_owned(),
11189 verified_head: None,
11190 verified_at: None,
11191 reviews: Vec::new(),
11192 e2e: Vec::new(),
11193 fix: None,
11194 blocking: 0,
11195 answered: 0,
11196 expected: 0,
11197 clean: true,
11198 verify_retried: false,
11199 e2e_deferred: false,
11200 e2e_defer_reason: None,
11201 progressed: false,
11202 vote_split: false,
11203 reconsideration: Vec::new(),
11204 verdict: None,
11205 }];
11206
11207 let mut runner = Runner {
11208 state,
11209 roles: ResolvedRoles {
11210 implementers: Vec::new(),
11211 judges: Vec::new(),
11212 reviewers: Vec::new(),
11213 fixer: None,
11214 conductor: conductor(),
11215 implementer_roster: Vec::new(),
11216 judge_roster: Vec::new(),
11217 reviewer_roster: Vec::new(),
11218 },
11219 sem: Arc::new(Semaphore::new(1)),
11220 pause: Pause::new(),
11221 interrupt: Pause::new(),
11222 };
11223
11224 let started = std::time::Instant::now();
11225 runner.gate().await.expect("gate");
11226 assert!(
11227 started.elapsed() < Duration::from_secs(1),
11228 "a gate with nothing to run must never wait on a lease it never needed"
11229 );
11230 assert!(
11231 runner.state.gate_ran,
11232 "zero commands is still a real, immediate attempt"
11233 );
11234 assert!(runner.state.gate.is_empty());
11235 assert_ne!(
11236 runner.state.status,
11237 RunStatus::Blocked,
11238 "must not read as resource-blocked on a lease it never asked for"
11239 );
11240 }
11241
11242 #[tokio::test]
11252 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
11253 crate::run::pin_test_home();
11254
11255 let tmp = tempfile::tempdir().expect("tempdir");
11256 let repo = tmp.path().join("repo");
11257 std::fs::create_dir_all(&repo).unwrap();
11258 init_repo(&repo);
11259
11260 let mut config = Config::default();
11261 config.verify.gate = vec![
11262 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
11263 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
11264 .to_owned(),
11265 ];
11266
11267 let mut state = RunState::new(
11268 repo.clone(),
11269 "main".to_owned(),
11270 "deadbeef".to_owned(),
11271 "task".to_owned(),
11272 config,
11273 );
11274 let run_id = state.id.clone();
11275 state.candidates = vec![Candidate {
11276 index: 0,
11277 label: 'A',
11278 agent: "alpha".to_owned(),
11279 branch: "does-not-exist".to_owned(),
11280 worktree: repo.clone(),
11281 summary: String::new(),
11282 stat: String::new(),
11283 files: 0,
11284 commits: 0,
11285 empty: false,
11286 failed: None,
11287 verified_noop: None,
11288 duration_ms: 0,
11289 folded: false,
11290 }];
11291 state.tally = Some(Tally {
11292 first_choice: BTreeMap::from([('A', 1)]),
11293 borda: BTreeMap::new(),
11294 winner: 'A',
11295 rankings: 1,
11296 unanimous_initial: true,
11297 deliberated: false,
11298 changed_votes: 0,
11299 unanimous_final: true,
11300 tie_break: None,
11301 judges: 0,
11302 present: 0,
11303 quorum: 0,
11304 met_quorum: true,
11305 uncontested: Some("only candidate A produced a change".to_owned()),
11306 });
11307 state.reviews = vec![ReviewRound {
11308 round: 1,
11309 head: "deadbeef".to_owned(),
11310 verified_head: None,
11311 verified_at: None,
11312 reviews: Vec::new(),
11313 e2e: Vec::new(),
11314 fix: None,
11315 blocking: 0,
11316 answered: 0,
11317 expected: 0,
11318 clean: true,
11319 verify_retried: false,
11320 e2e_deferred: false,
11321 e2e_defer_reason: None,
11322 progressed: false,
11323 vote_split: false,
11324 reconsideration: Vec::new(),
11325 verdict: None,
11326 }];
11327
11328 let mut runner = Runner {
11329 state,
11330 roles: ResolvedRoles {
11331 implementers: Vec::new(),
11332 judges: Vec::new(),
11333 reviewers: Vec::new(),
11334 fixer: None,
11335 conductor: conductor(),
11336 implementer_roster: Vec::new(),
11337 judge_roster: Vec::new(),
11338 reviewer_roster: Vec::new(),
11339 },
11340 sem: Arc::new(Semaphore::new(1)),
11341 pause: Pause::new(),
11342 interrupt: Pause::new(),
11343 };
11344
11345 let started_marker = repo.join("started.marker");
11346 let release_marker = repo.join("release.marker");
11347 let poller = tokio::spawn(async move {
11348 for _ in 0..100 {
11353 if started_marker.exists()
11354 && let Ok(s) = crate::run::RunState::load(&run_id)
11355 && let Some(a) = s.active.get("gate")
11356 {
11357 std::fs::write(&release_marker, b"go").expect("release marker");
11358 return Some(a.clone());
11359 }
11360 tokio::time::sleep(Duration::from_millis(50)).await;
11361 }
11362 None
11363 });
11364
11365 runner.gate().await.expect("gate");
11366 let captured = poller.await.expect("poller task");
11367 let captured = captured.expect(
11368 "the poller never saw a `gate` task entry in run.json while the command was \
11369 still blocked on its own release marker",
11370 );
11371
11372 assert_eq!(captured.task.as_deref(), Some("gate"));
11373 assert_eq!(captured.node, "gate");
11374 assert_eq!(captured.index, Some(1));
11375 assert_eq!(captured.total, Some(1));
11376 assert!(
11377 captured
11378 .command
11379 .as_deref()
11380 .is_some_and(|c| c.contains("started.marker")),
11381 "{captured:?}"
11382 );
11383
11384 assert!(
11385 runner.state.active.is_empty(),
11386 "the entry must be cleared once the command actually finished: {:?}",
11387 runner.state.active
11388 );
11389 assert!(runner.state.gate_ran);
11390 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
11391 }
11392
11393 #[tokio::test]
11396 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
11397 use crate::verdict::{Finding, ReviewVote, Severity};
11398 crate::run::pin_test_home();
11399 let tmp = tempfile::tempdir().expect("tempdir");
11400 let repo = tmp.path().join("repo");
11401 std::fs::create_dir_all(&repo).unwrap();
11402 init_repo(&repo);
11403
11404 for (severity, vote, expect) in [
11405 (Severity::Major, ReviewVote::Reject, true),
11406 (Severity::Minor, ReviewVote::Reject, false),
11407 (Severity::Major, ReviewVote::Approve, false),
11408 ] {
11409 let mut round = review_round(false, 1, 1, 1, false, true);
11410 round.reviews = vec![ReviewRecord {
11411 reviewer: 1,
11412 agent: "alpha".to_owned(),
11413 summary: String::new(),
11414 findings: vec![Finding {
11415 id: "R1-1-1".to_owned(),
11416 severity,
11417 file: None,
11418 line: None,
11419 title: "t".to_owned(),
11420 detail: String::new(),
11421 }],
11422 vote: Some(vote),
11423 failed: None,
11424 duration_ms: 0,
11425 attempts: 0,
11426 }];
11427 let mut state = RunState::new(
11428 repo.clone(),
11429 "main".to_owned(),
11430 "deadbeef".to_owned(),
11431 "task".to_owned(),
11432 Config::default(),
11433 );
11434 state.reviews = vec![round];
11435 let mut runner = Runner {
11436 state,
11437 roles: ResolvedRoles {
11438 implementers: Vec::new(),
11439 judges: Vec::new(),
11440 reviewers: Vec::new(),
11441 fixer: None,
11442 conductor: conductor(),
11443 implementer_roster: Vec::new(),
11444 judge_roster: Vec::new(),
11445 reviewer_roster: Vec::new(),
11446 },
11447 sem: Arc::new(Semaphore::new(1)),
11448 pause: Pause::new(),
11449 interrupt: Pause::new(),
11450 };
11451 let shell = runner.state.config.shell();
11452 runner
11453 .stop_reviewing("round budget spent", &shell, &repo)
11454 .await
11455 .expect("stop_reviewing");
11456 assert_eq!(runner.state.status, RunStatus::Gating);
11457 assert_eq!(
11458 runner.state.contested_handoff.is_some(),
11459 expect,
11460 "{severity:?} + {vote:?}"
11461 );
11462 }
11463 }
11464
11465 #[tokio::test]
11478 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
11479 crate::run::pin_test_home();
11480 let home = crate::run::home();
11481
11482 let tmp = tempfile::tempdir().expect("tempdir");
11483 let repo = tmp.path().join("repo");
11484 std::fs::create_dir_all(&repo).unwrap();
11485 init_repo(&repo);
11486 let head = crate::git::rev_parse(&repo, "HEAD")
11487 .await
11488 .expect("rev-parse");
11489 let cache_dir = tmp.path().join("target");
11492
11493 let mut config = Config::default();
11494 config.verify.e2e = vec![format!(
11495 "CARGO_TARGET_DIR='{}' test -f README.md",
11496 cache_dir.display()
11497 )];
11498 config.graph.review_rounds = 1;
11499 config.graph.timeout_verify = Some(2);
11502
11503 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11504 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11505 .expect("no io error acquiring directly")
11506 {
11507 crate::cache::AcquireOutcome::Acquired(g) => g,
11508 crate::cache::AcquireOutcome::Busy(b) => {
11509 panic!("expected the direct acquire to win the lease first: {b:?}")
11510 }
11511 };
11512
11513 let mut state = RunState::new(
11514 repo.clone(),
11515 "main".to_owned(),
11516 head.clone(),
11517 "task".to_owned(),
11518 config,
11519 );
11520 state.candidates = vec![Candidate {
11521 index: 0,
11522 label: 'A',
11523 agent: "alpha".to_owned(),
11524 branch: "does-not-exist".to_owned(),
11525 worktree: repo.clone(),
11526 summary: String::new(),
11527 stat: String::new(),
11528 files: 0,
11529 commits: 0,
11530 empty: false,
11531 failed: None,
11532 verified_noop: None,
11533 duration_ms: 0,
11534 folded: false,
11535 }];
11536 state.tally = Some(Tally {
11537 first_choice: BTreeMap::from([('A', 1)]),
11538 borda: BTreeMap::new(),
11539 winner: 'A',
11540 rankings: 1,
11541 unanimous_initial: true,
11542 deliberated: false,
11543 changed_votes: 0,
11544 unanimous_final: true,
11545 tie_break: None,
11546 judges: 0,
11547 present: 0,
11548 quorum: 0,
11549 met_quorum: true,
11550 uncontested: Some("only candidate A produced a change".to_owned()),
11551 });
11552 state.reviews = vec![ReviewRound {
11556 round: 1,
11557 head: head.clone(),
11558 verified_head: None,
11559 verified_at: None,
11560 reviews: Vec::new(),
11561 e2e: Vec::new(),
11562 fix: None,
11563 blocking: 1,
11564 answered: 1,
11565 expected: 1,
11566 clean: false,
11567 verify_retried: false,
11568 e2e_deferred: true,
11569 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11570 progressed: false,
11571 vote_split: false,
11572 reconsideration: Vec::new(),
11573 verdict: None,
11574 }];
11575
11576 let mut runner = Runner {
11577 state,
11578 roles: ResolvedRoles {
11579 implementers: Vec::new(),
11580 judges: Vec::new(),
11581 reviewers: Vec::new(),
11582 fixer: None,
11583 conductor: conductor(),
11584 implementer_roster: Vec::new(),
11585 judge_roster: Vec::new(),
11586 reviewer_roster: Vec::new(),
11587 },
11588 sem: Arc::new(Semaphore::new(1)),
11589 pause: Pause::new(),
11590 interrupt: Pause::new(),
11591 };
11592
11593 let shell = runner.state.config.shell();
11594 runner
11595 .stop_reviewing("round budget spent", &shell, &repo)
11596 .await
11597 .expect("stop_reviewing");
11598
11599 let last = runner.state.reviews.last().expect("round record");
11600 assert_eq!(
11601 last.e2e_status(),
11602 E2eStatus::ResourceBlocked,
11603 "the shared cache is still held; the attempt must read as blocked, not deferred or \
11604 failed: {last:?}"
11605 );
11606 assert_eq!(
11607 last.verified_head.as_deref(),
11608 Some(head.as_str()),
11609 "which commit this attempt targeted is known even though nothing finished checking \
11610 it"
11611 );
11612 let first_attempt_at = last
11613 .verified_at
11614 .expect("when this attempt ran is known too");
11615 assert_ne!(
11616 runner.state.status,
11617 RunStatus::Blocked,
11618 "contention is evidence about the machine, not the patch — it must not settle the \
11619 run as blocked: {:?}",
11620 runner.state.status
11621 );
11622 assert!(
11623 !runner
11624 .state
11625 .events
11626 .iter()
11627 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11628 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11629 runner.state.events
11630 );
11631
11632 runner
11637 .stop_reviewing("round budget spent", &shell, &repo)
11638 .await
11639 .expect("stop_reviewing retry");
11640 assert_eq!(
11641 runner.state.reviews.len(),
11642 1,
11643 "no new round was started: {:?}",
11644 runner.state.reviews
11645 );
11646 let last = runner.state.reviews.last().expect("round record");
11647 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11648 assert!(
11649 last.verified_at.expect("still known") > first_attempt_at,
11650 "a second reentry must be a fresh attempt, not a stale copy of the first"
11651 );
11652 assert_ne!(runner.state.status, RunStatus::Blocked);
11653
11654 held.release();
11655 }
11656
11657 #[tokio::test]
11669 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11670 crate::run::pin_test_home();
11671 let home = crate::run::home();
11672
11673 let tmp = tempfile::tempdir().expect("tempdir");
11674 let repo = tmp.path().join("repo");
11675 std::fs::create_dir_all(&repo).unwrap();
11676 init_repo(&repo);
11677 let head = crate::git::rev_parse(&repo, "HEAD")
11678 .await
11679 .expect("rev-parse");
11680 let cache_dir = tmp.path().join("target");
11681
11682 let mut config = Config::default();
11683 config.verify.e2e = vec![format!(
11684 "CARGO_TARGET_DIR='{}' test -f README.md",
11685 cache_dir.display()
11686 )];
11687 config.graph.review_rounds = 1;
11688 config.graph.timeout_verify = Some(2);
11689
11690 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11691 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11692 .expect("no io error acquiring directly")
11693 {
11694 crate::cache::AcquireOutcome::Acquired(g) => g,
11695 crate::cache::AcquireOutcome::Busy(b) => {
11696 panic!("expected the direct acquire to win the lease first: {b:?}")
11697 }
11698 };
11699
11700 let mut state = RunState::new(
11701 repo.clone(),
11702 "main".to_owned(),
11703 head.clone(),
11704 "task".to_owned(),
11705 config,
11706 );
11707 state.candidates = vec![Candidate {
11708 index: 0,
11709 label: 'A',
11710 agent: "alpha".to_owned(),
11711 branch: "does-not-exist".to_owned(),
11712 worktree: repo.clone(),
11713 summary: String::new(),
11714 stat: String::new(),
11715 files: 0,
11716 commits: 0,
11717 empty: false,
11718 failed: None,
11719 verified_noop: None,
11720 duration_ms: 0,
11721 folded: false,
11722 }];
11723 state.tally = Some(Tally {
11724 first_choice: BTreeMap::from([('A', 1)]),
11725 borda: BTreeMap::new(),
11726 winner: 'A',
11727 rankings: 1,
11728 unanimous_initial: true,
11729 deliberated: false,
11730 changed_votes: 0,
11731 unanimous_final: true,
11732 tie_break: None,
11733 judges: 0,
11734 present: 0,
11735 quorum: 0,
11736 met_quorum: true,
11737 uncontested: Some("only candidate A produced a change".to_owned()),
11738 });
11739 state.reviews = vec![ReviewRound {
11743 round: 1,
11744 head: head.clone(),
11745 verified_head: Some(head.clone()),
11746 verified_at: Some(jiff::Timestamp::now()),
11747 reviews: Vec::new(),
11748 e2e: vec![CommandOutcome {
11749 command: format!(
11750 "CARGO_TARGET_DIR='{}' test -f README.md",
11751 cache_dir.display()
11752 ),
11753 code: None,
11754 output_tail: "waiting for the shared build cache".to_owned(),
11755 duration_ms: 0,
11756 resource_blocked: true,
11757 }],
11758 fix: None,
11759 blocking: 1,
11760 answered: 1,
11761 expected: 1,
11762 clean: false,
11763 verify_retried: false,
11764 e2e_deferred: false,
11765 e2e_defer_reason: None,
11766 progressed: false,
11767 vote_split: false,
11768 reconsideration: Vec::new(),
11769 verdict: None,
11770 }];
11771
11772 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11773 let mut runner = Runner {
11774 state,
11775 roles: ResolvedRoles {
11776 implementers: Vec::new(),
11777 judges: Vec::new(),
11778 reviewers: Vec::new(),
11779 fixer: None,
11780 conductor: conductor(),
11781 implementer_roster: Vec::new(),
11782 judge_roster: Vec::new(),
11783 reviewer_roster: Vec::new(),
11784 },
11785 sem: Arc::new(Semaphore::new(1)),
11786 pause: Pause::new(),
11787 interrupt: Pause::new(),
11788 };
11789
11790 runner.review_loop().await.expect("review_loop");
11795
11796 assert_eq!(
11797 runner.state.reviews.len(),
11798 1,
11799 "no new round was started on top of the unresolved one: {:?}",
11800 runner.state.reviews
11801 );
11802 let last = &runner.state.reviews[0];
11803 assert_eq!(
11804 last.e2e_status(),
11805 E2eStatus::ResourceBlocked,
11806 "still contended: {last:?}"
11807 );
11808 assert!(
11809 last.verified_at.expect("still known") > first_attempt_at,
11810 "review_loop must have actually retried the check, not left it exactly as found"
11811 );
11812 assert_ne!(
11813 runner.state.status,
11814 RunStatus::Blocked,
11815 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11816 runner.state.status
11817 );
11818
11819 held.release();
11820 }
11821
11822 #[tokio::test]
11823 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11824 crate::run::pin_test_home();
11825 let tmp = tempfile::tempdir().expect("tempdir");
11826 let repo = tmp.path().join("repo");
11827 std::fs::create_dir_all(&repo).unwrap();
11828 init_repo(&repo);
11829
11830 let mut config = Config::default();
11831 config.merge.mode = MergeMode::Pr;
11832 config.graph.land = true;
11833 config.graph.land_approval = false;
11834
11835 let mut state = RunState::new(
11836 repo.clone(),
11837 "main".to_owned(),
11838 "deadbeef".to_owned(),
11839 "task".to_owned(),
11840 config,
11841 );
11842 state.candidates = vec![Candidate {
11843 index: 0,
11844 label: 'A',
11845 agent: "alpha".to_owned(),
11846 branch: "does-not-exist".to_owned(),
11847 worktree: repo.clone(),
11848 summary: String::new(),
11849 stat: String::new(),
11850 files: 0,
11851 commits: 0,
11852 empty: false,
11853 failed: None,
11854 verified_noop: None,
11855 duration_ms: 0,
11856 folded: false,
11857 }];
11858 state.tally = Some(Tally {
11859 first_choice: BTreeMap::from([('A', 1)]),
11860 borda: BTreeMap::new(),
11861 winner: 'A',
11862 rankings: 1,
11863 unanimous_initial: true,
11864 deliberated: false,
11865 changed_votes: 0,
11866 unanimous_final: true,
11867 tie_break: None,
11868 judges: 0,
11869 present: 0,
11870 quorum: 0,
11871 met_quorum: true,
11872 uncontested: Some("only candidate A produced a change".to_owned()),
11873 });
11874 state.reviews = vec![ReviewRound {
11875 round: 1,
11876 head: "deadbeef".to_owned(),
11877 verified_head: None,
11878 verified_at: None,
11879 reviews: Vec::new(),
11880 e2e: Vec::new(),
11881 fix: None,
11882 blocking: 0,
11883 answered: 0,
11884 expected: 0,
11885 clean: true,
11886 verify_retried: false,
11887 e2e_deferred: false,
11888 e2e_defer_reason: None,
11889 progressed: false,
11890 vote_split: false,
11891 reconsideration: Vec::new(),
11892 verdict: None,
11893 }];
11894 state.gate = vec![CommandOutcome {
11895 command: "test".to_owned(),
11896 code: Some(0),
11897 output_tail: String::new(),
11898 duration_ms: 0,
11899 resource_blocked: false,
11900 }];
11901 state.gate_ran = true;
11902 state.status = RunStatus::Landing;
11906 state.merge = Some(MergeOutcome {
11907 mode: MergeMode::Pr,
11908 ok: true,
11909 detail: "https://example.invalid/x/y/pull/1".to_owned(),
11910 empty: false,
11911 });
11912
11913 ask_test_home();
11917 state.save().expect("save landing run");
11920 let store = ask::Questions::open();
11921 let q = ask_open_question(&store, &state.id);
11922
11923 let mut runner = Runner {
11924 state,
11925 roles: ResolvedRoles {
11926 implementers: Vec::new(),
11927 judges: Vec::new(),
11928 reviewers: Vec::new(),
11929 fixer: None,
11930 conductor: conductor(),
11931 implementer_roster: Vec::new(),
11932 judge_roster: Vec::new(),
11933 reviewer_roster: Vec::new(),
11934 },
11935 sem: Arc::new(Semaphore::new(1)),
11936 pause: Pause::new(),
11937 interrupt: Pause::new(),
11938 };
11939
11940 runner.execute().await.expect("execute");
11945
11946 assert_eq!(
11947 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11948 Some("https://example.invalid/x/y/pull/1"),
11949 "reentry must not push again or open a second pull request over the \
11950 one `land` is already watching"
11951 );
11952 assert_ne!(
11953 runner.state.status,
11954 RunStatus::Landing,
11955 "land could not actually reach the fake pull request, so it must \
11956 have given up rather than left the run silently parked forever"
11957 );
11958 assert_eq!(runner.state.status, RunStatus::Blocked);
11962 assert!(
11963 store.get(&q.id).unwrap().status.open(),
11964 "Blocked is still alive; settle_questions must have been a no-op here"
11965 );
11966 }
11967
11968 fn state_with_round(round: ReviewRound) -> RunState {
11969 let mut s = RunState::new(
11970 PathBuf::from("/repo"),
11971 "main".to_owned(),
11972 "abc1234".to_owned(),
11973 "add retries".to_owned(),
11974 Config::default(),
11975 );
11976 s.reviews = vec![round];
11977 s
11978 }
11979
11980 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11981 crate::verdict::Finding {
11982 id: id.to_owned(),
11983 severity,
11984 file: None,
11985 line: None,
11986 title: title.to_owned(),
11987 detail: String::new(),
11988 }
11989 }
11990
11991 #[test]
11992 fn pr_body_names_open_findings_and_declined_ones() {
11993 let round = ReviewRound {
11994 round: 2,
11995 head: "deadbee".to_owned(),
11996 verified_head: None,
11997 verified_at: None,
11998 reviews: vec![ReviewRecord {
11999 attempts: 0,
12000 reviewer: 1,
12001 agent: "alpha".to_owned(),
12002 summary: String::new(),
12003 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
12004 vote: None,
12005 failed: None,
12006 duration_ms: 0,
12007 }],
12008 e2e: vec![CommandOutcome {
12009 command: "cargo test".to_owned(),
12010 code: Some(0),
12011 output_tail: String::new(),
12012 duration_ms: 0,
12013 resource_blocked: false,
12014 }],
12015 verify_retried: false,
12016 e2e_deferred: false,
12017 e2e_defer_reason: None,
12018 fix: Some(FixRecord {
12019 agent: "alpha".to_owned(),
12020 addressed: Vec::new(),
12021 rejected: vec![crate::verdict::Rejection {
12022 id: "R1-1-1".to_owned(),
12023 why: "not reachable from any caller".to_owned(),
12024 }],
12025 notes: String::new(),
12026 committed: true,
12027 failed: None,
12028 duration_ms: 0,
12029 continuation: None,
12030 }),
12031 blocking: 0,
12032 answered: 1,
12033 expected: 1,
12034 clean: false,
12035 progressed: true,
12036 vote_split: false,
12037 reconsideration: Vec::new(),
12038 verdict: None,
12039 };
12040 let state = state_with_round(round);
12041 let body = pr_message(&state, 'A').body;
12042
12043 assert!(body.contains("add retries"), "the task must still be there");
12044 assert!(body.contains("R2-1-1"), "{body}");
12045 assert!(body.contains("unused import"), "{body}");
12046 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
12047 assert!(
12048 body.contains("not reachable from any caller"),
12049 "the reason it was declined: {body}"
12050 );
12051 }
12052
12053 #[test]
12054 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
12055 let round = ReviewRound {
12056 round: 1,
12057 head: "deadbee".to_owned(),
12058 verified_head: None,
12059 verified_at: None,
12060 reviews: vec![ReviewRecord {
12061 attempts: 0,
12062 reviewer: 1,
12063 agent: "alpha".to_owned(),
12064 summary: String::new(),
12065 findings: Vec::new(),
12066 vote: None,
12067 failed: None,
12068 duration_ms: 0,
12069 }],
12070 e2e: Vec::new(),
12071 verify_retried: false,
12072 e2e_deferred: false,
12073 e2e_defer_reason: None,
12074 fix: None,
12075 blocking: 0,
12076 answered: 1,
12077 expected: 1,
12078 clean: true,
12079 progressed: false,
12080 vote_split: false,
12081 reconsideration: Vec::new(),
12082 verdict: None,
12083 };
12084 let state = state_with_round(round);
12085 let body = pr_message(&state, 'A').body;
12086 assert!(!body.contains("Open review findings"), "{body}");
12087 assert!(!body.contains("Declined"), "{body}");
12088 }
12089
12090 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
12091 let mut state = RunState::new(
12092 PathBuf::from("/repo"),
12093 "main".to_owned(),
12094 "abc1234".to_owned(),
12095 instruction.to_owned(),
12096 Config::default(),
12097 );
12098 state.candidates.push(Candidate {
12099 index: 0,
12100 label: 'A',
12101 agent: "alpha".to_owned(),
12102 branch: "magi/x/A".to_owned(),
12103 worktree: PathBuf::from("/wt"),
12104 summary: summary.to_owned(),
12105 stat: String::new(),
12106 files: 1,
12107 commits: 1,
12108 empty: false,
12109 failed: None,
12110 verified_noop: None,
12111 folded: false,
12112 duration_ms: 0,
12113 });
12114 state
12115 }
12116
12117 fn review_state(subjects: &[&str]) -> RunState {
12118 let mut state = state_with_summary(
12119 "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",
12120 "",
12121 );
12122 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
12123 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
12124 state
12125 }
12126
12127 #[tokio::test]
12130 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
12131 ask_test_home();
12132 let tmp = tempfile::tempdir().unwrap();
12133 let repo = tmp.path().join("repo");
12134 std::fs::create_dir_all(&repo).unwrap();
12135 init_repo(&repo);
12136 let origin = tmp.path().join("origin.git");
12137 let g = |dir: &Path, args: &[&str]| {
12138 let out = std::process::Command::new("git")
12139 .args(args)
12140 .current_dir(dir)
12141 .quiet()
12142 .output()
12143 .expect("spawn git");
12144 assert!(
12145 out.status.success(),
12146 "git {args:?}: {}",
12147 String::from_utf8_lossy(&out.stderr)
12148 );
12149 };
12150 g(
12151 tmp.path(),
12152 &[
12153 "clone",
12154 "--bare",
12155 "-q",
12156 repo.to_str().unwrap(),
12157 origin.to_str().unwrap(),
12158 ],
12159 );
12160 g(
12161 &repo,
12162 &["remote", "add", "origin", origin.to_str().unwrap()],
12163 );
12164 let c1 = git::rev_parse(&repo, "main").await.unwrap();
12165
12166 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
12168 g(&repo, &["add", "-A"]);
12169 g(
12170 &repo,
12171 &["commit", "-q", "-m", "chore(deps): update a crate"],
12172 );
12173 g(&repo, &["push", "-q", "origin", "main"]);
12174 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
12175 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
12176 g(&repo, &["add", "-A"]);
12177 g(
12178 &repo,
12179 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
12180 );
12181 g(&repo, &["checkout", "-q", "main"]);
12182
12183 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
12184 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
12185 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
12187 assert_eq!(fallback, c1);
12188
12189 let mut state = review_state(&[
12190 "chore(deps): update a crate",
12191 "fix(daemon): apply a chosen action",
12192 ]);
12193 state.repo = repo.clone();
12194 state.base_branch = "main".to_owned();
12195 state.base_commit = c1;
12196 refresh_reviewed_commits(&mut state, "feat/own").await;
12197 assert_eq!(
12198 state.reviewed_commits,
12199 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
12200 );
12201 assert_eq!(
12202 review_title(&state).as_deref(),
12203 Some("fix(daemon): apply a chosen action")
12204 );
12205 assert_eq!(
12206 leaked_subjects(&state, "feat/own").await,
12207 Some(vec!["chore(deps): update a crate".to_owned()])
12208 );
12209 state.config.merge.remote = "nowhere".to_owned();
12212 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
12213 }
12214
12215 #[test]
12216 fn pr_message_review_single_commit_uses_its_subject() {
12217 let state = review_state(&["feat(nats): per-role user"]);
12218 let m = pr_message(&state, 'A');
12219 assert_eq!(m.title, "feat(nats): per-role user");
12220 assert!(!m.body.contains("Review the work already"), "{}", m.body);
12221 assert!(m.body.contains("## Commits under review"), "{}", m.body);
12222 }
12223
12224 #[test]
12225 fn pr_message_review_multi_commit_takes_the_oldest() {
12226 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
12227 let m = pr_message(&state, 'A');
12228 assert_eq!(m.title, "feat: the change");
12229 for s in ["feat: the change", "fix: typo", "fix: again"] {
12230 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
12231 }
12232 }
12233
12234 #[test]
12235 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
12236 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
12237 let mut state = review_state(&[first, "fix: later fixup"]);
12238 state.candidates[0].branch = "機能/ログイン".to_owned();
12239 let m = pr_message(&state, 'A');
12240 assert!(
12241 m.title.starts_with("chore: land candidate A of run"),
12242 "{}",
12243 m.title
12244 );
12245 }
12246 }
12247
12248 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
12249 BranchFacts {
12250 commits: commits
12251 .iter()
12252 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
12253 .collect(),
12254 stat: stat.to_owned(),
12255 }
12256 }
12257
12258 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
12259 let mut r = review_round(true, 0, 1, 1, true, true);
12260 r.round = round;
12261 r.fix = notes.map(|n| FixRecord {
12262 agent: "fixer".to_owned(),
12263 addressed: Vec::new(),
12264 rejected: Vec::new(),
12265 notes: n.to_owned(),
12266 committed: true,
12267 failed: None,
12268 duration_ms: 0,
12269 continuation: None,
12270 });
12271 r
12272 }
12273
12274 #[test]
12275 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
12276 let state = review_state(&["ignored"]);
12277 let f = facts(
12278 &[
12279 (
12280 "fix(login): resolve PATH on macOS",
12281 "Login shells skip rc files.",
12282 ),
12283 ("fix: address review", ""),
12284 ],
12285 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
12286 );
12287 let m = pr_message_with(&state, 'A', Some(&f));
12288 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
12289 assert!(
12290 m.body
12291 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
12292 "{}",
12293 m.body
12294 );
12295 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
12296 assert!(m.body.contains("## Diff stat"), "{}", m.body);
12297 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
12298 for banned in [
12299 "Review the work already",
12300 "no task statement",
12301 "Original task",
12302 ] {
12303 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
12304 }
12305 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
12306 }
12307
12308 #[test]
12309 fn pr_message_review_truncates_a_huge_first_commit_body() {
12310 let state = review_state(&["ignored"]);
12311 let f = facts(
12312 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
12313 "s",
12314 );
12315 let m = pr_message_with(&state, 'A', Some(&f));
12316 assert!(m.body.len() < 30_000, "{}", m.body.len());
12317 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
12318 assert!(
12319 m.body.contains("- ... 1 more commit(s)"),
12320 "{}",
12321 m.body.len()
12322 );
12323 assert!(m.body.ends_with("magi:candidate-a\n"));
12324 }
12325
12326 #[test]
12327 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
12328 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
12329 assert_eq!(m.title, "feat: x");
12330 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
12331 assert!(!m.body.contains("Diff stat"), "{}", m.body);
12332 assert!(!m.body.contains("no task statement"), "{}", m.body);
12333 }
12334
12335 #[test]
12336 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
12337 let mut state = review_state(&["日本語の件名"]);
12338 state.candidates[0].branch = "fix/macos-login-path".to_owned();
12339 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
12340 state.candidates[0].branch = "機能/ログイン".to_owned();
12341 assert!(
12342 pr_message(&state, 'A')
12343 .title
12344 .starts_with("chore: land candidate A")
12345 );
12346 }
12347
12348 #[test]
12349 fn pr_message_review_fixes_survive_a_clean_final_round() {
12350 let mut state = review_state(&["feat: x"]);
12351 state.reviews = vec![
12352 round_with_notes(1, Some("handled the PATH case")),
12353 round_with_notes(2, None),
12354 ];
12355 let body = pr_message(&state, 'A').body;
12356 assert!(
12357 body.contains("## Review fixes\n\nhandled the PATH case\n"),
12358 "{body}"
12359 );
12360 assert!(!body.contains("### Round"), "{body}");
12361
12362 state.reviews = vec![
12363 round_with_notes(1, Some("first fix")),
12364 round_with_notes(2, Some("")),
12365 round_with_notes(3, Some("second fix")),
12366 round_with_notes(4, None),
12367 ];
12368 let body = pr_message(&state, 'A').body;
12369 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
12370 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
12371 assert!(!body.contains("### Round 2"), "{body}");
12372 }
12373
12374 #[test]
12375 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
12376 let state = state_with_summary(
12377 "add retries to the client",
12378 "TITLE: feat: retries\n\nDid it.",
12379 );
12380 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
12381 assert_eq!(m.title, "feat: retries");
12382 assert!(
12383 m.body.contains("<summary>Original task</summary>"),
12384 "{}",
12385 m.body
12386 );
12387 assert!(!m.body.contains("Commits under review"), "{}", m.body);
12388 assert!(
12389 m.body
12390 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
12391 "{}",
12392 m.body
12393 );
12394 }
12395
12396 #[test]
12397 fn pr_message_review_bounds_a_long_english_subject() {
12398 let long = format!("feat: {}", "word ".repeat(100));
12399 let m = pr_message(&review_state(&[&long]), 'A');
12400 assert!(m.title.starts_with("feat: word"), "{}", m.title);
12401 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12402 }
12403
12404 #[test]
12405 fn pr_message_implementation_run_is_unchanged_by_review_support() {
12406 let state = state_with_summary("add retries\n\ndetails", "- did some things");
12407 let m = pr_message(&state, 'A');
12408 assert_eq!(m.title, "add retries");
12409 assert!(m.body.contains("<summary>Original task</summary>"));
12410 assert!(!m.body.contains("Commits under review"));
12411 assert_eq!(landing_subject_source(&state), state.instruction);
12412 }
12413
12414 #[test]
12415 fn review_run_squash_subject_is_the_change_not_the_prompt() {
12416 let state = review_state(&["feat: the change", "fix: typo"]);
12417 let source = landing_subject_source(&state);
12418 assert_eq!(land::merge_subject("", &source), "feat: the change");
12419 assert_eq!(
12420 land::merge_subject("magi: candidate A (uncommitted work)", &source),
12421 "feat: the change"
12422 );
12423 assert_eq!(
12425 land::merge_subject("feat: renamed by hand", &source),
12426 "feat: renamed by hand"
12427 );
12428 let blank = review_state(&["日本語"]);
12429 assert!(
12430 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
12431 );
12432 }
12433
12434 #[test]
12435 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
12436 let state = review_state(&["feat: the change"]);
12437 let source = landing_subject_source(&state);
12438 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
12439 assert_eq!(
12440 land::merge_subject(landing_title(&state, old), &source),
12441 "feat: the change"
12442 );
12443 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
12444 let task = state_with_summary("add retries", "");
12445 assert_eq!(landing_title(&task, old), old);
12446 }
12447
12448 #[test]
12449 fn pr_message_describes_the_change_not_the_task() {
12450 let state = state_with_summary(
12451 "今回やってほしいこと: results projector を直す",
12452 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
12453 );
12454 let m = pr_message(&state, 'A');
12455 assert_eq!(m.title, "fix(web): batch the runs list reads");
12456 assert!(
12457 m.body.starts_with("## Summary\n\n- reads run.json once"),
12458 "{}",
12459 m.body
12460 );
12461 assert!(!m.body.contains("TITLE:"), "{}", m.body);
12462 let task_at = m.body.find("今回やってほしいこと").unwrap();
12463 let details_at = m.body.find("<details>").unwrap();
12464 assert!(
12465 details_at < task_at,
12466 "the task lives inside <details>: {}",
12467 m.body
12468 );
12469 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
12470 assert!(m.body.contains("magi:candidate-a"));
12471 }
12472
12473 #[test]
12474 fn pr_message_falls_back_to_the_task_without_a_title_line() {
12475 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
12476 let m = pr_message(&state, 'A');
12477 assert_eq!(m.title, "add retries");
12478 assert!(
12479 m.body.contains("## Summary\n\n- did some things"),
12480 "{}",
12481 m.body
12482 );
12483
12484 let none = RunState::new(
12485 PathBuf::from("/repo"),
12486 "main".to_owned(),
12487 "abc1234".to_owned(),
12488 "add retries".to_owned(),
12489 Config::default(),
12490 );
12491 let m = pr_message(&none, 'A');
12492 assert_eq!(m.title, "add retries");
12493 assert!(!m.body.contains("## Summary"), "{}", m.body);
12494 }
12495
12496 #[test]
12497 fn pr_message_refuses_the_candidate_commit_subject() {
12498 for bad in [
12499 "TITLE: magi: candidate A (uncommitted work)",
12500 "TITLE: chore: stuff (uncommitted work)",
12501 "TITLE: ",
12502 ] {
12503 let state = state_with_summary("add retries", bad);
12504 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
12505 }
12506 }
12507
12508 #[test]
12509 fn pr_message_bounds_a_very_long_task_and_title() {
12510 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
12511 let state = state_with_summary(&long, "- nothing");
12512 let m = pr_message(&state, 'A');
12513 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12514 assert!(!m.title.contains('\n'));
12515
12516 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12517 let m = pr_message(&state, 'A');
12518 assert!(m.title.starts_with("feat: "));
12519 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12520 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12521 }
12522
12523 fn long_title_of(instruction: &str) -> String {
12524 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12525 }
12526
12527 #[test]
12528 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12529 let first = "Make the landing path keep a readable title for long tasks";
12530 let line = format!(
12531 "{first}. {}",
12532 "And then keep going with more words ".repeat(20)
12533 );
12534 let t = long_title_of(&line);
12535 assert_eq!(t, first);
12536 assert!(!t.starts_with("chore: land"));
12537 }
12538
12539 #[test]
12540 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12541 let line = "word ".repeat(200);
12542 let t = long_title_of(&line);
12543 assert!(t.ends_with("word..."), "{t}");
12544 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12545 }
12546
12547 #[test]
12548 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12549 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12550 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12551 }
12552 }
12553
12554 #[test]
12555 fn pr_message_title_limit_is_exact() {
12556 let at = "a".repeat(PR_TITLE_MAX);
12557 assert_eq!(long_title_of(&at), at);
12558 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12559 assert!(over.ends_with("..."), "{over}");
12560 assert_eq!(over.chars().count(), PR_TITLE_MAX);
12561 }
12562
12563 #[test]
12564 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12565 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12566 let t = long_title_of(&line);
12567 assert!(t.ends_with("..."), "{t}");
12568 assert!(t.is_ascii(), "{t}");
12569 }
12570
12571 #[test]
12572 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12573 let line = format!(
12574 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12575 "plus more filler words ".repeat(20)
12576 );
12577 let t = long_title_of(&line);
12578 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12579 }
12580
12581 #[test]
12582 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12583 let line = format!("\n\n# {}", "title words ".repeat(40));
12584 let state = state_with_summary(&line, "- nothing");
12585 let m = pr_message(&state, 'A');
12586 assert!(m.title.starts_with("title words"), "{}", m.title);
12587 assert_eq!(
12588 land::merge_subject(&m.title, &landing_subject_source(&state)),
12589 m.title
12590 );
12591 assert_eq!(
12593 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12594 "feat: renamed by hand"
12595 );
12596 }
12597
12598 #[tokio::test]
12599 async fn github_text_rewrite_is_bounded_and_falls_back() {
12600 crate::run::pin_test_home();
12601 for (reply, accepted) in [
12602 (
12603 "TITLE: fix: retries\nAdd retries for failed requests.",
12604 true,
12605 ),
12606 (
12607 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12608 false,
12609 ),
12610 (
12611 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
12612 true,
12613 ),
12614 (
12615 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
12616 true,
12617 ),
12618 (
12619 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
12620 true,
12621 ),
12622 (
12623 "TITLE: fix: retries\n日本語の説明です。 token=secret",
12624 false,
12625 ),
12626 ("TITLE: fix: retries\n", false),
12627 ] {
12628 let dir = tempfile::tempdir().unwrap();
12629 let mut runner = runner_at(RunStatus::Gating);
12630 runner.state = state_with_summary(
12631 "add retries",
12632 "TITLE: fix: retries\n日本語の説明を書きます。",
12633 );
12634 runner.state.repo = dir.path().to_owned();
12635 runner.state.candidates[0].worktree = dir.path().to_owned();
12636 let mut author = spec("alpha");
12637 author.command = vec![
12638 "sh".into(),
12639 "-c".into(),
12640 "printf '%s' \"$REWRITE_REPLY\"".into(),
12641 ];
12642 author.env.insert("REWRITE_REPLY".into(), reply.into());
12643 runner.state.config.agents = vec![author];
12644 let winner = runner.state.candidates[0].clone();
12645 let first = runner
12646 .guarded_pr_message(&winner, None, true)
12647 .await
12648 .unwrap();
12649 let message = if accepted {
12650 first.expect("a passing rewrite never asks")
12651 } else {
12652 assert!(first.is_none());
12654 assert!(runner.state.parked);
12655 let store = ask::Questions::open();
12656 let asked: Vec<_> = store
12657 .list()
12658 .into_iter()
12659 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12660 .collect();
12661 assert_eq!(asked.len(), 1);
12662 assert!(!asked[0].detail.contains("secret"));
12663 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
12664 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
12665 assert_eq!(
12666 asked[0]
12667 .choices
12668 .contains(&crate::github_text::USE_REWRITE.to_owned()),
12669 rewrite.is_some()
12670 );
12671 store
12674 .update(&asked[0].id, |q| {
12675 q.abandon("test");
12676 Ok(())
12677 })
12678 .unwrap();
12679 let again = runner
12680 .guarded_pr_message(&winner, None, true)
12681 .await
12682 .unwrap()
12683 .expect("an abandoned question falls back");
12684 let n = store
12685 .list()
12686 .into_iter()
12687 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12688 .count();
12689 assert_eq!(n, 1, "asked once per rejected text");
12690 again
12691 };
12692 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12693 assert!(!message.body.contains("secret"));
12694 assert!(!message.body.contains("prefix"));
12695 assert!(!message.title.contains("secret"));
12696 assert_eq!(
12697 message.body.contains("Add retries for failed requests."),
12698 accepted
12699 );
12700 assert_eq!(
12701 runner.state.seats["impl-A"].turns, 1,
12702 "only one rewrite invocation"
12703 );
12704 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12705 if accepted {
12706 assert!(runner.state.github_text.is_none());
12707 assert!(!runner.state.parked);
12708 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
12709 assert!(
12710 message
12711 .body
12712 .contains(&format!("magi:run/{}", runner.state.id))
12713 );
12714 }
12715 if !accepted {
12716 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12717 assert!(
12718 message
12719 .body
12720 .contains(&format!("magi:run/{}", runner.state.id))
12721 );
12722 }
12723 }
12724 }
12725
12726 #[tokio::test]
12727 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
12728 crate::run::pin_test_home();
12729 let dir = tempfile::tempdir().unwrap();
12730 let mut runner = runner_at(RunStatus::Gating);
12731 runner.state =
12732 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
12733 runner.state.repo = dir.path().to_owned();
12734 runner.state.candidates[0].worktree = dir.path().to_owned();
12735 let mut author = spec("alpha");
12736 author.command = vec![
12737 "sh".into(),
12738 "-c".into(),
12739 "printf '%s' \"$REWRITE_REPLY\"".into(),
12740 ];
12741 author.env.insert(
12742 "REWRITE_REPLY".into(),
12743 format!(
12744 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
12745 "English sentence. ".repeat(600)
12746 ),
12747 );
12748 runner.state.config.agents = vec![author];
12749 let winner = runner.state.candidates[0].clone();
12750 assert!(
12751 runner
12752 .guarded_pr_message(&winner, None, true)
12753 .await
12754 .unwrap()
12755 .is_none()
12756 );
12757 let record = runner.state.github_text.clone().unwrap();
12758 let rewrite = record.rewrite.clone().unwrap();
12759 assert_eq!(rewrite.failed, ["body-language"]);
12760 assert!(
12761 rewrite
12762 .body
12763 .ends_with(&format!("magi:run/{}", runner.state.id))
12764 );
12765 let store = ask::Questions::open();
12766 let q = store.get(record.question.as_ref().unwrap()).unwrap();
12767 assert!(
12768 q.choices
12769 .contains(&crate::github_text::USE_REWRITE.to_owned())
12770 );
12771 for label in [
12772 "ORIGINAL",
12773 "FALLBACK",
12774 "REWRITE",
12775 "Rules still failed: body-language",
12776 "truncated",
12777 ] {
12778 assert!(q.detail.contains(label), "{label}");
12779 }
12780 let artifact = std::fs::read_to_string(crate::run::artifact_path(
12781 &runner.state,
12782 &crate::github_text::artifact_name(&record.fingerprint),
12783 ))
12784 .unwrap();
12785 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
12786 runner.state =
12788 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
12789 store
12790 .update(&q.id, |q| {
12791 q.say("まだ日本語")?;
12792 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
12793 })
12794 .unwrap();
12795 assert!(
12796 runner
12797 .guarded_pr_message(&winner, None, true)
12798 .await
12799 .unwrap()
12800 .is_none()
12801 );
12802 let retry = runner.state.github_text.as_ref().unwrap();
12803 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
12804 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
12805 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
12806 store
12807 .update(&q.id, |q| {
12808 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
12809 })
12810 .unwrap();
12811 let message = runner
12812 .guarded_pr_message(&winner, None, true)
12813 .await
12814 .unwrap()
12815 .unwrap();
12816 assert_eq!(message.title, rewrite.title);
12817 assert_eq!(message.body, rewrite.body);
12818 assert_eq!(
12819 runner.state.github_text.as_ref().unwrap().choice,
12820 Some(GithubTextChoice::Rewrite)
12821 );
12822 let again = runner
12823 .guarded_pr_message(&winner, None, true)
12824 .await
12825 .unwrap()
12826 .unwrap();
12827 assert_eq!(again.title, message.title);
12828 assert_eq!(again.body, message.body);
12829 assert_eq!(runner.state.seats["impl-A"].turns, 1);
12830 assert!(
12831 !runner
12832 .state
12833 .events
12834 .iter()
12835 .any(|e| e.message.contains(&rewrite.title)
12836 || e.message.contains("English sentence."))
12837 );
12838 }
12839
12840 #[tokio::test]
12841 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
12842 crate::run::pin_test_home();
12843 let dir = tempfile::tempdir().unwrap();
12844 let mut runner = runner_at(RunStatus::Gating);
12845 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
12846 runner.state.repo = dir.path().to_owned();
12847 runner.state.candidates[0].worktree = dir.path().to_owned();
12848 let winner = runner.state.candidates[0].clone();
12849 assert!(
12850 runner
12851 .guarded_pr_message(&winner, None, true)
12852 .await
12853 .unwrap()
12854 .is_none()
12855 );
12856 let record = runner.state.github_text.as_ref().unwrap();
12857 assert!(record.rewrite.is_none());
12858 let mut old = serde_json::to_value(record).unwrap();
12860 old.as_object_mut().unwrap().remove("rewrite");
12861 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
12862 assert!(old.rewrite.is_none());
12863 let id = record.question.clone().unwrap();
12864 ask::Questions::open()
12865 .update(&id, |q| {
12866 q.choices.push(crate::github_text::USE_REWRITE.into());
12867 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
12868 })
12869 .unwrap();
12870 let message = runner
12871 .guarded_pr_message(&winner, None, true)
12872 .await
12873 .unwrap()
12874 .unwrap();
12875 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12876 assert_eq!(
12877 runner.state.github_text.as_ref().unwrap().choice,
12878 Some(GithubTextChoice::Fallback)
12879 );
12880 }
12881
12882 #[tokio::test]
12883 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12884 crate::run::pin_test_home();
12885 let dir = tempfile::tempdir().unwrap();
12886 let mut runner = runner_at(RunStatus::Gating);
12887 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12888 runner.state.repo = dir.path().to_owned();
12889 runner.state.candidates[0].worktree = dir.path().to_owned();
12890 let winner = runner.state.candidates[0].clone();
12891 assert!(
12892 runner
12893 .guarded_pr_message(&winner, None, true)
12894 .await
12895 .unwrap()
12896 .is_none()
12897 );
12898 let store = ask::Questions::open();
12899 let find = |run: &str| {
12900 store
12901 .list()
12902 .into_iter()
12903 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12904 .unwrap()
12905 };
12906 let run = runner.state.id.clone();
12907 let q = find(&run);
12908 let fp = runner
12909 .state
12910 .github_text
12911 .as_ref()
12912 .unwrap()
12913 .fingerprint
12914 .clone();
12915 let name = crate::github_text::artifact_name(&fp);
12916 let path = crate::run::artifact_path(&runner.state, &name);
12917 assert!(q.detail.contains(&path.display().to_string()));
12918 assert!(q.detail.contains("title-language: title"));
12919 let saved = std::fs::read_to_string(&path).expect("artifact written");
12920 assert!(saved.contains("日本語のタイトル"));
12921 assert!(q.cwd.is_none());
12922 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
12923 let say = |id: &str, text: &str, choice: &str| {
12925 store
12926 .update(id, |q| {
12927 q.thread.push(ask::Turn {
12928 who: ask::Who::Operator,
12929 body: text.to_owned(),
12930 at: jiff::Timestamp::now(),
12931 note: None,
12932 });
12933 q.answer(ask::Answer::Choice(choice.to_owned()))
12934 })
12935 .unwrap();
12936 };
12937 say(&q.id, "まだ日本語", "use my text");
12938 assert!(
12939 runner
12940 .guarded_pr_message(&winner, None, true)
12941 .await
12942 .unwrap()
12943 .is_none()
12944 );
12945 let second = find(&run);
12946 assert_ne!(second.id, q.id);
12947 assert!(second.detail.contains("did not pass"));
12948 assert!(second.detail.contains("日本語のタイトル"));
12951 assert!(!second.detail.contains("まだ日本語"));
12952 assert!(second.detail.contains(&path.display().to_string()));
12953 say(&second.id, "fix: retry failed requests", "use my text");
12954 let message = runner
12955 .guarded_pr_message(&winner, None, true)
12956 .await
12957 .unwrap()
12958 .expect("a vetted replacement is posted");
12959 assert_eq!(message.title, "fix: retry failed requests");
12960 assert_eq!(
12961 runner
12962 .state
12963 .github_text
12964 .as_ref()
12965 .unwrap()
12966 .chosen_title
12967 .as_deref(),
12968 Some("fix: retry failed requests")
12969 );
12970 assert!(runner.state.github_text.as_ref().unwrap().resolved);
12971 let again = runner
12973 .guarded_pr_message(&winner, None, true)
12974 .await
12975 .unwrap()
12976 .unwrap();
12977 assert_eq!(again.title, message.title);
12978 assert_eq!(
12979 store
12980 .list()
12981 .into_iter()
12982 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12983 .count(),
12984 2
12985 );
12986 }
12987
12988 #[test]
12989 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12990 let mut state = state_with_summary(
12994 "add retries",
12995 "TITLE: fix(web): batch reads\n- reads run.json once",
12996 );
12997 state.config.graph.language = "ja".to_owned();
12998 let m = pr_message(&state, 'A');
12999 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
13000 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
13001
13002 let task = "今回やってほしいこと: results projector を直す";
13005 let mut state = state_with_summary(task, "- no title line");
13006 state.config.graph.language = "ja".to_owned();
13007 let m = pr_message(&state, 'A');
13008 assert_eq!(
13009 m.title,
13010 format!("chore: land candidate A of run {}", state.id)
13011 );
13012 assert!(
13013 m.body.contains(&format!(
13014 "<summary>Original task</summary>\n\n{task}\n\n</details>"
13015 )),
13016 "{}",
13017 m.body
13018 );
13019 }
13020
13021 #[test]
13022 fn pr_message_scrubs_home_paths_and_addresses() {
13023 let state = state_with_summary(
13024 "fix it in /Users/someone/src/x",
13025 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
13026 );
13027 let m = pr_message(&state, 'A');
13028 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
13029 assert!(!m.body.contains(leak), "{}", m.body);
13030 }
13031 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
13032 }
13033
13034 #[test]
13035 fn pr_message_survives_a_task_that_closes_details() {
13036 let state = state_with_summary("a </details> b", "TITLE: fix: x");
13037 let m = pr_message(&state, 'A');
13038 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
13039 }
13040
13041 #[test]
13042 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
13043 let cmd = manual_merge_command(
13044 MergeStyle::Squash,
13045 Path::new("/repo"),
13046 "b",
13047 "fix: \"quoted\" $(x) `y`\n\nbody",
13048 );
13049 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
13050 }
13051
13052 #[test]
13053 fn manual_merge_command_matches_the_configured_style() {
13054 let repo = Path::new("/repo");
13055 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
13056
13057 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
13058 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
13059
13060 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
13061 assert_eq!(
13062 squash,
13063 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
13064 \"Merge magi run 0832 (candidate A)\""
13065 );
13066
13067 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
13068 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
13069 }
13070
13071 #[test]
13072 fn a_nudge_gets_a_quarter_of_the_budget() {
13073 assert_eq!(retry_budget(secs(1200), true), secs(300));
13075 assert_eq!(retry_budget(secs(3600), true), secs(900));
13076 }
13077
13078 #[test]
13079 fn a_resent_prompt_keeps_the_whole_budget() {
13080 assert_eq!(retry_budget(secs(1200), false), secs(1200));
13083 assert_eq!(retry_budget(secs(60), false), secs(60));
13084 }
13085
13086 #[test]
13087 fn the_floor_never_exceeds_the_original_budget() {
13088 assert_eq!(retry_budget(secs(60), true), secs(60));
13092 assert_eq!(retry_budget(secs(480), true), secs(120));
13093 assert_eq!(retry_budget(secs(0), true), secs(0));
13094 }
13095
13096 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
13097 agent::CommandEvidence {
13098 id: "item1".to_owned(),
13099 description: "cargo test".to_owned(),
13100 exit_code,
13101 result_summary: String::new(),
13102 source: "codex".to_owned(),
13103 }
13104 }
13105
13106 #[test]
13107 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
13108 assert!(!has_unconfirmed_command(&[]));
13112 }
13113
13114 #[test]
13115 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
13116 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
13120 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
13121 assert!(!has_unconfirmed_command(&[
13122 evidence(Some(0)),
13123 evidence(Some(101))
13124 ]));
13125 }
13126
13127 #[test]
13128 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
13129 assert!(has_unconfirmed_command(&[
13130 evidence(Some(0)),
13131 evidence(None)
13132 ]));
13133 }
13134
13135 #[test]
13136 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
13137 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
13138 assert_eq!(
13139 verified_noop_claim(true, &[], text).as_deref(),
13140 Some("already fixed by b32cfc4, on main.")
13141 );
13142 }
13143
13144 #[test]
13145 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
13146 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
13149 assert!(verified_noop_claim(false, &[], text).is_none());
13150 }
13151
13152 #[test]
13153 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
13154 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
13155 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
13156 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
13158 }
13159
13160 #[test]
13161 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
13162 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
13163 }
13164
13165 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
13168 runner.state.candidates = shape
13169 .iter()
13170 .enumerate()
13171 .map(|(i, &(empty, verified))| Candidate {
13172 index: i,
13173 label: (b'A' + i as u8) as char,
13174 agent: "sonnet".to_owned(),
13175 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
13176 worktree: PathBuf::from(format!("/wt/{i}")),
13177 summary: String::new(),
13178 stat: String::new(),
13179 files: 0,
13180 commits: 0,
13181 empty,
13182 failed: None,
13183 verified_noop: verified.map(str::to_owned),
13184 duration_ms: 0,
13185 folded: false,
13186 })
13187 .collect();
13188 }
13189
13190 #[test]
13191 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
13192 ask_test_home();
13193 let mut runner = runner_at(RunStatus::Implementing);
13194 set_candidates(
13195 &mut runner,
13196 &[
13197 (true, Some("already on main at b32cfc4")),
13198 (true, Some("same fix, see the existing test")),
13199 ],
13200 );
13201
13202 runner
13203 .after_implement()
13204 .expect("a verified no-op is not an error");
13205
13206 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
13207 }
13208
13209 #[test]
13210 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
13211 ask_test_home();
13212 let mut runner = runner_at(RunStatus::Implementing);
13213 set_candidates(
13217 &mut runner,
13218 &[(true, Some("already on main at b32cfc4")), (true, None)],
13219 );
13220
13221 let err = runner
13222 .after_implement()
13223 .expect_err("an unverified empty candidate must still fail the run");
13224
13225 assert!(
13226 err.to_string().contains("no candidate produced a change"),
13227 "{err}"
13228 );
13229 assert_eq!(runner.state.status, RunStatus::Failed);
13230 }
13231
13232 #[test]
13233 fn after_implement_still_fails_an_ordinary_all_empty_run() {
13234 ask_test_home();
13235 let mut runner = runner_at(RunStatus::Implementing);
13236 set_candidates(&mut runner, &[(true, None), (true, None)]);
13237
13238 let err = runner
13239 .after_implement()
13240 .expect_err("no candidate declared anything; this is an ordinary failure");
13241
13242 assert!(
13243 err.to_string().contains("no candidate produced a change"),
13244 "{err}"
13245 );
13246 assert_eq!(runner.state.status, RunStatus::Failed);
13247 }
13248}
13249
13250#[derive(Debug)]
13253struct GraftRestoreFailed(String);
13254
13255impl std::fmt::Display for GraftRestoreFailed {
13256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13257 write!(
13258 f,
13259 "could not restore the winner to its pre-graft commit: {}",
13260 self.0
13261 )
13262 }
13263}
13264
13265impl std::error::Error for GraftRestoreFailed {}