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::land;
41use crate::proc::Quiet as _;
42use crate::prompt::{
43 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
44};
45use crate::queue;
46use crate::refs;
47use crate::run::{
48 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
49 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord, Handover,
50 JobRecord, JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome,
51 OperatorFixRequest, Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState,
52 RunStatus, SeatHistory, Tally, VoteRecord, tail, write_artifact,
53};
54use crate::verdict::{
55 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
56 ReviewVote, Severity,
57};
58use crate::worktree_setup;
59
60const OUTPUT_TAIL: usize = 8_000;
62
63const EVENT_OUTPUT_TAIL: usize = 2_000;
66
67const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
70
71const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
88
89pub(crate) const STAGNANT_LIMIT: usize = 2;
103
104const BASE_SYNC_ROUNDS: usize = 4;
117
118const MAX_FIX_CONTINUATIONS: usize = 2;
134
135#[derive(Clone)]
141struct SeatJob {
142 spec: AgentSpec,
143 seat: SeatState,
144 cwd: PathBuf,
145 prompt: String,
146 timeout: Duration,
147 allow_write: bool,
148 sessions: bool,
149 artifacts: PathBuf,
150 stem: String,
151 handover: Option<String>,
156}
157
158enum AgentOutcome {
170 Ok(AgentOutput),
172 Quota(AgentOutput),
174 Dropped(AgentOutput),
177 Failed(String),
179}
180
181#[derive(Debug, Clone, Default)]
208pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
209
210impl Pause {
211 #[must_use]
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 pub fn park(&self) {
219 self.0.store(true, Ordering::SeqCst);
220 }
221
222 pub fn park_because(&self, reason: impl Into<String>) {
228 let mut reason_guard = self
229 .1
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner);
232 if reason_guard.is_none() {
233 *reason_guard = Some(reason.into());
234 }
235 drop(reason_guard);
236 self.park();
237 }
238
239 #[must_use]
241 pub fn parked(&self) -> bool {
242 self.0.load(Ordering::SeqCst)
243 }
244
245 #[must_use]
247 pub fn reason(&self) -> Option<String> {
248 self.1
249 .lock()
250 .unwrap_or_else(std::sync::PoisonError::into_inner)
251 .clone()
252 }
253}
254
255pub struct Runner {
257 pub state: RunState,
259 roles: ResolvedRoles,
260 sem: Arc<Semaphore>,
261 pause: Pause,
265 interrupt: Pause,
271}
272
273async fn review_base(
278 repo: &Path,
279 remote: &str,
280 base_branch: &str,
281 base_commit: &str,
282 branch: &str,
283) -> String {
284 review_base_checked(repo, remote, base_branch, base_commit, branch)
285 .await
286 .0
287}
288
289async fn review_base_checked(
294 repo: &Path,
295 remote: &str,
296 base_branch: &str,
297 base_commit: &str,
298 branch: &str,
299) -> (String, bool) {
300 let tracking = format!("{remote}/{base_branch}");
301 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
302 if git::rev_exists(repo, &tracking).await
303 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
304 && !mb.is_empty()
305 {
306 return (mb, fresh);
307 }
308 let mb = git::merge_base(repo, base_commit, branch)
309 .await
310 .ok()
311 .filter(|mb| !mb.is_empty())
312 .unwrap_or_else(|| base_commit.to_owned());
313 (mb, false)
314}
315
316pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
320 if !is_review_run(state) {
321 return;
322 }
323 let base = review_base(
324 &state.repo,
325 &state.config.merge.remote,
326 &state.base_branch,
327 &state.base_commit,
328 branch,
329 )
330 .await;
331 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
332 && !subjects.is_empty()
333 && state.reviewed_commits.as_ref() != Some(&subjects)
334 {
335 state.reviewed_commits = Some(subjects);
336 state.save().ok();
337 }
338}
339
340async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
344 let (base, trusted) = review_base_checked(
345 &state.repo,
346 &state.config.merge.remote,
347 &state.base_branch,
348 &state.base_commit,
349 branch,
350 )
351 .await;
352 if !trusted {
353 return None;
354 }
355 git::subjects(&state.repo, &state.base_commit, &base)
356 .await
357 .ok()
358}
359
360fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
364 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
365 if is_leak(computed) {
366 return false;
367 }
368 let cur = current.trim();
369 cur.is_empty()
370 || cur.starts_with(REVIEW_PROMPT_OPENING)
371 || cur.starts_with("chore: land candidate")
372 || cur.starts_with("magi: candidate")
373 || is_leak(cur)
374}
375
376async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
405 let tracking = format!("{remote}/{branch}");
406 let fetched = git::fetch(repo, remote, branch).await;
407 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
408 let local_exists = git::branch_exists(repo, branch).await?;
409 if !fresh {
410 if !local_exists {
411 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
412 }
413 tracing::warn!(
414 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
415 );
416 return Ok(());
417 }
418 let remote_sha = git::rev_parse(repo, &tracking).await?;
419 if !local_exists {
420 git::git(repo, &["branch", branch, &tracking]).await?;
421 return Ok(());
422 }
423 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
424 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
425 return Ok(());
426 }
427 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
428 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
434 .await?
435 {
436 crate::reconcile::Reconciliation::Pushed => {
437 tracing::warn!(
438 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
439 short(&local_sha),
440 short(&remote_sha)
441 );
442 return Ok(());
443 }
444 crate::reconcile::Reconciliation::Placeholder => {}
445 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
446 }
447 }
448 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
449 if !out.ok() {
450 bail!(
451 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
452 short(&local_sha),
453 short(&remote_sha),
454 out.stderr
455 );
456 }
457 tracing::warn!(
458 "local `{branch}` was stale: fast-forwarded {} -> {}",
459 short(&local_sha),
460 short(&remote_sha)
461 );
462 Ok(())
463}
464
465async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
466 let tracking = format!("{remote}/{base_branch}");
467 let fetched = git::fetch(repo, remote, base_branch).await;
468 if let Ok(out) = &fetched
469 && out.ok()
470 && git::rev_exists(repo, &tracking).await
471 {
472 return git::rev_parse(repo, &tracking).await;
473 }
474 let why = match &fetched {
475 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
476 Ok(_) => format!("{remote} has no {base_branch}"),
477 Err(e) => e.to_string(),
478 };
479 bail!(
482 "cannot read {tracking} ({why}); refusing to branch off the local \
483 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
484 base / remote in magi.toml"
485 )
486}
487
488struct FixClaim {
505 path: PathBuf,
506}
507
508impl FixClaim {
509 fn acquire(dir: &Path) -> Result<Self> {
510 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
511 let path = dir.join("fix.lock");
512 match Self::create(&path) {
513 Ok(claim) => Ok(claim),
514 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
515 if Self::reclaim_if_dead(&path) {
516 Self::create(&path).with_context(|| format!("lock {}", path.display()))
517 } else {
518 bail!(
519 "another `magi fix` is already running for this run ({} exists)",
520 path.display()
521 )
522 }
523 }
524 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
525 }
526 }
527
528 fn create(path: &Path) -> std::io::Result<Self> {
529 let mut f = std::fs::OpenOptions::new()
530 .write(true)
531 .create_new(true)
532 .open(path)?;
533 use std::io::Write as _;
534 writeln!(f, "{}", std::process::id())?;
536 Ok(Self {
537 path: path.to_owned(),
538 })
539 }
540
541 fn reclaim_if_dead(path: &Path) -> bool {
545 let dead = std::fs::read_to_string(path)
546 .ok()
547 .and_then(|body| body.trim().parse::<u32>().ok())
548 .is_some_and(|pid| !crate::proc::pid_alive(pid));
549 dead && std::fs::remove_file(path).is_ok()
550 }
551}
552
553impl Drop for FixClaim {
554 fn drop(&mut self) {
555 let _ = std::fs::remove_file(&self.path);
556 }
557}
558
559impl Runner {
560 pub async fn start(
562 repo: &Path,
563 instruction: String,
564 config: Config,
565 origin: Origin,
566 ) -> Result<Self> {
567 Self::start_naming(repo, instruction, "", config, origin).await
568 }
569
570 pub async fn start_naming(
574 repo: &Path,
575 instruction: String,
576 also_scan: &str,
577 config: Config,
578 origin: Origin,
579 ) -> Result<Self> {
580 let repo = git::toplevel(repo).await?;
581 let missing = agent::missing_programs(&config.agents);
582 if !missing.is_empty() {
583 bail!(
584 "these agent programs are not on PATH: {}. Fix the roster in \
585 magi.toml or install them.",
586 missing.join(", ")
587 );
588 }
589 let base_branch =
590 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
591 .await?;
592 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
593 let roles = config.resolve_roles()?;
594 let max_parallel = config.graph.max_parallel.max(1);
595 let seeds = refs::resolve(
598 &repo,
599 &base_commit,
600 &config.merge.remote,
601 &format!("{also_scan}\n{instruction}"),
602 )
603 .await;
604 refs::plan(&repo, &seeds).await?;
605 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
606 state.origin = Some(origin);
610 for seed in &seeds {
611 state.event(
612 "seed",
613 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
614 );
615 }
616 state.seeds = seeds;
617 state.event("start", format!("run {} created", state.id));
618 state.save()?;
619 Ok(Self {
620 state,
621 roles,
622 sem: Arc::new(Semaphore::new(max_parallel)),
623 pause: Pause::new(),
624 interrupt: Pause::new(),
625 })
626 }
627
628 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
642 Self::review_taking_over(repo, branch, config, None, origin).await
643 }
644
645 pub async fn review_taking_over(
652 repo: &Path,
653 branch: &str,
654 config: Config,
655 takeover: Option<crate::handover::Takeover>,
656 origin: Origin,
657 ) -> Result<Self> {
658 let repo = git::toplevel(repo).await?;
659 let missing = agent::missing_programs(&config.agents);
660 if !missing.is_empty() {
661 bail!(
662 "these agent programs are not on PATH: {}. Fix the roster in \
663 magi.toml or install them.",
664 missing.join(", ")
665 );
666 }
667 let base_branch =
668 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
669 .await?;
670 if base_branch == branch {
671 bail!("`{branch}` is the base branch; there is nothing to review against");
672 }
673 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
674
675 let roles = config.resolve_roles()?;
676 let max_parallel = config.graph.max_parallel.max(1);
677 let mut state = RunState::new(
678 repo.clone(),
679 base_branch,
680 base_commit.clone(),
681 String::new(),
682 config,
683 );
684 state.origin = Some(origin);
685
686 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
693 earlier: Vec::new(),
694 home: crate::run::home(),
695 choice: None,
696 });
697 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
698 if let Some(released) = &released {
699 state.event(
700 "release",
701 format!(
702 "took `{branch}` over from run {}: its worktree was released: {}",
703 crate::run::short_of(&released.old_id),
704 released.audit
705 ),
706 );
707 }
708 if let Some(choice) = takeover.choice.as_ref()
712 && let Err(e) =
713 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
714 .await
715 {
716 if let Some(released) = &released {
717 released.restore(&repo, branch).await;
718 }
719 return Err(e.context("applying the owner's answer about the diverged branch"));
720 }
721 let opened =
722 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
723 if opened.is_err()
724 && let Some(released) = &released
725 {
726 released.restore(&repo, branch).await;
727 }
728 opened
729 }
730
731 async fn open_review(
734 repo: &Path,
735 branch: &str,
736 mut state: RunState,
737 roles: ResolvedRoles,
738 max_parallel: usize,
739 base_commit: String,
740 ) -> Result<Self> {
741 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
742 let start = review_base(
745 repo,
746 &state.config.merge.remote,
747 &state.base_branch,
748 &base_commit,
749 branch,
750 )
751 .await;
752 let log = git::log_oneline(repo, &start, branch)
753 .await
754 .unwrap_or_default();
755 let instruction = format!(
756 "Review the work already on branch `{branch}`. There is no task \
757 statement: what the change claims to do is whatever its commits \
758 say.\n\n{}",
759 if log.trim().is_empty() {
760 "(no commit messages)"
761 } else {
762 log.trim()
763 }
764 );
765 state.instruction = instruction;
766 state.reviewed_commits = Some(
767 git::subjects(repo, &start, branch)
768 .await
769 .unwrap_or_default(),
770 );
771
772 let worktree = state.worktree_root().join("under-review");
775 if let Some(parent) = worktree.parent() {
776 tokio::fs::create_dir_all(parent).await.ok();
777 }
778 let path = worktree.to_string_lossy().to_string();
779 git::git(repo, &["worktree", "add", &path, branch])
780 .await
781 .with_context(|| {
782 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
783 })?;
784
785 let setup = match ensure_setup_config(&mut state, repo).await {
786 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
787 Err(e) => Err(e),
788 };
789 if let Err(e) = setup {
790 git::worktree_remove(repo, &worktree).await.ok();
791 return Err(e);
792 }
793 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
794 .await
795 .unwrap_or(0);
796 if commits == 0 {
797 git::worktree_remove(repo, &worktree).await.ok();
798 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
799 }
800 let files = git::changed_files(&worktree, &base_commit, "HEAD")
801 .await
802 .map(|f| f.len())
803 .unwrap_or(0);
804 if files == 0
805 && let (Ok(head_tree), Ok(base_tree)) = (
806 git::tree_of(&worktree, "HEAD").await,
807 git::tree_of(&worktree, &base_commit).await,
808 )
809 && head_tree == base_tree
810 {
811 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
812 git::worktree_remove(repo, &worktree).await.ok();
813 bail!(
814 "`{branch}` at {} has a tree identical to base {}; this usually means \
815 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
816 against `{}/{branch}`) rather than an empty change",
817 short(&head),
818 short(&base_commit),
819 state.config.merge.remote
820 );
821 }
822 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
823 .await
824 .unwrap_or_default();
825
826 state.candidates.push(Candidate {
827 index: 0,
828 label: 'A',
829 agent: EXISTING_BRANCH.to_owned(),
832 branch: branch.to_owned(),
833 worktree,
834 summary: String::new(),
835 stat,
836 files,
837 commits,
838 empty: false,
839 failed: None,
840 verified_noop: None,
841 duration_ms: 0,
842 folded: false,
843 });
844 state.tally = Some(Tally {
845 first_choice: BTreeMap::from([('A', 0)]),
846 borda: BTreeMap::new(),
847 winner: 'A',
848 rankings: 0,
849 unanimous_initial: false,
850 deliberated: false,
851 changed_votes: 0,
852 unanimous_final: false,
853 tie_break: None,
854 judges: 0,
858 present: 0,
859 quorum: 0,
860 met_quorum: true,
861 uncontested: Some("review-only run: nothing competed".to_owned()),
862 });
863 state.status = RunStatus::Reviewing;
864 state.event(
865 "start",
866 format!(
867 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
868 state.id
869 ),
870 );
871 state.save()?;
872 Ok(Self {
873 state,
874 roles,
875 sem: Arc::new(Semaphore::new(max_parallel)),
876 pause: Pause::new(),
877 interrupt: Pause::new(),
878 })
879 }
880
881 pub fn resume(id: &str) -> Result<Self> {
883 let state = RunState::load(id)?;
884 if let Some(to) = &state.released_to {
885 bail!(
886 "run {} cannot be resumed: its worktree was released to run {}",
887 state.short(),
888 crate::run::short_of(to)
889 );
890 }
891 let roles = state.config.resolve_roles()?;
892 let max_parallel = state.config.graph.max_parallel.max(1);
893 Ok(Self {
894 state,
895 roles,
896 sem: Arc::new(Semaphore::new(max_parallel)),
897 pause: Pause::new(),
898 interrupt: Pause::new(),
899 })
900 }
901
902 pub async fn execute(&mut self) -> Result<()> {
909 let result = self.execute_graph().await;
910 self.mark_driver_exited();
911 let ended = if result.is_err() {
912 Some(crate::notices::run_stopped(&self.state.id, &self.state))
913 } else {
914 crate::notices::run_ended(&self.state)
915 };
916 if let Some(notice) = ended {
917 crate::notices::raise(notice);
918 }
919 result
920 }
921
922 fn mark_driver_exited(&mut self) {
931 self.state.driver_exited = true;
932 let pid = std::process::id();
933 let Ok(mut disk) = RunState::load(&self.state.id) else {
934 return;
935 };
936 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
937 return;
938 }
939 disk.driver_exited = true;
940 if let Err(e) = disk.save() {
941 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
942 }
943 }
944
945 async fn execute_graph(&mut self) -> Result<()> {
946 self.state.parked = false;
951 self.state.clear_active();
958 if let Ok(disk) = RunState::load(&self.state.id)
976 && let Some(to) = &disk.released_to
977 {
978 bail!(
979 "run {} cannot continue: its worktree was released to run {}",
980 self.state.short(),
981 crate::run::short_of(to)
982 );
983 }
984 let pid = std::process::id();
985 self.state.driver_pid = Some(pid);
986 self.state.driver_started_at = crate::proc::process_started_at(pid);
987 self.state.driver_exited = false;
988 self.state.save()?;
989 if self.state.status == RunStatus::Stalled {
1002 if self.recover_stall().await? {
1003 self.finish_after_tally().await?;
1004 } else {
1005 self.state.save()?;
1007 }
1008 return Ok(());
1009 }
1010 if self.state.status == RunStatus::Landing {
1020 self.run_land().await?;
1021 self.settle_questions();
1026 return Ok(());
1027 }
1028 if self.state.github_text.is_some()
1033 && self.state.merge.is_none()
1034 && self.state.status == RunStatus::Gating
1035 {
1036 self.merge().await?;
1037 self.state.save()?;
1038 return Ok(());
1039 }
1040 self.prep().await?;
1041 if self.park_here()? {
1042 return Ok(());
1043 }
1044 self.advise().await?;
1045 if self.park_here()? {
1046 return Ok(());
1047 }
1048 self.implement().await?;
1049 if self.park_here()? {
1050 return Ok(());
1051 }
1052 if self.state.status == RunStatus::VerifiedNoop {
1056 return Ok(());
1057 }
1058 self.judge().await?;
1059 if self.park_here()? {
1060 return Ok(());
1061 }
1062 self.deliberate().await?;
1063 if self.park_here()? {
1064 return Ok(());
1065 }
1066 self.vote().await?;
1067 if self.park_here()? {
1068 return Ok(());
1069 }
1070 self.tally()?;
1071 if self.state.status == RunStatus::Stalled {
1076 self.state.save()?;
1080 return Ok(());
1081 }
1082 self.finish_after_tally().await?;
1083 Ok(())
1084 }
1085
1086 fn park_here(&mut self) -> Result<bool> {
1093 if !self.pause.parked() && !self.interrupt.parked() {
1098 return Ok(false);
1099 }
1100 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1101 Some(reason) => format!(
1102 "parked after `{}` ({reason}) — resume to carry on from here",
1103 self.state.status.as_str()
1104 ),
1105 None => format!(
1106 "parked after `{}` — resume to carry on from here",
1107 self.state.status.as_str()
1108 ),
1109 };
1110 self.state.event("park", why);
1111 self.state.parked = true;
1112 self.state.save()?;
1113 Ok(true)
1114 }
1115
1116 pub fn on_pause(&mut self, pause: Pause) {
1118 self.pause = pause;
1119 }
1120
1121 pub fn watch_interrupt(&mut self, pause: Pause) {
1127 self.interrupt = pause;
1128 }
1129
1130 fn settle_questions(&mut self) {
1150 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1151 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1152 }
1153 }
1154
1155 async fn finish_after_tally(&mut self) -> Result<()> {
1158 self.fold_losers().await?;
1159 self.sync_to_base().await?;
1164 if self.state.status == RunStatus::AlreadyInBase {
1165 return Ok(());
1166 }
1167 self.review_loop().await?;
1168 self.sync_to_base().await?;
1169 if self.state.status == RunStatus::AlreadyInBase {
1170 return Ok(());
1171 }
1172 self.gate().await?;
1173 self.merge().await?;
1174 self.state.save()?;
1175 Ok(())
1176 }
1177
1178 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1185 let result = match ensure_setup_config(&mut self.state, repo).await {
1186 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1187 Err(e) => Err(e),
1188 };
1189 if let Err(e) = result {
1190 git::worktree_remove(repo, wt).await.ok();
1191 if in_prep {
1192 self.rollback_prep(repo).await;
1193 }
1194 self.state.status = RunStatus::Blocked;
1195 self.state.event(
1196 "setup",
1197 format!("worktree setup failed in {}: {e:#}", wt.display()),
1198 );
1199 self.state.save()?;
1200 return Err(e);
1201 }
1202 Ok(())
1203 }
1204
1205 async fn rollback_prep(&mut self, repo: &Path) {
1207 let root = self.state.worktree_root();
1208 for c in std::mem::take(&mut self.state.candidates) {
1209 git::worktree_remove(repo, &c.worktree).await.ok();
1210 git::branch_delete(repo, &c.branch).await.ok();
1211 }
1212 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1213 for j in 1..=seats {
1214 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1215 .await
1216 .ok();
1217 }
1218 for k in 1..=self.state.config.graph.advisors {
1219 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1220 .await
1221 .ok();
1222 }
1223 }
1224
1225 async fn prep(&mut self) -> Result<()> {
1226 if !self.state.candidates.is_empty() {
1227 return Ok(());
1228 }
1229 self.state.status = RunStatus::Prep;
1230 let repo = self.state.repo.clone();
1231 let base = self.state.base_commit.clone();
1232 let plan = refs::plan(&repo, &self.state.seeds).await?;
1233 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1234 let root = self.state.worktree_root();
1235 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1236
1237 let hooks_dir = self.state.dir().join("hooks");
1240 if self.state.config.blind.commit_msg_hook {
1241 std::fs::create_dir_all(&hooks_dir)
1242 .with_context(|| format!("create {}", hooks_dir.display()))?;
1243 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1244 let path = hooks_dir.join("commit-msg");
1245 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1246 make_executable(&path)?;
1247 git::acquire_worktree_config(&repo).await?;
1255 self.state.enabled_worktree_config = true;
1256 }
1257
1258 ensure_setup_config(&mut self.state, &repo).await?;
1261
1262 for (index, (spec, label)) in self
1263 .roles
1264 .implementers
1265 .clone()
1266 .into_iter()
1267 .zip(labels)
1268 .enumerate()
1269 {
1270 let branch = self.state.branch_for(label);
1271 let worktree = root.join(format!("cand-{label}"));
1272 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1273 if self.state.config.blind.commit_msg_hook {
1274 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1275 }
1276 git::local_exclude(&worktree, "/.magi/").await?;
1277 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1278 git::worktree_remove(&repo, &worktree).await.ok();
1281 git::branch_delete(&repo, &branch).await.ok();
1282 self.rollback_prep(&repo).await;
1283 self.state.status = RunStatus::Blocked;
1284 self.state
1285 .event("prep", format!("worktree setup failed: {e:#}"));
1286 self.state.save()?;
1287 return Err(e);
1288 }
1289 for pick in &plan.picks {
1290 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1291 self.state.status = RunStatus::Blocked;
1292 self.state
1293 .event("prep", format!("cannot apply referenced commit: {e}"));
1294 self.state.save()?;
1295 return Err(e);
1296 }
1297 }
1298 self.state.candidates.push(Candidate {
1299 index,
1300 label,
1301 agent: spec.id.clone(),
1302 branch,
1303 worktree,
1304 summary: String::new(),
1305 stat: String::new(),
1306 files: 0,
1307 commits: 0,
1308 empty: false,
1309 failed: None,
1310 verified_noop: None,
1311 duration_ms: 0,
1312 folded: false,
1313 });
1314 }
1315
1316 for j in 1..=self.roles.judges.len() {
1317 let wt = root.join(format!("judge-{j}"));
1318 if !wt.exists() {
1319 git::worktree_add_detached(&repo, &wt, &base).await?;
1320 self.setup_seat_worktree(&repo, &wt, true).await?;
1321 }
1322 }
1323
1324 if self.state.config.graph.advise {
1332 for k in 1..=self.state.config.graph.advisors {
1333 let wt = root.join(format!("advisor-{k}"));
1334 if !wt.exists() {
1335 git::worktree_add_detached(&repo, &wt, &base).await?;
1336 self.setup_seat_worktree(&repo, &wt, true).await?;
1337 }
1338 }
1339 }
1340
1341 let authors: Vec<&str> = self
1346 .roles
1347 .implementers
1348 .iter()
1349 .map(|a| a.id.as_str())
1350 .collect();
1351 let overlap: Vec<String> = self
1352 .roles
1353 .judges
1354 .iter()
1355 .enumerate()
1356 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1357 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1358 .collect();
1359 if !overlap.is_empty() {
1360 let note = format!(
1361 "{} also authored a candidate; blind, but the panel is less \
1362 independent than {} distinct agents would be",
1363 overlap.join(", "),
1364 self.roles.judges.len()
1365 );
1366 self.state.event("prep", note);
1367 }
1368
1369 self.state.event(
1370 "prep",
1371 format!(
1372 "{} candidates, {} judges, base {} ({})",
1373 self.state.candidates.len(),
1374 self.roles.judges.len(),
1375 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1376 self.state.base_branch
1377 ),
1378 );
1379 self.state.status = RunStatus::Implementing;
1380 self.state.save()?;
1381 Ok(())
1382 }
1383
1384 async fn advise(&mut self) -> Result<()> {
1417 let implement_untouched = self
1418 .state
1419 .candidates
1420 .iter()
1421 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1422 if !self.state.config.graph.advise || self.state.advise_attempted {
1423 return Ok(());
1424 }
1425 if !implement_untouched {
1426 self.state.event(
1427 "advise",
1428 "skipping the design-deliberation stage: at least one \
1429 candidate already shows implementation progress, so this \
1430 run is past the point the stage exists to run before"
1431 .to_owned(),
1432 );
1433 self.state.advise_attempted = true;
1434 self.state.save()?;
1435 return Ok(());
1436 }
1437 let run_id = self.state.id.clone();
1438 let prompts = self.state.config.prompts.clone();
1439 let instruction = self.state.instruction.clone();
1440 let language = self.state.config.graph.language.clone();
1441 let root = self.state.worktree_root();
1442 let n = self.state.config.graph.advisors;
1443 let where_recorded = self.state.dir().join("run.json");
1444
1445 let seats = match self.state.config.advisors() {
1446 Ok(seats) if !seats.is_empty() => seats,
1447 Ok(_) => {
1448 self.state.event(
1449 "advise",
1450 format!(
1451 "[graph] advisors is 0; skipping the design-deliberation \
1452 stage and continuing without a synthesis brief (see {})",
1453 where_recorded.display()
1454 ),
1455 );
1456 self.state.advise_attempted = true;
1457 self.state.save()?;
1458 return Ok(());
1459 }
1460 Err(e) => {
1461 self.state.event(
1462 "advise",
1463 format!(
1464 "could not resolve advisor seats ({e:#}); continuing \
1465 without a design-deliberation brief (see {})",
1466 where_recorded.display()
1467 ),
1468 );
1469 self.state.advise_attempted = true;
1470 self.state.save()?;
1471 return Ok(());
1472 }
1473 };
1474
1475 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1476 let artifacts = agent::artifacts_dir(&self.state.dir());
1477 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1478
1479 let mut jobs = Vec::new();
1480 for (i, spec) in seats.iter().cloned().enumerate() {
1481 let seat_key = format!("advisor-{}", i + 1);
1482 let seat = self.seat(&seat_key, &spec.id);
1483 jobs.push(SeatJob {
1484 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1485 spec,
1486 seat,
1487 cwd: worktrees[i % worktrees.len()].clone(),
1488 timeout,
1489 allow_write: false,
1490 sessions: false,
1491 artifacts: artifacts.clone(),
1492 stem: seat_key,
1493 handover: None,
1494 });
1495 }
1496
1497 self.state.event(
1498 "advise",
1499 format!(
1500 "{} advisor seat(s) sketching a design in parallel",
1501 jobs.len()
1502 ),
1503 );
1504 let mut quota_losses = Vec::new();
1505 let cache = self.state.config.cache_dir();
1506 let ctx = WaveCtx {
1507 carry_seats: false,
1508 run: &run_id,
1509 node: "advise",
1510 prompts: &prompts,
1511 cache: cache.as_deref(),
1512 round: None,
1513 };
1514 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1515 let results = ask_json_wave::<Proposal>(
1516 jobs,
1517 Arc::clone(&self.sem),
1518 self.state.config.graph.retries,
1519 &advisor_roster,
1520 &ctx,
1521 &mut quota_losses,
1522 &mut self.state,
1523 &|p: &Proposal| p.validate(),
1524 )
1525 .await;
1526 self.state.quota.extend(quota_losses);
1527
1528 let mut records = Vec::with_capacity(results.len());
1529 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1530 let agent_id = seat.agent.clone();
1531 self.state.seats.insert(seat.key.clone(), seat);
1532 match res {
1533 Ok((proposal, out)) => {
1534 self.state
1535 .event("advise", format!("advisor-{} proposed a design", i + 1));
1536 records.push(advise::AdvisorRecord::proposed(
1537 i + 1,
1538 agent_id,
1539 proposal,
1540 out.duration_ms,
1541 ));
1542 }
1543 Err(e) => {
1544 self.state.event(
1545 "advise",
1546 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1547 );
1548 records.push(advise::AdvisorRecord::failed(
1549 i + 1,
1550 agent_id,
1551 e.to_string(),
1552 ));
1553 }
1554 }
1555 }
1556
1557 let mut advice = advise::Advice {
1558 records,
1559 synthesis: None,
1560 };
1561 if advice.proposals().is_empty() {
1562 self.state.event(
1563 "advise",
1564 "no advisor produced a usable proposal; continuing without a \
1565 synthesis brief"
1566 .to_owned(),
1567 );
1568 } else {
1569 match self
1570 .synthesize_brief(
1571 &advice,
1572 &instruction,
1573 &language,
1574 &worktrees[0],
1575 &artifacts,
1576 &run_id,
1577 &prompts,
1578 cache.as_deref(),
1579 )
1580 .await
1581 {
1582 Ok(Some(text)) => {
1583 self.state.event(
1584 "advise",
1585 "synthesized a design brief for the implementer".to_owned(),
1586 );
1587 advice.synthesis = Some(text);
1588 }
1589 Ok(None) => {
1590 self.state.event(
1591 "advise",
1592 "the synthesis seat produced nothing usable; continuing \
1593 without a design brief"
1594 .to_owned(),
1595 );
1596 }
1597 Err(e) => {
1598 self.state.event(
1599 "advise",
1600 format!("could not synthesize a design brief: {e:#}"),
1601 );
1602 }
1603 }
1604 }
1605 advise::apply_reflection(&mut advice);
1606
1607 self.state.advice = Some(advice);
1608 self.state.advise_attempted = true;
1609 self.state.save()?;
1610 Ok(())
1611 }
1612
1613 #[allow(clippy::too_many_arguments)]
1625 async fn synthesize_brief(
1626 &mut self,
1627 advice: &advise::Advice,
1628 instruction: &str,
1629 language: &str,
1630 cwd: &Path,
1631 artifacts: &Path,
1632 run_id: &str,
1633 prompts: &Prompts,
1634 cache: Option<&Path>,
1635 ) -> Result<Option<String>> {
1636 let chain = agent::pick_chain(
1637 &self.state.config.agents,
1638 self.state.config.roles.synthesizer.as_ref(),
1639 &agent::installed,
1640 "synthesizer",
1641 )?;
1642 let proposals = advice.proposals();
1643 let mut prompt = prompt::with_overlay(
1644 prompt::synthesize_brief(instruction, &proposals, language),
1645 prompts.overlay("advise"),
1646 );
1647 if cache.is_some() {
1648 prompt.push('\n');
1653 prompt.push_str(&prompt::build_cache_note("advise", false));
1654 }
1655 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1656 let mut last = None;
1661 for (n, spec) in chain.iter().enumerate() {
1662 if n > 0 {
1663 self.state
1664 .event("advise", format!("synthesis falling back to {}", spec.id));
1665 }
1666 let mut seat = self.seat("advise-synthesis", &spec.id);
1667 let outcome = agent::invoke(
1668 spec,
1669 &mut seat,
1670 &Invocation {
1671 cwd,
1672 prompt: &prompt,
1673 timeout,
1674 allow_write: false,
1675 unsandboxed: false,
1676 sessions: false,
1677 artifacts,
1678 stem: &if n == 0 {
1679 "advise-synthesis".to_owned()
1680 } else {
1681 format!("advise-synthesis-{}", spec.id)
1682 },
1683 run: run_id,
1684 node: "advise",
1685 cache_dir: None,
1686 attachments: &[],
1687 writable: &[],
1688 },
1689 )
1690 .await;
1691 if outcome.is_ok() {
1692 self.state.seats.insert(seat.key.clone(), seat);
1693 }
1694 let advance = agent::chain_advances(&outcome);
1695 last = Some(outcome);
1696 if !advance {
1697 break;
1698 }
1699 }
1700 let out = last.expect("a chain holds at least one agent")?;
1703 if !out.usable() {
1704 return Ok(None);
1705 }
1706 let text =
1707 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1708 Ok((!text.trim().is_empty()).then_some(text))
1709 }
1710
1711 async fn implement(&mut self) -> Result<()> {
1714 let run_id = self.state.id.clone();
1719 let prompts = self.state.config.prompts.clone();
1720 let todo: Vec<usize> = self
1721 .state
1722 .candidates
1723 .iter()
1724 .enumerate()
1725 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1726 .map(|(i, _)| i)
1727 .collect();
1728 if todo.is_empty() {
1729 return self.after_implement();
1730 }
1731 self.state.status = RunStatus::Implementing;
1732
1733 let language = self.state.config.graph.language.clone();
1734 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1735 let sessions = self.state.config.graph.sessions;
1736 let artifacts = agent::artifacts_dir(&self.state.dir());
1737 let brief = self
1741 .state
1742 .advice
1743 .as_ref()
1744 .and_then(|a| a.synthesis.as_deref())
1745 .map(str::to_owned);
1746 let attachments = self.state.attachments.clone();
1747
1748 let mut jobs = Vec::new();
1749 for &i in &todo {
1750 let (index, label, worktree) = {
1751 let c = &self.state.candidates[i];
1752 (c.index, c.label, c.worktree.clone())
1753 };
1754 let spec = self.roles.implementers[index].clone();
1755 let seat_key = format!("impl-{label}");
1756 let seat = self.seat(&seat_key, &spec.id);
1757 let instruction = seeded_instruction(&self.state);
1758 jobs.push(SeatJob {
1759 spec,
1760 seat,
1761 prompt: prompt::implement(
1762 &instruction,
1763 &worktree.to_string_lossy(),
1764 &language,
1765 brief.as_deref(),
1766 &attachments,
1767 ),
1768 cwd: worktree,
1769 timeout,
1770 allow_write: true,
1771 sessions,
1772 artifacts: artifacts.clone(),
1773 stem: format!("impl-{label}"),
1774 handover: None,
1775 });
1776 }
1777
1778 self.state.event(
1779 "implement",
1780 format!("{} candidates in parallel", jobs.len()),
1781 );
1782 let mut sent = jobs.clone();
1788 let cache = self.state.config.cache_dir();
1789 let ctx = WaveCtx {
1790 carry_seats: false,
1791 run: &run_id,
1792 node: "implement",
1793 prompts: &prompts,
1794 cache: cache.as_deref(),
1795 round: None,
1796 };
1797 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1798 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1799 .await;
1800 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1801 .await;
1802 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1803 .await;
1804
1805 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1806 let seat_key = seat.key.clone();
1807 let agent = seat.agent.clone();
1816 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1817 self.state.seats.insert(seat.key.clone(), seat);
1818 let label = self.state.candidates[i].label;
1819 let worktree = self.state.candidates[i].worktree.clone();
1820 let base = self.state.base_commit.clone();
1821
1822 let (summary, duration, failed, verified_claim) = match out {
1823 AgentOutcome::Ok(o) => {
1824 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1825 let failed = (!o.usable()).then(|| {
1826 if o.timed_out {
1827 "agent timed out".to_owned()
1828 } else {
1829 format!("agent exited with {:?}", o.exit_code)
1830 }
1831 });
1832 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1833 (text, o.duration_ms, failed, verified_claim)
1834 }
1835 AgentOutcome::Dropped(o) => {
1841 let why = o
1842 .dropped
1843 .as_ref()
1844 .map(|d| d.why.as_str())
1845 .unwrap_or("the CLI ended the stream without delivering its answer");
1846 (
1847 String::new(),
1848 o.duration_ms,
1849 Some(format!("the CLI dropped the stream ({why})")),
1850 None,
1851 )
1852 }
1853 AgentOutcome::Quota(o) => {
1854 self.state.quota.push(QuotaLoss {
1855 seat: seat_key,
1856 node: "implement".to_owned(),
1857 at: Timestamp::now(),
1858 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1859 });
1860 (
1861 String::new(),
1862 o.duration_ms,
1863 Some("rate limited (quota); produced no change".to_owned()),
1864 None,
1865 )
1866 }
1867 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1868 };
1869
1870 let rescued = match git::rescue_commit(
1873 &worktree,
1874 &format!("magi: candidate {label} (uncommitted work)"),
1875 )
1876 .await
1877 {
1878 Ok(r) => {
1879 self.state.note_withheld("implement", &r.withheld);
1880 r.committed
1881 }
1882 Err(_) => false,
1883 };
1884 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1885 .await
1886 .unwrap_or(0);
1887 let patch = git::diff(&worktree, &base, "HEAD")
1888 .await
1889 .unwrap_or_default();
1890 let stat = git::diff_stat(&worktree, &base, "HEAD")
1891 .await
1892 .unwrap_or_default();
1893 let files = git::changed_files(&worktree, &base, "HEAD")
1894 .await
1895 .map(|f| f.len())
1896 .unwrap_or(0);
1897 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1898
1899 let c = &mut self.state.candidates[i];
1900 if !exhausted_the_fallback_chain {
1901 c.agent = agent;
1902 }
1903 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1904 c.stat = stat;
1905 c.files = files;
1906 c.commits = commits;
1907 c.duration_ms = duration;
1908 c.empty = commits == 0 || patch.trim().is_empty();
1909 c.failed = match failed {
1912 Some(_) if c.empty => failed,
1913 _ => None,
1914 };
1915 c.verified_noop = if c.empty { verified_claim } else { None };
1920 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1921 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1922 (None, true, Some(_), _) => {
1923 format!("candidate {label}: no change produced (agent-verified no-op)")
1924 }
1925 (None, true, None, _) => format!("candidate {label}: no change produced"),
1926 (None, false, _, true) => {
1927 format!(
1928 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1929 )
1930 }
1931 (None, false, _, false) => {
1932 format!("candidate {label}: {files} files, {commits} commits")
1933 }
1934 };
1935 self.state.event("implement", note);
1936 self.state.save()?;
1937 }
1938
1939 self.after_implement()
1940 }
1941
1942 async fn resume_undelivered(
1970 &mut self,
1971 results: &mut [(usize, SeatState, AgentOutcome)],
1972 sent: &[SeatJob],
1973 prompts: &Prompts,
1974 run_id: &str,
1975 ) {
1976 for (wi, seat, out) in results.iter_mut() {
1977 let Some(dropped) = (match &*out {
1978 AgentOutcome::Dropped(o) => o.dropped.clone(),
1979 _ => None,
1980 }) else {
1981 continue;
1982 };
1983 let Some(job) = sent.get(*wi) else { continue };
1984 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1986 self.state.event(
1987 "implement",
1988 format!(
1989 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1990 work is in the tree",
1991 seat.key, dropped.output_tokens, dropped.why
1992 ),
1993 );
1994 continue;
1995 }
1996 if !has_context(&job.spec, seat, job.sessions) {
2004 self.state.event(
2005 "implement",
2006 format!(
2007 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2008 is no session left to resume",
2009 seat.key, dropped.output_tokens, dropped.why
2010 ),
2011 );
2012 continue;
2013 }
2014 self.state.event(
2015 "implement",
2016 format!(
2017 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2018 conversation",
2019 seat.key, dropped.output_tokens, dropped.why
2020 ),
2021 );
2022 let mut retry = job.clone();
2023 retry.seat = seat.clone();
2024 retry.prompt = prompt::resume_after_drop(&dropped.why);
2025 retry.timeout = retry_budget(job.timeout, true);
2026 retry.stem = format!("{}-resume", job.stem);
2027 let cache = self.state.config.cache_dir();
2028 let ctx = WaveCtx {
2029 carry_seats: false,
2030 run: run_id,
2031 node: "implement",
2032 prompts,
2033 cache: cache.as_deref(),
2034 round: None,
2035 };
2036 let (resumed_seat, resumed) =
2037 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2038 *seat = resumed_seat;
2039 *out = resumed;
2040 }
2041 }
2042
2043 async fn resume_seat_handovers(
2108 &mut self,
2109 results: &mut [(usize, SeatState, AgentOutcome)],
2110 sent: &mut [SeatJob],
2111 prompts: &Prompts,
2112 run_id: &str,
2113 ) {
2114 let instruction = seeded_instruction(&self.state);
2115 let language = self.state.config.graph.language.clone();
2116 let brief = self
2117 .state
2118 .advice
2119 .as_ref()
2120 .and_then(|a| a.synthesis.as_deref())
2121 .map(str::to_owned);
2122 let attachments = self.state.attachments.clone();
2123 for (wi, seat, out) in results.iter_mut() {
2124 let others: BTreeSet<String> = sent
2127 .iter()
2128 .enumerate()
2129 .filter(|(j, _)| j != wi)
2130 .map(|(_, j)| j.spec.id.clone())
2131 .collect();
2132 let Some(job) = sent.get_mut(*wi) else {
2133 continue;
2134 };
2135 let start = self
2140 .roles
2141 .implementer_roster
2142 .iter()
2143 .position(|s| s.id == job.spec.id)
2144 .unwrap_or(0);
2145 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2146 let mut fallback_attempt = 0usize;
2147 let mut prev: Option<FailClass> = None;
2148 while let Some(cur) = FailClass::of(&*out) {
2149 if !should_hand_over(prev.as_ref(), &cur) {
2150 break;
2151 }
2152 let Some(next) =
2153 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2154 .cloned()
2155 else {
2156 break;
2157 };
2158 tried.insert(next.id.clone());
2159 fallback_attempt += 1;
2160
2161 if let Ok(r) = git::rescue_commit(
2162 &job.cwd,
2163 &format!(
2164 "magi: candidate {} (uncommitted work before {} fallback)",
2165 seat.key,
2166 if cur == FailClass::Quota {
2167 "quota"
2168 } else {
2169 "handover"
2170 }
2171 ),
2172 )
2173 .await
2174 {
2175 self.state.note_withheld("implement", &r.withheld);
2176 }
2177
2178 record_handover(
2179 &mut self.state,
2180 "implement",
2181 &seat.key,
2182 &seat.agent,
2183 &next.id,
2184 &cur,
2185 &fail_reason(&*out),
2186 );
2187 prev = Some(cur.clone());
2188
2189 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2190 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2191 job.spec = next.clone();
2199 let mut retry = job.clone();
2200 retry.seat = new_seat;
2201 retry.prompt = prompt::implement(
2202 &instruction,
2203 &job.cwd.to_string_lossy(),
2204 &language,
2205 brief.as_deref(),
2206 &attachments,
2207 );
2208 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2209 let cache = self.state.config.cache_dir();
2210 let ctx = WaveCtx {
2211 carry_seats: false,
2212 run: run_id,
2213 node: "implement",
2214 prompts,
2215 cache: cache.as_deref(),
2216 round: None,
2217 };
2218 let (fallback_seat, fallback_out) = run_one(
2219 retry,
2220 Arc::clone(&self.sem),
2221 &ctx,
2222 &mut self.state,
2223 fallback_attempt,
2224 )
2225 .await;
2226 *seat = fallback_seat;
2227 *out = fallback_out;
2228 }
2229 }
2230 }
2231
2232 async fn resume_unconfirmed_commands(
2256 &mut self,
2257 results: &mut [(usize, SeatState, AgentOutcome)],
2258 sent: &[SeatJob],
2259 prompts: &Prompts,
2260 run_id: &str,
2261 ) {
2262 for (wi, seat, out) in results.iter_mut() {
2263 let AgentOutcome::Ok(o) = &*out else {
2264 continue;
2265 };
2266 if !has_unconfirmed_command(&o.commands) {
2267 continue;
2268 }
2269 let Some(job) = sent.get(*wi) else { continue };
2270 if !has_context(&job.spec, seat, job.sessions) {
2271 self.state.event(
2272 "implement",
2273 format!(
2274 "{}: the reply named a command whose own CLI never confirmed the exit \
2275 status of, but there is no session left to resume",
2276 seat.key
2277 ),
2278 );
2279 continue;
2280 }
2281 self.state.event(
2282 "implement",
2283 format!(
2284 "{}: the reply named a command whose own CLI never confirmed the exit \
2285 status of; resuming the conversation",
2286 seat.key
2287 ),
2288 );
2289 let mut retry = job.clone();
2290 retry.seat = seat.clone();
2291 retry.prompt = prompt::resume_incomplete(
2292 "a command in your last reply had no confirmed exit status",
2293 );
2294 retry.timeout = retry_budget(job.timeout, true);
2295 retry.stem = format!("{}-confirm", job.stem);
2296 let cache = self.state.config.cache_dir();
2297 let ctx = WaveCtx {
2298 carry_seats: false,
2299 run: run_id,
2300 node: "implement",
2301 prompts,
2302 cache: cache.as_deref(),
2303 round: None,
2304 };
2305 let (resumed_seat, resumed) =
2306 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2307 *seat = resumed_seat;
2308 *out = resumed;
2309 }
2310 }
2311
2312 async fn continue_fix_report(
2333 &mut self,
2334 mut seat: SeatState,
2335 parse_err: String,
2336 job: &SeatJob,
2337 prompts: &Prompts,
2338 run_id: &str,
2339 round: usize,
2340 ) -> (
2341 SeatState,
2342 Option<FixReport>,
2343 Option<String>,
2344 ContinuationRecord,
2345 ) {
2346 let mut last_err = parse_err;
2347 let mut cumulative_wait_ms = 0u64;
2348 let mut attempts = 0usize;
2349 loop {
2350 if !has_context(&job.spec, &seat, job.sessions) {
2351 self.state.event(
2352 "fix",
2353 format!(
2354 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2355 session left to resume into"
2356 ),
2357 );
2358 let outcome = if attempts == 0 {
2359 ContinuationOutcome::NoSession
2360 } else {
2361 ContinuationOutcome::Exhausted
2362 };
2363 return (
2364 seat,
2365 None,
2366 Some(format!("unparsable fix report: {last_err}")),
2367 ContinuationRecord {
2368 attempts,
2369 cumulative_wait_ms,
2370 outcome,
2371 },
2372 );
2373 }
2374 if attempts >= MAX_FIX_CONTINUATIONS {
2375 self.state.event(
2376 "fix",
2377 format!(
2378 "round {round}: fixer's reply still had no adoption report after \
2379 {attempts} continuation(s) ({last_err}); giving up"
2380 ),
2381 );
2382 return (
2383 seat,
2384 None,
2385 Some(format!(
2386 "unparsable fix report after {attempts} continuation(s): {last_err}"
2387 )),
2388 ContinuationRecord {
2389 attempts,
2390 cumulative_wait_ms,
2391 outcome: ContinuationOutcome::Exhausted,
2392 },
2393 );
2394 }
2395 attempts += 1;
2396 self.state.event(
2397 "fix",
2398 format!(
2399 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2400 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2401 ),
2402 );
2403 let mut retry = job.clone();
2404 retry.seat = seat.clone();
2405 retry.prompt = prompt::resume_incomplete(&last_err);
2406 retry.timeout = retry_budget(job.timeout, true);
2407 retry.stem = format!("{}-continue{attempts}", job.stem);
2408 let cache = self.state.config.cache_dir();
2409 let ctx = WaveCtx {
2410 carry_seats: false,
2411 run: run_id,
2412 node: "fix",
2413 prompts,
2414 cache: cache.as_deref(),
2415 round: Some(round),
2416 };
2417 let (resumed_seat, resumed_out) = run_one(
2418 retry,
2419 Arc::clone(&self.sem),
2420 &ctx,
2421 &mut self.state,
2422 attempts,
2423 )
2424 .await;
2425 seat = resumed_seat;
2426 match resumed_out {
2427 AgentOutcome::Ok(o) => {
2428 cumulative_wait_ms += o.duration_ms;
2429 match verdict::extract_json::<FixReport>(&o.text) {
2430 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2431 self.state.event(
2432 "fix",
2433 format!(
2434 "round {round}: fixer's adoption report recovered after \
2435 {attempts} continuation(s)"
2436 ),
2437 );
2438 return (
2439 seat,
2440 Some(report),
2441 None,
2442 ContinuationRecord {
2443 attempts,
2444 cumulative_wait_ms,
2445 outcome: ContinuationOutcome::Resumed,
2446 },
2447 );
2448 }
2449 Ok(_) => {
2457 last_err = "the reply parsed, but it reported a command whose own CLI \
2458 never confirmed an exit status"
2459 .to_owned();
2460 }
2461 Err(e) => last_err = e.to_string(),
2462 }
2463 }
2464 AgentOutcome::Quota(o) => {
2465 cumulative_wait_ms += o.duration_ms;
2466 self.state.quota.push(QuotaLoss {
2467 seat: seat.key.clone(),
2468 node: "fix".to_owned(),
2469 at: Timestamp::now(),
2470 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2471 });
2472 self.state.event(
2473 "fix",
2474 format!(
2475 "round {round}: continuation rate limited (quota); not retrying now"
2476 ),
2477 );
2478 return (
2479 seat,
2480 None,
2481 Some("rate limited (quota) while recovering the fix report".to_owned()),
2482 ContinuationRecord {
2483 attempts,
2484 cumulative_wait_ms,
2485 outcome: ContinuationOutcome::QuotaLost,
2486 },
2487 );
2488 }
2489 AgentOutcome::Dropped(o) => {
2490 cumulative_wait_ms += o.duration_ms;
2491 let why = o
2492 .dropped
2493 .as_ref()
2494 .map(|d| d.why.as_str())
2495 .unwrap_or("the CLI ended the stream without delivering its answer");
2496 last_err = format!("the CLI dropped the stream ({why})");
2497 }
2498 AgentOutcome::Failed(e) => last_err = e,
2499 }
2500 }
2501 }
2502
2503 fn after_implement(&mut self) -> Result<()> {
2504 if self.state.leaks.is_empty() {
2506 let cfg = self.state.config.blind.clone();
2507 let mut leaks = Vec::new();
2508 for c in &self.state.candidates {
2509 let Some(patch) =
2510 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2511 else {
2512 continue;
2513 };
2514 leaks.extend(blind::scan(
2515 &format!("candidate {} patch", c.label),
2516 &patch,
2517 &cfg.vendor_tokens,
2518 ));
2519 }
2520 if !leaks.is_empty() {
2521 let summary = leaks
2522 .iter()
2523 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2524 .collect::<Vec<_>>()
2525 .join(", ");
2526 match cfg.on_leak {
2527 LeakPolicy::Fail => {
2528 self.state.status = RunStatus::Failed;
2529 self.state
2530 .event("blind", format!("vendor text in a patch: {summary}"));
2531 self.state.leaks = leaks;
2532 self.state.save()?;
2533 self.settle_questions();
2534 bail!(
2535 "blind.on_leak = \"fail\" and vendor text reached a \
2536 judged patch: {summary}"
2537 );
2538 }
2539 LeakPolicy::Redact => self.state.event(
2540 "blind",
2541 format!("redacting vendor text for judging: {summary}"),
2542 ),
2543 LeakPolicy::Warn => self.state.event(
2544 "blind",
2545 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2546 ),
2547 }
2548 self.state.leaks = leaks;
2549 }
2550 }
2551
2552 if self.state.viable().is_empty() {
2553 if self.state.all_candidates_verified_noop() {
2554 self.state.status = RunStatus::VerifiedNoop;
2565 self.state.save()?;
2566 self.settle_questions();
2567 return Ok(());
2568 }
2569 self.state.status = RunStatus::Failed;
2570 self.state.save()?;
2571 self.settle_questions();
2572 bail!("no candidate produced a change; nothing to judge");
2573 }
2574 self.state.status = RunStatus::Judging;
2575 self.state.save()?;
2576 Ok(())
2577 }
2578
2579 async fn judge(&mut self) -> Result<()> {
2582 let run_id = self.state.id.clone();
2587 let prompts = self.state.config.prompts.clone();
2588 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2589 return Ok(());
2590 }
2591 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2592 if viable.len() == 1 {
2593 self.state.judge_skipped = true;
2600 self.state.event(
2601 "judge",
2602 format!(
2603 "only candidate {} produced a change; judging skipped",
2604 viable[0].label
2605 ),
2606 );
2607 self.state.save()?;
2608 return Ok(());
2609 }
2610 self.state.status = RunStatus::Judging;
2611
2612 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2613 let language = self.state.config.graph.language.clone();
2614 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2615 let sessions = self.state.config.graph.sessions;
2616 let artifacts = agent::artifacts_dir(&self.state.dir());
2617 let root = self.state.worktree_root();
2618 let base_short = short(&self.state.base_commit);
2619
2620 let mut jobs = Vec::new();
2621 let mut orders = Vec::new();
2622 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2623 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2624 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2625 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2626 let seat_key = format!("judge-{}", j + 1);
2627 let seat = self.seat(&seat_key, &spec.id);
2628 jobs.push(SeatJob {
2629 prompt: prompt::judge(
2630 &self.state.instruction,
2631 &views,
2632 self.roles.judges.len(),
2633 &base_short,
2634 &language,
2635 ),
2636 spec,
2637 seat,
2638 cwd: root.join(format!("judge-{}", j + 1)),
2639 timeout,
2640 allow_write: false,
2641 sessions,
2642 artifacts: artifacts.clone(),
2643 stem: format!("judge-{}", j + 1),
2644 handover: None,
2645 });
2646 }
2647
2648 self.state.event(
2649 "judge",
2650 format!(
2651 "{} judges ranking {} candidates blind",
2652 jobs.len(),
2653 viable.len()
2654 ),
2655 );
2656 let labels_for_check = labels.clone();
2657 let mut quota_losses = Vec::new();
2658 let cache = self.state.config.cache_dir();
2659 let ctx = WaveCtx {
2660 carry_seats: false,
2661 run: &run_id,
2662 node: "judge",
2663 prompts: &prompts,
2664 cache: cache.as_deref(),
2665 round: None,
2666 };
2667 let results = ask_json_wave::<Ranking>(
2668 jobs,
2669 Arc::clone(&self.sem),
2670 self.state.config.graph.retries,
2671 &self.roles.judge_roster,
2672 &ctx,
2673 &mut quota_losses,
2674 &mut self.state,
2675 &move |r: &Ranking| r.validate(&labels_for_check),
2676 )
2677 .await;
2678 self.state.quota.extend(quota_losses);
2679
2680 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2681 let agent_id = seat.agent.clone();
2682 self.state.seats.insert(seat.key.clone(), seat);
2683 let mut record = Judgement {
2684 judge: j + 1,
2685 seat: format!("judge-{}", j + 1),
2686 agent: agent_id,
2687 ranking: Vec::new(),
2688 reasons: BTreeMap::new(),
2689 confidence: None,
2690 order: orders[j].clone(),
2691 failed: None,
2692 duration_ms: 0,
2693 };
2694 match res {
2695 Ok((ranking, out)) => {
2696 record.ranking = ranking.normalized();
2697 record.reasons = ranking.reasons;
2698 record.confidence = ranking.confidence;
2699 record.duration_ms = out.duration_ms;
2700 self.state.event(
2701 "judge",
2702 format!(
2703 "judge {} ranked {}",
2704 j + 1,
2705 record.ranking.iter().collect::<String>()
2706 ),
2707 );
2708 }
2709 Err(e) => {
2710 record.failed = Some(e.to_string());
2711 self.state
2712 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2713 }
2714 }
2715 self.state.judgements.push(record);
2716 self.state.save()?;
2717 }
2718 Ok(())
2719 }
2720
2721 async fn deliberate(&mut self) -> Result<()> {
2724 let run_id = self.state.id.clone();
2729 let prompts = self.state.config.prompts.clone();
2730 if !self.state.deliberation.is_empty() {
2731 return Ok(());
2732 }
2733 let tops: Vec<char> = self
2734 .state
2735 .judgements
2736 .iter()
2737 .filter_map(|j| j.ranking.first().copied())
2738 .collect();
2739 let rounds = self.state.config.graph.deliberate_rounds;
2740 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2741 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2742 self.state.event(
2743 "deliberate",
2744 format!("judges agreed on {} outright; no deliberation", tops[0]),
2745 );
2746 }
2747 self.state.status = RunStatus::Voting;
2748 self.state.save()?;
2749 return Ok(());
2750 }
2751
2752 self.state.status = RunStatus::Deliberating;
2753 self.state.event(
2754 "deliberate",
2755 format!(
2756 "split: first choices were {} — opening {rounds} round(s)",
2757 tops.iter().collect::<String>()
2758 ),
2759 );
2760
2761 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2762 let language = self.state.config.graph.language.clone();
2763 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2764 let sessions = self.state.config.graph.sessions;
2765 let artifacts = agent::artifacts_dir(&self.state.dir());
2766 let root = self.state.worktree_root();
2767 let base_short = short(&self.state.base_commit);
2768
2769 for round in 1..=rounds {
2773 let mut turns: Vec<DeliberationTurn> = Vec::new();
2774 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2775 if self.state.judgements[j].failed.is_some() {
2776 continue;
2777 }
2778 let seat_key = format!("judge-{}", j + 1);
2779 let spec = self.occupant(&seat_key, spec);
2780 let mut seat = self.seat(&seat_key, &spec.id);
2781 let transcript = self.transcript(&turns, j);
2782 let build = |context: Option<&str>| {
2783 prompt::deliberate(
2784 &self.state.instruction,
2785 context,
2786 &transcript,
2787 round,
2788 rounds,
2789 &language,
2790 )
2791 };
2792 let block = self.candidate_block(&viable, &base_short);
2793 let full = build(Some(&block));
2794 let text = if has_context(&spec, &seat, sessions) {
2795 build(None)
2796 } else {
2797 full.clone()
2798 };
2799 let job = SeatJob {
2800 spec,
2801 seat: seat.clone(),
2802 prompt: text,
2803 cwd: root.join(format!("judge-{}", j + 1)),
2804 timeout,
2805 allow_write: false,
2806 sessions,
2807 artifacts: artifacts.clone(),
2808 stem: format!("delib-{round}-judge-{}", j + 1),
2809 handover: Some(full),
2810 };
2811 let cache = self.state.config.cache_dir();
2812 let ctx = WaveCtx {
2813 carry_seats: false,
2814 run: &run_id,
2815 node: "deliberate",
2816 prompts: &prompts,
2817 cache: cache.as_deref(),
2818 round: None,
2819 };
2820 let mut losses = Vec::new();
2824 let mut results = ask_wave_with::<String>(
2825 vec![job],
2826 Arc::clone(&self.sem),
2827 0,
2828 &self.roles.judge_roster,
2829 &ctx,
2830 &mut losses,
2831 &mut self.state,
2832 &|text: &str| {
2833 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2834 },
2835 )
2836 .await;
2837 self.state.quota.extend(losses);
2838 let (updated, res, _) = results.pop().expect("one job in, one result out");
2839 seat = updated;
2840 let agent_id = seat.agent.clone();
2841 self.state.seats.insert(seat.key.clone(), seat);
2842 let body = match res {
2843 Ok((body, _)) => body,
2844 Err(e) => {
2847 self.state
2848 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2849 continue;
2850 }
2851 };
2852 let tentative = verdict::extract_json::<Position>(&body)
2853 .ok()
2854 .and_then(|p| p.tentative)
2855 .and_then(|s| s.trim().chars().next())
2856 .map(|c| c.to_ascii_uppercase());
2857 self.state.event(
2858 "deliberate",
2859 format!(
2860 "round {round}: judge {} now favours {}",
2861 j + 1,
2862 tentative.map_or("—".to_owned(), |c| c.to_string())
2863 ),
2864 );
2865 turns.push(DeliberationTurn {
2866 judge: j + 1,
2867 agent: agent_id,
2868 body: blind::sanitize_prose(&body, &self.state.config.blind),
2869 tentative,
2870 });
2871 }
2872 self.state
2873 .deliberation
2874 .push(DeliberationRound { round, turns });
2875 self.state.save()?;
2876 }
2877
2878 self.state.status = RunStatus::Voting;
2879 self.state.save()?;
2880 Ok(())
2881 }
2882
2883 async fn vote(&mut self) -> Result<()> {
2886 let run_id = self.state.id.clone();
2891 let prompts = self.state.config.prompts.clone();
2892 if !self.state.votes.is_empty() {
2893 return Ok(());
2894 }
2895 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2896 if viable.len() == 1 {
2897 return Ok(());
2898 }
2899 self.state.status = RunStatus::Voting;
2900
2901 let language = self.state.config.graph.language.clone();
2902 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2903 let sessions = self.state.config.graph.sessions;
2904 let artifacts = agent::artifacts_dir(&self.state.dir());
2905 let root = self.state.worktree_root();
2906 let base_short = short(&self.state.base_commit);
2907 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2908
2909 let mut jobs = Vec::new();
2910 let mut seats_at = Vec::new();
2911 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2912 if self
2913 .state
2914 .judgements
2915 .get(j)
2916 .is_some_and(|r| r.failed.is_some())
2917 {
2918 continue;
2919 }
2920 let seat_key = format!("judge-{}", j + 1);
2921 let spec = self.occupant(&seat_key, spec);
2922 let seat = self.seat(&seat_key, &spec.id);
2923 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2924 let text = if has_context(&spec, &seat, sessions) {
2925 prompt::final_vote(&viable, &language)
2926 } else {
2927 full.clone()
2928 };
2929 jobs.push(SeatJob {
2930 spec,
2931 seat,
2932 prompt: text,
2933 cwd: root.join(format!("judge-{}", j + 1)),
2934 timeout,
2935 allow_write: false,
2936 sessions,
2937 artifacts: artifacts.clone(),
2938 stem: format!("vote-judge-{}", j + 1),
2939 handover: Some(full),
2940 });
2941 seats_at.push(j);
2942 }
2943
2944 self.state.event(
2945 "vote",
2946 format!(
2947 "collecting {} final votes one by one, privately",
2948 jobs.len()
2949 ),
2950 );
2951 let allowed = viable.clone();
2952 let mut quota_losses = Vec::new();
2953 let cache = self.state.config.cache_dir();
2954 let ctx = WaveCtx {
2955 carry_seats: false,
2956 run: &run_id,
2957 node: "vote",
2958 prompts: &prompts,
2959 cache: cache.as_deref(),
2960 round: None,
2961 };
2962 let results = ask_json_wave::<FinalVote>(
2963 jobs,
2964 Arc::clone(&self.sem),
2965 self.state.config.graph.retries,
2966 &self.roles.judge_roster,
2967 &ctx,
2968 &mut quota_losses,
2969 &mut self.state,
2970 &move |v: &FinalVote| match v.label() {
2971 Some(c) if allowed.contains(&c) => Ok(()),
2972 other => bail!("vote {other:?} is not one of {allowed:?}"),
2973 },
2974 )
2975 .await;
2976 self.state.quota.extend(quota_losses);
2977
2978 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2979 let agent_id = seat.agent.clone();
2980 self.state.seats.insert(seat.key.clone(), seat);
2981 let initial = self
2982 .state
2983 .judgements
2984 .get(j)
2985 .and_then(|r| r.ranking.first().copied());
2986 let mut record = VoteRecord {
2987 judge: j + 1,
2988 agent: agent_id,
2989 vote: None,
2990 reason: String::new(),
2991 changed: false,
2992 };
2993 match res {
2994 Ok((v, _)) => {
2995 record.vote = v.label();
2996 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2997 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2998 self.state.event(
2999 "vote",
3000 format!(
3001 "judge {} voted {}{}",
3002 j + 1,
3003 record.vote.unwrap_or('?'),
3004 if record.changed { " (changed)" } else { "" }
3005 ),
3006 );
3007 }
3008 Err(e) => {
3009 self.state
3010 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3011 }
3012 }
3013 self.state.votes.push(record);
3014 self.state.save()?;
3015 }
3016 Ok(())
3017 }
3018
3019 fn tally(&mut self) -> Result<()> {
3022 if self.state.tally.is_some() {
3023 return Ok(());
3024 }
3025 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3026 let tops: Vec<char> = self
3027 .state
3028 .judgements
3029 .iter()
3030 .filter_map(|j| j.ranking.first().copied())
3031 .collect();
3032 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3033
3034 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3037 let mut cast: Vec<char> = Vec::new();
3038 for (i, j) in self.state.judgements.iter().enumerate() {
3039 let vote = self
3040 .state
3041 .votes
3042 .iter()
3043 .find(|v| v.judge == i + 1)
3044 .and_then(|v| v.vote)
3045 .or_else(|| j.ranking.first().copied());
3046 if let Some(v) = vote {
3047 *first_choice.entry(v).or_insert(0) += 1;
3048 cast.push(v);
3049 }
3050 }
3051
3052 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3053 for j in &self.state.judgements {
3054 let n = j.ranking.len();
3055 for (pos, label) in j.ranking.iter().enumerate() {
3056 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3057 }
3058 }
3059
3060 let best = first_choice.values().copied().max().unwrap_or(0);
3061 let mut leaders: Vec<char> = first_choice
3062 .iter()
3063 .filter(|(_, v)| **v == best)
3064 .map(|(k, _)| *k)
3065 .collect();
3066 let mut tie_break = None;
3067 if leaders.len() > 1 {
3068 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3069 let borda_leaders: Vec<char> = leaders
3070 .iter()
3071 .copied()
3072 .filter(|l| borda[l] == top_borda)
3073 .collect();
3074 tie_break = Some(if borda_leaders.len() == 1 {
3075 format!(
3076 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3077 leaders.len()
3078 )
3079 } else {
3080 format!(
3081 "{} way tie on both first-choice votes and Borda points, broken by label order",
3082 leaders.len()
3083 )
3084 });
3085 leaders = borda_leaders;
3086 leaders.sort_unstable();
3087 }
3088 let winner = *leaders
3089 .first()
3090 .or(viable.first())
3091 .context("no candidate to declare a winner from")?;
3092
3093 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3094 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3095 let deliberated = !self.state.deliberation.is_empty();
3096
3097 let quota_seats: std::collections::BTreeSet<&str> =
3101 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3102 let mut present = 0usize;
3103 for (i, j) in self.state.judgements.iter().enumerate() {
3104 if quota_seats.contains(j.seat.as_str()) {
3105 continue;
3106 }
3107 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3108 let voted = self
3109 .state
3110 .votes
3111 .iter()
3112 .any(|v| v.judge == i + 1 && v.vote.is_some());
3113 if ranked || voted {
3114 present += 1;
3115 }
3116 }
3117 let needs_quorum = viable.len() > 1;
3123 let judges_total = if needs_quorum {
3124 self.roles.judges.len()
3125 } else {
3126 0
3127 };
3128 let quorum = if needs_quorum {
3129 judges_total / 2 + 1
3130 } else {
3131 0
3132 };
3133 let met_quorum = !needs_quorum || present >= quorum;
3134 let uncontested = (!needs_quorum).then(|| {
3135 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3136 });
3137
3138 self.state.event(
3139 "tally",
3140 match &uncontested {
3141 Some(reason) => format!("winner {winner} — {reason}"),
3142 None => format!(
3143 "winner {winner} — votes {} | initial {} | {} changed | \
3144 {present}/{judges_total} judges{}",
3145 first_choice
3146 .iter()
3147 .map(|(k, v)| format!("{k}:{v}"))
3148 .collect::<Vec<_>>()
3149 .join(" "),
3150 if unanimous_initial {
3151 "unanimous"
3152 } else {
3153 "split"
3154 },
3155 changed_votes,
3156 if met_quorum {
3157 String::new()
3158 } else {
3159 format!(" — below quorum ({quorum} required)")
3160 },
3161 ),
3162 },
3163 );
3164 if !met_quorum {
3165 self.state.event(
3166 "stall",
3167 format!(
3168 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3169 the run stops here, resumable"
3170 ),
3171 );
3172 }
3173 self.state.tally = Some(Tally {
3174 first_choice,
3175 borda,
3176 winner,
3177 rankings: tops.len(),
3178 unanimous_initial,
3179 deliberated,
3180 changed_votes,
3181 unanimous_final,
3182 tie_break,
3183 judges: judges_total,
3184 present,
3185 quorum,
3186 met_quorum,
3187 uncontested,
3188 });
3189 self.state.status = if met_quorum {
3190 RunStatus::Reviewing
3191 } else {
3192 RunStatus::Stalled
3193 };
3194 self.state.save()?;
3195 Ok(())
3196 }
3197
3198 #[allow(clippy::too_many_lines)]
3219 async fn recover_stall(&mut self) -> Result<bool> {
3220 let run_id = self.state.id.clone();
3225 let prompts = self.state.config.prompts.clone();
3226 let quota_seats: BTreeSet<&str> =
3231 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3232 let absent: Vec<String> = self
3233 .state
3234 .judgements
3235 .iter()
3236 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3237 .map(|j| j.seat.clone())
3238 .collect();
3239 if absent.is_empty() {
3240 return Ok(false);
3241 }
3242 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3243 if viable.len() <= 1 {
3244 return Ok(false);
3245 }
3246 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3247 let language = self.state.config.graph.language.clone();
3248 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3249 let sessions = self.state.config.graph.sessions;
3250 let artifacts = agent::artifacts_dir(&self.state.dir());
3251 let root = self.state.worktree_root();
3252 let base_short = short(&self.state.base_commit);
3253 let candidates: Vec<Candidate> = viable.clone();
3254
3255 let mut positions: Vec<usize> = absent
3257 .iter()
3258 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3259 .collect();
3260 if positions.is_empty() {
3261 return Ok(false);
3262 }
3263 positions.sort_unstable();
3264 positions.dedup();
3265
3266 let mut judge_jobs = Vec::new();
3268 for &j in &positions {
3269 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3270 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3271 let seat_key = format!("judge-{}", j + 1);
3272 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3273 let seat = self.seat(&seat_key, &spec.id);
3274 judge_jobs.push(SeatJob {
3275 spec,
3276 seat,
3277 prompt: prompt::judge(
3278 &self.state.instruction,
3279 &views,
3280 self.roles.judges.len(),
3281 &base_short,
3282 &language,
3283 ),
3284 cwd: root.join(seat_key),
3285 timeout,
3286 allow_write: false,
3287 sessions,
3288 artifacts: artifacts.clone(),
3289 stem: format!("judge-{}-recover", j + 1),
3290 handover: None,
3291 });
3292 }
3293
3294 let labels_for_check = labels.clone();
3295 let mut judge_losses = Vec::new();
3296 let retries = self.state.config.graph.retries;
3297 let cache = self.state.config.cache_dir();
3298 let ctx = WaveCtx {
3299 carry_seats: false,
3300 run: &run_id,
3301 node: "judge",
3302 prompts: &prompts,
3303 cache: cache.as_deref(),
3304 round: None,
3305 };
3306 let results = ask_json_wave::<Ranking>(
3307 judge_jobs,
3308 Arc::clone(&self.sem),
3309 retries,
3310 &self.roles.judge_roster,
3311 &ctx,
3312 &mut judge_losses,
3313 &mut self.state,
3314 &move |r: &Ranking| r.validate(&labels_for_check),
3315 )
3316 .await;
3317
3318 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3320 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3321 let agent_id = seat.agent.clone();
3322 self.state.seats.insert(seat.key.clone(), seat);
3323 let record = &mut self.state.judgements[j];
3324 match res {
3325 Ok((ranking, out)) => {
3326 record.agent = agent_id;
3327 record.ranking = ranking.normalized();
3328 record.reasons = ranking.reasons;
3329 record.confidence = ranking.confidence;
3330 record.failed = None;
3331 record.duration_ms = out.duration_ms;
3332 recovered.insert(j);
3333 self.state.event(
3334 "recover",
3335 format!("judge {} ranked again after the limit", j + 1),
3336 );
3337 }
3338 Err(e) => {
3339 self.state
3340 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3341 }
3342 }
3343 }
3344
3345 let mut vote_jobs = Vec::new();
3347 let mut vote_pos: Vec<usize> = Vec::new();
3348 for &j in &recovered {
3349 let seat_key = format!("judge-{}", j + 1);
3350 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3351 let seat = self.seat(&seat_key, &spec.id);
3352 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3353 let text = if has_context(&spec, &seat, sessions) {
3354 prompt::final_vote(&labels, &language)
3355 } else {
3356 full.clone()
3357 };
3358 vote_jobs.push(SeatJob {
3359 spec,
3360 seat,
3361 prompt: text,
3362 cwd: root.join(seat_key),
3363 timeout,
3364 allow_write: false,
3365 sessions,
3366 artifacts: artifacts.clone(),
3367 stem: format!("vote-judge-{}-recover", j + 1),
3368 handover: Some(full),
3369 });
3370 vote_pos.push(j);
3371 }
3372 let allowed = labels.clone();
3373 let mut vote_losses = Vec::new();
3374 let vote_retries = self.state.config.graph.retries;
3375 let vote_cache = self.state.config.cache_dir();
3376 let ctx = WaveCtx {
3377 carry_seats: false,
3378 run: &run_id,
3379 node: "vote",
3380 prompts: &prompts,
3381 cache: vote_cache.as_deref(),
3382 round: None,
3383 };
3384 let votes = ask_json_wave::<FinalVote>(
3385 vote_jobs,
3386 Arc::clone(&self.sem),
3387 vote_retries,
3388 &self.roles.judge_roster,
3389 &ctx,
3390 &mut vote_losses,
3391 &mut self.state,
3392 &move |v: &FinalVote| match v.label() {
3393 Some(c) if allowed.contains(&c) => Ok(()),
3394 other => bail!("vote {other:?} is not one of {allowed:?}"),
3395 },
3396 )
3397 .await;
3398 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3399 let agent_id = seat.agent.clone();
3400 self.state.seats.insert(seat.key.clone(), seat);
3401 match res {
3402 Ok((v, _)) => {
3403 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3404 rec.vote = v.label();
3405 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3406 } else {
3407 self.state.votes.push(VoteRecord {
3408 judge: j + 1,
3409 agent: agent_id,
3410 vote: v.label(),
3411 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3412 changed: false,
3413 });
3414 }
3415 self.state.event(
3416 "recover",
3417 format!("judge {} voted again after the limit", j + 1),
3418 );
3419 }
3420 Err(e) => {
3421 self.state
3422 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3423 }
3424 }
3425 }
3426
3427 let recovered_keys: BTreeSet<String> = recovered
3431 .iter()
3432 .map(|&j| format!("judge-{}", j + 1))
3433 .collect();
3434 self.state
3435 .quota
3436 .retain(|q| !recovered_keys.contains(&q.seat));
3437 for loss in judge_losses.into_iter().chain(vote_losses) {
3441 if recovered_keys.contains(&loss.seat) {
3442 continue;
3443 }
3444 self.state.quota.retain(|q| q.seat != loss.seat);
3445 self.state.quota.push(loss);
3446 }
3447
3448 self.state.tally = None;
3450 self.tally()?;
3451 Ok(self
3452 .state
3453 .tally
3454 .as_ref()
3455 .map(|t| t.met_quorum)
3456 .unwrap_or(false))
3457 }
3458
3459 async fn fold_losers(&mut self) -> Result<()> {
3462 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3463 return Ok(());
3464 };
3465 let repo = self.state.repo.clone();
3466 let mut folded = Vec::new();
3467 for i in 0..self.state.candidates.len() {
3468 let c = &self.state.candidates[i];
3469 if c.label == winner || c.folded {
3470 continue;
3471 }
3472 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3473 git::worktree_remove(&repo, &wt).await.ok();
3474 git::branch_delete(&repo, &branch).await.ok();
3475 self.state.candidates[i].folded = true;
3476 folded.push(label.to_string());
3477 }
3478 let root = self.state.worktree_root();
3480 for j in 1..=self.roles.judges.len() {
3481 let wt = root.join(format!("judge-{j}"));
3482 if wt.exists() {
3483 git::worktree_remove(&repo, &wt).await.ok();
3484 }
3485 }
3486 if self.state.config.graph.advise {
3489 for k in 1..=self.state.config.graph.advisors {
3490 let wt = root.join(format!("advisor-{k}"));
3491 if wt.exists() {
3492 git::worktree_remove(&repo, &wt).await.ok();
3493 }
3494 }
3495 }
3496 if !folded.is_empty() {
3497 self.state
3498 .event("fold", format!("folded candidates {}", folded.join(", ")));
3499 self.state.save()?;
3500 }
3501 Ok(())
3502 }
3503
3504 async fn sync_to_base(&mut self) -> Result<()> {
3544 if self.state.status == RunStatus::AlreadyInBase {
3545 return Ok(());
3546 }
3547 let conflicted = self
3548 .state
3549 .base_sync
3550 .as_ref()
3551 .is_some_and(|s| s.conflict.is_some());
3552 let Some(winner) = self.state.winner().cloned() else {
3553 return Ok(());
3554 };
3555
3556 let repo = self.state.repo.clone();
3557 let remote = self.state.config.merge.remote.clone();
3558 let base_branch = self.state.base_branch.clone();
3559 let tracking = format!("{remote}/{base_branch}");
3560
3561 git::fetch(&repo, &remote, &base_branch).await.ok();
3562 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3567 return Ok(());
3568 };
3569
3570 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3571 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3572 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3573
3574 if (behind > 0 || conflicted || head == tip)
3583 && self
3584 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3585 .await?
3586 {
3587 return Ok(());
3588 }
3589 if conflicted {
3590 return Ok(());
3591 }
3592
3593 if behind == 0 {
3594 if let Some(from) = self
3603 .state
3604 .rebase_fixes
3605 .iter()
3606 .rev()
3607 .find_map(|r| r.from.clone())
3608 && from != head
3609 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3610 .await
3611 .is_ok_and(|o| o.ok())
3612 {
3613 git::sync_to_head(&winner.worktree).await?;
3614 }
3615 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3621 if let Some(why) = &conflict {
3622 self.state.status = RunStatus::Blocked;
3623 self.state.event("land", why.clone());
3624 }
3625 self.state.base_sync = Some(BaseSync {
3626 tip,
3627 behind: 0,
3628 attempts,
3629 conflict,
3630 already_in: None,
3631 });
3632 self.state.save()?;
3633 return Ok(());
3634 }
3635
3636 if attempts >= BASE_SYNC_ROUNDS {
3637 let why = format!(
3638 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3639 rebase(s); rebasing again would only race it",
3640 winner.branch
3641 );
3642 self.state.status = RunStatus::Blocked;
3643 self.state.base_sync = Some(BaseSync {
3644 tip,
3645 behind,
3646 attempts,
3647 conflict: Some(why.clone()),
3648 already_in: None,
3649 });
3650 self.state.event("land", why);
3651 self.state.save()?;
3652 return Ok(());
3653 }
3654
3655 self.state.event(
3656 "land",
3657 format!(
3658 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3659 winner.branch
3660 ),
3661 );
3662 self.state.save()?;
3663
3664 let branch_tracking = format!("{remote}/{}", winner.branch);
3670 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3671 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3672 git::rev_parse(&repo, &branch_tracking).await.ok()
3673 } else {
3674 None
3675 };
3676 if let Some(theirs) = &remote_tip
3680 && !git::is_ancestor(&repo, theirs, &head).await
3681 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3682 .await
3683 .is_ok_and(|missing| missing.is_empty())
3684 {
3685 let why = format!(
3686 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3687 them",
3688 short(theirs),
3689 winner.branch
3690 );
3691 self.state.status = RunStatus::Blocked;
3692 self.state.base_sync = Some(BaseSync {
3693 tip,
3694 behind,
3695 attempts,
3696 conflict: Some(why.clone()),
3697 already_in: None,
3698 });
3699 self.state.event("land", why);
3700 self.state.save()?;
3701 return Ok(());
3702 }
3703
3704 let scratch = self.state.dir().join("base-sync");
3705 let rebased = match crate::rebase::rebase_with_fixer(
3706 &mut self.state,
3707 &scratch,
3708 &winner.branch,
3709 &tracking,
3710 )
3711 .await
3712 {
3713 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3714 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3715 Err(e) => Err(e),
3716 };
3717 let attempts = attempts + 1;
3718 match rebased {
3719 Ok(None) => {
3720 git::sync_to_head(&winner.worktree).await?;
3724 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3725 let mut conflict = None;
3726 if let Some(pinned) = &remote_tip {
3727 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3728 match pushed {
3729 Ok(o) if o.ok() => self.state.event(
3730 "land",
3731 format!("pushed rebased {} to {remote}", winner.branch),
3732 ),
3733 Ok(o) => {
3734 conflict = Some(format!(
3735 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
3736 winner.branch,
3737 short(pinned),
3738 o.stderr.chars().take(600).collect::<String>()
3739 ));
3740 }
3741 Err(e) => {
3742 conflict = Some(format!(
3743 "rebased {} locally but could not push it: {e:#}",
3744 winner.branch
3745 ));
3746 }
3747 }
3748 }
3749 if let Some(why) = &conflict {
3750 self.state.status = RunStatus::Blocked;
3751 self.state.event("land", why.clone());
3752 }
3753 self.state.base_sync = Some(BaseSync {
3754 tip: tip.clone(),
3755 behind: 0,
3756 attempts,
3757 conflict,
3758 already_in: None,
3759 });
3760 self.state
3761 .event("land", format!("rebased {} onto {tracking}", winner.branch));
3762 }
3763 Ok(Some(conflict)) => {
3764 let why = format!(
3765 "{} conflicts with {tracking} and did not rebase: {}",
3766 winner.branch,
3767 conflict.chars().take(600).collect::<String>()
3768 );
3769 self.state.status = RunStatus::Blocked;
3770 self.state.base_sync = Some(BaseSync {
3771 tip,
3772 behind,
3773 attempts,
3774 conflict: Some(why.clone()),
3775 already_in: None,
3776 });
3777 self.state.event("land", why);
3778 }
3779 Err(e) => {
3780 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3781 self.state.status = RunStatus::Blocked;
3782 self.state.base_sync = Some(BaseSync {
3783 tip,
3784 behind,
3785 attempts,
3786 conflict: Some(why.clone()),
3787 already_in: None,
3788 });
3789 self.state.event("land", why);
3790 }
3791 }
3792 self.state.save()?;
3793 Ok(())
3794 }
3795
3796 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3802 let repo = self.state.repo.clone();
3803 let remote = self.state.config.merge.remote.clone();
3804 let fetched = git::fetch(&repo, &remote, branch).await;
3805 if !matches!(&fetched, Ok(o) if o.ok()) {
3806 return None;
3807 }
3808 let branch_tracking = format!("{remote}/{branch}");
3809 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3810 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3811 return None;
3812 }
3813 if !crate::reconcile::origin_missing(&repo, head, &theirs)
3814 .await
3815 .is_ok_and(|missing| missing.is_empty())
3816 {
3817 return Some(format!(
3818 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3819 them",
3820 short(&theirs)
3821 ));
3822 }
3823 match git::push_pinned(&repo, &remote, branch, &theirs).await {
3824 Ok(o) if o.ok() => {
3825 self.state
3826 .event("land", format!("pushed rebased {branch} to {remote}"));
3827 None
3828 }
3829 Ok(o) => Some(format!(
3830 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3831 someone may have pushed): {}",
3832 short(&theirs),
3833 o.stderr.chars().take(600).collect::<String>()
3834 )),
3835 Err(e) => Some(format!(
3836 "{branch} is rebased locally but could not be pushed: {e:#}"
3837 )),
3838 }
3839 }
3840
3841 async fn settle_already_in(
3858 &mut self,
3859 branch: &str,
3860 tip: &str,
3861 head: &str,
3862 attempts: usize,
3863 behind: usize,
3864 ) -> Result<bool> {
3865 let repo = self.state.repo.clone();
3866 let remote = self.state.config.merge.remote.clone();
3867 let start = self.state.base_commit.clone();
3868 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3869 Ok(Some(e)) => e,
3870 Ok(None) => return Ok(false),
3871 Err(e) => {
3872 tracing::warn!("already-in-base check for {branch}: {e:#}");
3873 return Ok(false);
3874 }
3875 };
3876 let mut verified = vec![head.to_owned()];
3877 let fetched = git::fetch(&repo, &remote, branch).await;
3878 if matches!(&fetched, Ok(o) if o.ok())
3879 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3880 && theirs != head
3881 {
3882 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3883 Ok(Some(_)) => verified.push(theirs),
3884 _ => return Ok(false),
3885 }
3886 }
3887 let base_branch = self.state.base_branch.clone();
3888 let message = format!(
3889 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3890 land",
3891 evidence.names(),
3892 evidence.proof.as_str()
3893 );
3894 let closed =
3895 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3896 match closed {
3897 Ok(Ok(url)) => self
3898 .state
3899 .event("land", format!("closed {url}: superseded on {base_branch}")),
3900 Ok(Err(why)) => self
3901 .state
3902 .event("land", format!("did not close a pull request: {why}")),
3903 Err(e) => {
3904 let why = format!(
3905 "{branch} is already in {remote}/{base_branch}, but its pull request could \
3906 not be closed ({e:#}); resume to retry"
3907 );
3908 self.state.status = RunStatus::Blocked;
3909 self.state.base_sync = Some(BaseSync {
3910 tip: tip.to_owned(),
3911 behind,
3912 attempts,
3913 conflict: Some(why.clone()),
3914 already_in: None,
3915 });
3916 self.state.event("land", why);
3917 self.state.save()?;
3918 return Ok(true);
3919 }
3920 }
3921 self.state.status = RunStatus::AlreadyInBase;
3922 self.state.base_sync = Some(BaseSync {
3923 tip: tip.to_owned(),
3924 behind,
3925 attempts,
3926 conflict: None,
3927 already_in: Some(evidence),
3928 });
3929 self.state.event("land", message);
3930 self.state.save()?;
3931 self.settle_questions();
3932 Ok(true)
3933 }
3934
3935 fn landing_base(&self) -> String {
3945 self.state
3946 .base_sync
3947 .as_ref()
3948 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3949 }
3950
3951 pub async fn fix_selected(
3984 &mut self,
3985 ids: &[String],
3986 reason: &str,
3987 allow_stale: bool,
3988 ) -> Result<()> {
3989 let reason = reason.trim();
3990 if reason.is_empty() {
3991 bail!("a fix request needs a reason — that is the operator's own record of why");
3992 }
3993 if ids.is_empty() {
3994 bail!("no finding id given");
3995 }
3996 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3997 bail!(
3998 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3999 has already concluded — can be given a targeted fix. A run still \
4000 in progress should simply be resumed; a `merged` run's branch has \
4001 already landed, so its answer is a fresh `magi review <branch>`, \
4002 not reopening this run's own record",
4003 self.state.id,
4004 self.state.status.as_str()
4005 );
4006 }
4007 let Some(winner) = self.state.winner().cloned() else {
4008 bail!("run {} has no winning candidate to fix", self.state.id);
4009 };
4010 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4011 bail!(
4012 "branch `{}` no longer exists; this run cannot be extended",
4013 winner.branch
4014 );
4015 }
4016 let home = crate::run::home();
4017 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4018 bail!(
4019 "run {} is currently being worked on by another magi process",
4020 self.state.id
4021 );
4022 }
4023 let _claim = FixClaim::acquire(&self.state.dir())?;
4029
4030 let mut seen = BTreeSet::new();
4034 let mut findings = Vec::new();
4035 let mut missing = Vec::new();
4036 for id in ids {
4037 if !seen.insert(id.clone()) {
4038 continue;
4039 }
4040 match self.state.finding(id) {
4041 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4042 id: f.id.clone(),
4043 severity: f.severity,
4044 reviewer_vote: rec.vote,
4045 round: round.round,
4046 round_head: round.head.clone(),
4047 reviewer: rec.reviewer,
4048 agent: rec.agent.clone(),
4049 file: f.file.clone(),
4050 line: f.line,
4051 title: f.title.clone(),
4052 detail: f.detail.clone(),
4053 outcome: OperatorFixOutcome::Pending,
4054 }),
4055 None => missing.push(id.clone()),
4056 }
4057 }
4058 if !missing.is_empty() {
4059 bail!(
4060 "unknown finding id(s): {}; nothing was changed",
4061 missing.join(", ")
4062 );
4063 }
4064
4065 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4066 let stale_details: Vec<(String, String)> = findings
4067 .iter()
4068 .filter(|f| f.round_head != head_at_request)
4069 .map(|f| (f.id.clone(), f.round_head.clone()))
4070 .collect();
4071 let stale = !stale_details.is_empty();
4072 if stale && !allow_stale {
4073 bail!(
4074 "the branch has moved since some finding(s) were raised — {} — now \
4075 at {}; pass --allow-stale to fix anyway, or re-run review first",
4076 stale_details
4077 .iter()
4078 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4079 .collect::<Vec<_>>()
4080 .join(", "),
4081 short(&head_at_request)
4082 );
4083 }
4084
4085 let request = OperatorFixRequest {
4086 requested_at: Timestamp::now(),
4087 reason: reason.to_owned(),
4088 findings,
4089 head_at_request: head_at_request.clone(),
4090 allow_stale,
4091 stale,
4092 fix: None,
4093 result_head: None,
4094 follow_up_review_run: None,
4095 };
4096 self.state.event(
4097 "fix",
4098 format!(
4099 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4100 request.findings.len(),
4101 request
4102 .findings
4103 .iter()
4104 .map(|f| f.id.as_str())
4105 .collect::<Vec<_>>()
4106 .join(", "),
4107 ),
4108 );
4109 self.state.operator_fixes.push(request);
4116 self.state.save()?;
4117 let request_index = self.state.operator_fixes.len() - 1;
4118
4119 if winner.worktree.exists() {
4128 let dirty = git::git(
4131 &winner.worktree,
4132 &["status", "--porcelain", "--untracked-files=all"],
4133 )
4134 .await?;
4135 let only_withheld = dirty.lines().all(|l| {
4136 l.strip_prefix("?? ")
4137 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4138 });
4139 if !only_withheld {
4140 bail!(
4141 "`{}` has uncommitted changes; refusing to touch it — commit or \
4142 discard them first",
4143 winner.worktree.display()
4144 );
4145 }
4146 git::worktree_remove(&self.state.repo, &winner.worktree)
4147 .await
4148 .ok();
4149 }
4150 let fix_worktree = self.state.worktree_root().join("operator-fix");
4151 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4152 git::git(
4153 &self.state.repo,
4154 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4155 )
4156 .await
4157 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4158 if !git::is_clean(&fix_worktree).await? {
4159 git::worktree_remove(&self.state.repo, &fix_worktree)
4160 .await
4161 .ok();
4162 bail!(
4163 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4164 winner.branch
4165 );
4166 }
4167 let repo_for_setup = self.state.repo.clone();
4168 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4169 Ok(()) => {
4170 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4171 }
4172 Err(e) => Err(e),
4173 };
4174 if let Err(e) = setup {
4175 git::worktree_remove(&self.state.repo, &fix_worktree)
4176 .await
4177 .ok();
4178 return Err(e);
4179 }
4180
4181 let run_id = self.state.id.clone();
4182 let prompts = self.state.config.prompts.clone();
4183 let language = self.state.config.graph.language.clone();
4184 let sessions = self.state.config.graph.sessions;
4185 let artifacts = agent::artifacts_dir(&self.state.dir());
4186 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4187 .findings
4188 .iter()
4189 .map(|f| Finding {
4190 id: f.id.clone(),
4191 severity: f.severity,
4192 file: f.file.clone(),
4193 line: f.line,
4194 title: f.title.clone(),
4195 detail: f.detail.clone(),
4196 })
4197 .collect();
4198 let fix_prompt = prompt::operator_fix(
4199 &self.state.instruction,
4200 &finding_list,
4201 reason,
4202 &stale_details,
4203 &head_at_request,
4204 &language,
4205 );
4206 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4207 let (job, seat, out) = self
4208 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4209 prompt: fix_prompt.clone(),
4210 spec,
4211 seat,
4212 cwd: fix_worktree.clone(),
4213 timeout,
4214 allow_write: true,
4215 sessions,
4216 artifacts: artifacts.clone(),
4217 stem: "operator-fix".to_owned(),
4218 handover: None,
4219 })
4220 .await;
4221 let agent_id = seat.agent.clone();
4222
4223 let mut fix = FixRecord {
4224 agent: agent_id,
4225 addressed: Vec::new(),
4226 rejected: Vec::new(),
4227 notes: String::new(),
4228 committed: false,
4229 failed: None,
4230 duration_ms: 0,
4231 continuation: None,
4232 };
4233 let mut final_seat = seat.clone();
4234 match out {
4235 AgentOutcome::Ok(o) => {
4236 fix.duration_ms = o.duration_ms;
4237 let parsed = verdict::extract_json::<FixReport>(&o.text);
4238 let incomplete_reason = match &parsed {
4239 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4240 "the reply parsed, but it reported a command whose own CLI \
4241 never confirmed an exit status"
4242 .to_owned(),
4243 ),
4244 Ok(_) => None,
4245 Err(e) => Some(e.to_string()),
4246 };
4247 match incomplete_reason {
4248 None => {
4249 let report = parsed.expect("checked Ok above");
4250 fix.addressed = report.addressed;
4251 fix.rejected = report.rejected;
4252 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4253 }
4254 Some(reason) => {
4255 let (resumed_seat, resolved, failure, cont) = self
4256 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4257 .await;
4258 fix.duration_ms += cont.cumulative_wait_ms;
4259 fix.continuation = Some(cont);
4260 final_seat = resumed_seat;
4261 match resolved {
4262 Some(report) => {
4263 fix.addressed = report.addressed;
4264 fix.rejected = report.rejected;
4265 fix.notes =
4266 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4267 }
4268 None => fix.failed = failure,
4269 }
4270 }
4271 }
4272 }
4273 AgentOutcome::Dropped(o) => {
4274 fix.duration_ms = o.duration_ms;
4275 let why = o
4276 .dropped
4277 .as_ref()
4278 .map(|d| d.why.as_str())
4279 .unwrap_or("the CLI ended the stream without delivering its answer");
4280 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4281 }
4282 AgentOutcome::Quota(o) => {
4283 self.state.quota.push(QuotaLoss {
4284 seat: final_seat.key.clone(),
4285 node: "fix".to_owned(),
4286 at: Timestamp::now(),
4287 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4288 });
4289 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4290 }
4291 AgentOutcome::Failed(e) => fix.failed = Some(e),
4292 }
4293 if fix.continuation.is_none() {
4294 fix.continuation = Some(ContinuationRecord::not_needed());
4295 }
4296 self.state.seats.insert(final_seat.key.clone(), final_seat);
4297
4298 let rescue_message = format!(
4299 "magi: operator-selected fix ({}) (uncommitted work)",
4300 self.state.operator_fixes[request_index]
4301 .findings
4302 .iter()
4303 .map(|f| f.id.as_str())
4304 .collect::<Vec<_>>()
4305 .join(", ")
4306 );
4307 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4308 self.state.note_withheld("fix", &r.withheld);
4309 }
4310 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4311 fix.committed = after != head_at_request;
4312 git::worktree_remove(&self.state.repo, &fix_worktree)
4313 .await
4314 .ok();
4315
4316 self.state.event(
4317 "fix",
4318 match &fix.failed {
4319 Some(reason) => format!(
4320 "operator fix: adoption report was lost ({reason}); {}",
4321 if fix.committed {
4322 "committed"
4323 } else {
4324 "NO new commit"
4325 }
4326 ),
4327 None => format!(
4328 "operator fix: {} addressed, {} rejected, {}",
4329 fix.addressed.len(),
4330 fix.rejected.len(),
4331 if fix.committed {
4332 "committed"
4333 } else {
4334 "NO new commit"
4335 }
4336 ),
4337 },
4338 );
4339
4340 for f in &mut self.state.operator_fixes[request_index].findings {
4347 f.outcome = if fix.failed.is_some() {
4348 OperatorFixOutcome::Unreported
4349 } else if fix.addressed.contains(&f.id) {
4350 OperatorFixOutcome::Addressed
4351 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4352 OperatorFixOutcome::Rejected { why: r.why.clone() }
4353 } else {
4354 OperatorFixOutcome::Unreported
4355 };
4356 }
4357
4358 let committed = fix.committed;
4359 if committed {
4360 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4361 }
4362 self.state.operator_fixes[request_index].fix = Some(fix);
4363 self.state.save()?;
4366
4367 if committed {
4368 self.state.event(
4369 "fix",
4370 format!(
4371 "operator fix committed {}; opening a follow-up review-only run",
4372 short(&after)
4373 ),
4374 );
4375 let origin =
4378 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4379 match Self::review(
4380 &self.state.repo,
4381 &winner.branch,
4382 self.state.config.clone(),
4383 origin,
4384 )
4385 .await
4386 {
4387 Ok(mut follow_up) => {
4388 follow_up.state.event(
4389 "start",
4390 format!(
4391 "requested by an operator fix on run {} for finding(s) {}",
4392 self.state.id,
4393 self.state.operator_fixes[request_index]
4394 .findings
4395 .iter()
4396 .map(|f| f.id.as_str())
4397 .collect::<Vec<_>>()
4398 .join(", "),
4399 ),
4400 );
4401 follow_up.state.save()?;
4402 let follow_up_id = follow_up.state.id.clone();
4403 let adopted = match crate::direct::adopt(
4406 &follow_up.state,
4407 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4408 ) {
4409 Ok(a) => a,
4410 Err(e) => {
4411 self.state.event(
4414 "fix",
4415 format!(
4416 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4417 ),
4418 );
4419 self.state.save()?;
4420 return Ok(());
4421 }
4422 };
4423 let executed = follow_up.execute().await;
4424 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4425 if let Some(a) = adopted {
4426 a.finish(&follow_up.state, executed);
4427 }
4428 if let Some(e) = failure {
4429 self.state.event(
4430 "fix",
4431 format!(
4432 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4433 ),
4434 );
4435 }
4436 self.state.operator_fixes[request_index].follow_up_review_run =
4437 Some(follow_up_id);
4438 }
4439 Err(e) => {
4440 self.state.event(
4441 "fix",
4442 format!("committed the fix but could not open a follow-up review: {e:#}"),
4443 );
4444 }
4445 }
4446 self.state.save()?;
4447 }
4448
4449 Ok(())
4450 }
4451
4452 async fn ask_fixer(
4472 &mut self,
4473 winner: &Candidate,
4474 node: &'static str,
4475 round: Option<usize>,
4476 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4477 ) -> (SeatJob, SeatState, AgentOutcome) {
4478 let run_id = self.state.id.clone();
4479 let prompts = self.state.config.prompts.clone();
4480 let cache = self.state.config.cache_dir();
4481 let attempts = fixer::attempts(&self.state, &self.roles, winner);
4482 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4483 let mut last = None;
4484 for (i, (spec, key)) in attempts.into_iter().enumerate() {
4485 let seat = self.seat(&key, &spec.id);
4486 let mut job = build(spec.clone(), seat);
4487 if i > 0 {
4488 job.stem = format!("{}-{}", job.stem, spec.id);
4489 }
4490 let ctx = WaveCtx {
4491 carry_seats: false,
4492 run: &run_id,
4493 node,
4494 prompts: &prompts,
4495 cache: cache.as_deref(),
4496 round,
4497 };
4498 let (seat, out) =
4499 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4500 let advances = match &out {
4501 AgentOutcome::Ok(o) => agent::output_advances(o),
4502 _ => true,
4503 };
4504 if let Some(next) = ids.get(i + 1)
4505 && advances
4506 {
4507 self.state.seats.insert(seat.key.clone(), seat.clone());
4508 let class =
4509 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4510 let reason = match &out {
4511 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4512 other => fail_reason(other),
4513 };
4514 record_handover(
4515 &mut self.state,
4516 node,
4517 &seat.key,
4518 &spec.id,
4519 next,
4520 &class,
4521 &reason,
4522 );
4523 continue;
4524 }
4525 last = Some((job, seat, out));
4526 break;
4527 }
4528 last.expect("the fixer chain always has an entry")
4529 }
4530
4531 async fn review_loop(&mut self) -> Result<()> {
4532 if self
4537 .state
4538 .base_sync
4539 .as_ref()
4540 .is_some_and(|s| s.conflict.is_some())
4541 {
4542 return Ok(());
4543 }
4544 let run_id = self.state.id.clone();
4549 let prompts = self.state.config.prompts.clone();
4550 let Some(winner) = self.state.winner().cloned() else {
4551 return Ok(());
4552 };
4553 let max_rounds = self.state.config.graph.review_rounds;
4554 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4564 if status == RunStatus::Gating {
4567 self.record_contested_handoff();
4568 }
4569 self.state.status = status;
4570 self.state.save()?;
4571 return Ok(());
4572 }
4573 self.state.status = RunStatus::Reviewing;
4574 if self
4584 .state
4585 .reviews
4586 .last()
4587 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4588 {
4589 let shell = self.state.config.shell();
4590 return self
4591 .stop_reviewing(
4592 "the last round's own verification never resolved",
4593 &shell,
4594 &winner.worktree,
4595 )
4596 .await;
4597 }
4598
4599 let repo = self.state.repo.clone();
4600 let root = self.state.worktree_root();
4601 let language = self.state.config.graph.language.clone();
4602 let sessions = self.state.config.graph.sessions;
4603 let artifacts = agent::artifacts_dir(&self.state.dir());
4604 let base = self.landing_base();
4605 let base_short = short(&base);
4606 let reviewers = self.roles.reviewers.clone();
4607 let shell = self.state.config.shell();
4608
4609 for round in (self.state.reviews.len() + 1)..=max_rounds {
4610 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4611 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4612 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4613 let prev_verification = self
4622 .state
4623 .reviews
4624 .last()
4625 .and_then(|r| r.verification_summary(&head));
4626
4627 let mut jobs = Vec::new();
4631 for (r, spec) in reviewers.iter().cloned().enumerate() {
4632 let wt = root.join(format!("review-{}", r + 1));
4633 if wt.exists() {
4636 git::reset_detached(&wt, &head).await?;
4637 } else {
4638 git::worktree_add_detached(&repo, &wt, &head).await?;
4639 }
4640 self.setup_seat_worktree(&repo, &wt, false).await?;
4641 let seat_key = format!("review-{}", r + 1);
4642 let spec = pick_start_spec(
4645 &self.roles.reviewer_roster,
4646 spec,
4647 self.state.seat_history.get(&seat_key),
4648 );
4649 let seat = self.seat(&seat_key, &spec.id);
4650 jobs.push(SeatJob {
4651 prompt: prompt::review(&prompt::ReviewCtx {
4652 instruction: &self.state.instruction,
4653 branch: &winner.branch,
4654 base_short: &base_short,
4655 stat: &stat,
4656 patch: &patch,
4657 verification: prev_verification.as_ref(),
4658 reviewers: reviewers.len(),
4659 round,
4660 rounds: max_rounds,
4661 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4664 lens: Lens::for_seat(r),
4665 language: &language,
4666 }),
4667 spec,
4668 seat,
4669 cwd: wt,
4670 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4671 allow_write: false,
4672 sessions,
4673 artifacts: artifacts.clone(),
4674 stem: format!("review-{round}-{}", r + 1),
4675 handover: None,
4676 });
4677 }
4678
4679 self.state.event(
4680 "review",
4681 format!(
4682 "round {round}: {} reviewers on {}",
4683 jobs.len(),
4684 short(&head)
4685 ),
4686 );
4687 let mut quota_losses = Vec::new();
4688 let review_retries = self.state.config.graph.retries;
4689 let review_cache = self.state.config.cache_dir();
4690 let ctx = WaveCtx {
4691 carry_seats: true,
4692 run: &run_id,
4693 node: "review",
4694 prompts: &prompts,
4695 cache: review_cache.as_deref(),
4696 round: Some(round),
4697 };
4698 let results = ask_json_wave::<Review>(
4699 jobs,
4700 Arc::clone(&self.sem),
4701 review_retries,
4702 &self.roles.reviewer_roster,
4703 &ctx,
4704 &mut quota_losses,
4705 &mut self.state,
4706 &|_: &Review| Ok(()),
4707 )
4708 .await;
4709 let round_quota_missing = quota_losses.len();
4713 self.state.quota.extend(quota_losses);
4714
4715 let mut records = Vec::new();
4716 let mut all_findings = Vec::new();
4717 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4718 let agent_id = seat.agent.clone();
4719 self.state.seats.insert(seat.key.clone(), seat);
4720 let mut record = ReviewRecord {
4721 reviewer: r + 1,
4722 agent: agent_id,
4723 summary: String::new(),
4724 findings: Vec::new(),
4725 vote: None,
4726 failed: None,
4727 duration_ms: 0,
4728 attempts,
4734 };
4735 match res {
4736 Ok((review, out)) => {
4737 record.summary =
4745 blind::sanitize_prose(&review.summary, &self.state.config.blind);
4746 record.vote = Some(review.vote);
4747 record.duration_ms = out.duration_ms;
4748 for (n, mut f) in review.findings.into_iter().enumerate() {
4749 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4752 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4753 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4754 f.file = f
4760 .file
4761 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4762 all_findings.push(f.clone());
4763 record.findings.push(f);
4764 }
4765 self.state.event(
4766 "review",
4767 format!(
4768 "round {round}: reviewer {} voted {} with {} finding(s)",
4769 r + 1,
4770 review.vote.label(),
4771 record.findings.len()
4772 ),
4773 );
4774 }
4775 Err(e) => {
4776 record.failed = Some(e.to_string());
4777 self.state.event(
4778 "review",
4779 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4780 );
4781 }
4782 }
4783 records.push(record);
4784 }
4785
4786 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4793 let vote_split =
4794 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4795 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4796 if vote_split {
4797 self.state.event(
4798 "review",
4799 format!(
4800 "round {round}: votes split ({}) — one round of reconsideration",
4801 initial_votes
4802 .iter()
4803 .map(|v| v.label())
4804 .collect::<Vec<_>>()
4805 .join(", ")
4806 ),
4807 );
4808 let panel: Vec<ReviewSeatReport<'_>> = records
4811 .iter()
4812 .filter_map(|r| {
4813 r.vote.map(|vote| ReviewSeatReport {
4814 reviewer: r.reviewer,
4815 vote,
4816 summary: &r.summary,
4817 findings: &r.findings,
4818 })
4819 })
4820 .collect();
4821
4822 let mut jobs = Vec::new();
4823 let mut seats_at = Vec::new();
4824 for (r, spec) in reviewers.iter().cloned().enumerate() {
4825 if records[r].vote.is_none() {
4829 continue;
4830 }
4831 let wt = root.join(format!("review-{}", r + 1));
4832 let seat_key = format!("review-{}", r + 1);
4833 let spec = self.occupant(&seat_key, spec);
4834 let seat = self.seat(&seat_key, &spec.id);
4835 let build = |with_patch: bool| {
4843 prompt::review_reconsider(&ReviewReconsiderCtx {
4844 instruction: &self.state.instruction,
4845 reviewer: r + 1,
4846 lens: Lens::for_seat(r),
4847 panel: &panel,
4848 patch: with_patch.then_some(ReviewPatch {
4849 branch: &winner.branch,
4850 base_short: &base_short,
4851 stat: &stat,
4852 patch: &patch,
4853 }),
4854 round,
4855 rounds: max_rounds,
4856 language: &language,
4857 })
4858 };
4859 let full = build(true);
4860 let prompt = if has_context(&spec, &seat, sessions) {
4861 build(false)
4862 } else {
4863 full.clone()
4864 };
4865 jobs.push(SeatJob {
4866 prompt,
4867 spec,
4868 seat,
4869 cwd: wt,
4870 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4871 allow_write: false,
4872 sessions,
4873 artifacts: artifacts.clone(),
4874 stem: format!("review-{round}-reconsider-{}", r + 1),
4875 handover: Some(full),
4876 });
4877 seats_at.push(r);
4878 }
4879
4880 let mut recon_quota_losses = Vec::new();
4881 let recon_cache = self.state.config.cache_dir();
4882 let recon_ctx = WaveCtx {
4883 carry_seats: true,
4884 run: &run_id,
4885 node: "review",
4886 prompts: &prompts,
4887 cache: recon_cache.as_deref(),
4888 round: Some(round),
4889 };
4890 let recon_results = ask_json_wave::<ReviewRevote>(
4891 jobs,
4892 Arc::clone(&self.sem),
4893 review_retries,
4894 &self.roles.reviewer_roster,
4895 &recon_ctx,
4896 &mut recon_quota_losses,
4897 &mut self.state,
4898 &|_: &ReviewRevote| Ok(()),
4899 )
4900 .await;
4901 self.state.quota.extend(recon_quota_losses);
4902
4903 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4904 let agent_id = seat.agent.clone();
4905 self.state.seats.insert(seat.key.clone(), seat);
4906 let mut rec = ReviewRevoteRecord {
4907 reviewer: r + 1,
4908 agent: agent_id,
4909 vote: None,
4910 reason: String::new(),
4911 failed: None,
4912 };
4913 match res {
4914 Ok((rv, _)) => {
4915 rec.vote = Some(rv.vote);
4916 rec.reason =
4917 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4918 self.state.event(
4919 "review",
4920 format!(
4921 "round {round}: reviewer {} revoted {}",
4922 r + 1,
4923 rv.vote.label()
4924 ),
4925 );
4926 }
4927 Err(e) => {
4928 rec.failed = Some(e.to_string());
4929 self.state.event(
4930 "review",
4931 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4932 );
4933 }
4934 }
4935 reconsideration.push(rec);
4936 }
4937 } else if initial_votes.len() > 1 {
4938 self.state.event(
4939 "review",
4940 format!(
4941 "round {round}: votes agreed ({}) — no reconsideration",
4942 initial_votes[0].label()
4943 ),
4944 );
4945 }
4946
4947 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4948 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4949 let defer_e2e =
4960 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4961 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4962 let reason =
4963 format!("{blocking} blocking finding(s) already required a fix this round");
4964 self.state.event(
4965 "verify",
4966 format!(
4967 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4968 {}); it will run once a round has none left",
4969 short(&head)
4970 ),
4971 );
4972 (Vec::new(), false, true, Some(reason))
4973 } else {
4974 let e2e_commands = self.state.config.verify.e2e.clone();
4975 let cache_dir = self.state.config.cache_dir();
4976 let context = format!("round {round}");
4977 let (e2e, verify_retried) = with_cache_lease(
4978 &mut self.state,
4979 cache_dir.as_deref(),
4980 "e2e",
4981 "e2e",
4982 &winner.worktree,
4983 &head,
4984 verify_timeout,
4985 &context,
4986 |state, budget| {
4987 let shell = shell.clone();
4988 let e2e_commands = e2e_commands.clone();
4989 let worktree = winner.worktree.clone();
4990 let context = context.clone();
4991 async move {
4992 run_e2e_with_retry(
4993 state,
4994 &shell,
4995 &e2e_commands,
4996 &worktree,
4997 budget,
4998 &context,
4999 )
5000 .await
5001 }
5002 },
5003 )
5004 .await;
5005 (e2e, verify_retried, false, None)
5006 };
5007
5008 let expected = records.len();
5009 let answered = records.iter().filter(|r| r.failed.is_none()).count();
5010 let incomplete = answered < expected;
5011 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5012 let policy = self.state.config.graph.incomplete_review;
5013 let clean = round_is_clean(
5014 blocking,
5015 e2e_ok,
5016 answered,
5017 expected,
5018 round_quota_missing,
5019 policy,
5020 );
5021
5022 let mut round_record = ReviewRound {
5023 round,
5024 head: head.clone(),
5025 verified_head: None,
5026 verified_at: None,
5027 reviews: records,
5028 e2e,
5029 verify_retried,
5030 e2e_deferred,
5031 e2e_defer_reason,
5032 fix: None,
5033 blocking,
5034 answered,
5035 expected,
5036 clean,
5037 progressed: false,
5038 vote_split,
5039 reconsideration,
5040 verdict: None,
5041 };
5042 round_record.verdict = ReviewVote::worst(
5046 round_record
5047 .final_votes()
5048 .into_iter()
5049 .map(|(_, _, vote)| vote),
5050 );
5051 if !matches!(
5062 round_record.e2e_status(),
5063 E2eStatus::Deferred | E2eStatus::NotConfigured
5064 ) {
5065 round_record.verified_head = Some(head.clone());
5066 round_record.verified_at = Some(Timestamp::now());
5067 }
5068 let this_round_verification = round_record.verification_summary(&head);
5069
5070 if incomplete {
5071 let missing: Vec<String> = round_record
5072 .reviews
5073 .iter()
5074 .filter(|r| r.failed.is_some())
5075 .map(|r| format!("review-{}", r.reviewer))
5076 .collect();
5077 self.state.event(
5078 "review",
5079 format!(
5080 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5081 missing.join(", ")
5082 ),
5083 );
5084 }
5085
5086 if clean {
5087 self.state.event(
5088 "review",
5089 if incomplete && policy == IncompleteReviewPolicy::Warn {
5090 format!(
5091 "round {round}: clean (warn policy, incomplete panel) — no \
5092 blocking findings from the seats that answered, verification green"
5093 )
5094 } else if incomplete {
5095 format!(
5096 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5097 quorum) — no blocking findings from the seats that answered, \
5098 verification green",
5099 expected - answered
5100 )
5101 } else {
5102 format!("round {round}: clean — no blocking findings, verification green")
5103 },
5104 );
5105 self.state.reviews.push(round_record);
5106 self.state.status = RunStatus::Gating;
5107 self.state.save()?;
5108 return Ok(());
5109 }
5110
5111 if incomplete && blocking == 0 && e2e_ok {
5119 self.state.reviews.push(round_record);
5120 self.state.save()?;
5121 if round == max_rounds {
5122 self.state.status = RunStatus::Blocked;
5123 self.state.event(
5124 "review",
5125 format!(
5126 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5127 refusing to call it clean",
5128 expected - answered
5129 ),
5130 );
5131 return Ok(());
5132 }
5133 continue;
5134 }
5135
5136 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5148 self.state.reviews.push(round_record);
5149 return self
5150 .stop_reviewing(
5151 "the round's own verification could not run",
5152 &shell,
5153 &winner.worktree,
5154 )
5155 .await;
5156 }
5157
5158 if round == max_rounds {
5159 self.state.reviews.push(round_record);
5160 return self
5161 .stop_reviewing(
5162 &format!(
5163 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5164 ),
5165 &shell,
5166 &winner.worktree,
5167 )
5168 .await;
5169 }
5170
5171 let blocking_findings: Vec<_> = all_findings
5174 .iter()
5175 .filter(|f| f.severity.blocks())
5176 .cloned()
5177 .collect();
5178 let fix_prompt = prompt::fix(
5179 &self.state.instruction,
5180 &blocking_findings,
5181 this_round_verification.as_ref(),
5182 round,
5183 max_rounds,
5184 &language,
5185 );
5186 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5187 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5188 let (job, seat, out) = self
5189 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5190 prompt: fix_prompt.clone(),
5191 spec,
5192 seat,
5193 cwd: winner.worktree.clone(),
5194 timeout,
5195 allow_write: true,
5196 sessions,
5197 artifacts: artifacts.clone(),
5198 stem: format!("fix-{round}"),
5199 handover: None,
5200 })
5201 .await;
5202 let agent_id = seat.agent.clone();
5203
5204 let mut fix = FixRecord {
5205 agent: agent_id,
5206 addressed: Vec::new(),
5207 rejected: Vec::new(),
5208 notes: String::new(),
5209 committed: false,
5210 failed: None,
5211 duration_ms: 0,
5212 continuation: None,
5213 };
5214 let mut continuation = ContinuationRecord::not_needed();
5215 let mut final_seat = seat.clone();
5216 match out {
5217 AgentOutcome::Ok(o) => {
5218 fix.duration_ms = o.duration_ms;
5219 let parsed = verdict::extract_json::<FixReport>(&o.text);
5220 let incomplete_reason = match &parsed {
5227 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5228 "the reply parsed, but it reported a command whose own CLI never \
5229 confirmed an exit status"
5230 .to_owned(),
5231 ),
5232 Ok(_) => None,
5233 Err(e) => Some(e.to_string()),
5234 };
5235 match incomplete_reason {
5236 None => {
5237 let report = parsed.expect("checked Ok above");
5238 fix.addressed = report.addressed;
5239 fix.rejected = report.rejected;
5240 fix.notes =
5241 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5242 }
5243 Some(reason) => {
5244 let (resumed_seat, resolved, failure, cont) = self
5245 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5246 .await;
5247 fix.duration_ms += cont.cumulative_wait_ms;
5248 continuation = cont;
5249 final_seat = resumed_seat;
5250 match resolved {
5251 Some(report) => {
5252 fix.addressed = report.addressed;
5253 fix.rejected = report.rejected;
5254 fix.notes = blind::sanitize_prose(
5255 &report.notes,
5256 &self.state.config.blind,
5257 );
5258 }
5259 None => fix.failed = failure,
5260 }
5261 }
5262 }
5263 }
5264 AgentOutcome::Dropped(o) => {
5266 fix.duration_ms = o.duration_ms;
5267 let why = o
5268 .dropped
5269 .as_ref()
5270 .map(|d| d.why.as_str())
5271 .unwrap_or("the CLI ended the stream without delivering its answer");
5272 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5273 }
5274 AgentOutcome::Quota(o) => {
5275 self.state.quota.push(QuotaLoss {
5276 seat: final_seat.key.clone(),
5277 node: "fix".to_owned(),
5278 at: Timestamp::now(),
5279 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5280 });
5281 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5282 }
5283 AgentOutcome::Failed(e) => fix.failed = Some(e),
5284 }
5285 fix.continuation = Some(continuation);
5286 self.state.seats.insert(final_seat.key.clone(), final_seat);
5287 if let Ok(r) = git::rescue_commit(
5288 &winner.worktree,
5289 &format!("magi: review round {round} fixes (uncommitted work)"),
5290 )
5291 .await
5292 {
5293 self.state.note_withheld("fix", &r.withheld);
5294 }
5295 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5296 fix.committed = after != before;
5297 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5305 let progressed = diff_after != patch;
5306 let commit_note = if fix.committed {
5307 "committed"
5308 } else {
5309 "NO new commit"
5310 };
5311 let tree_note = if progressed {
5312 "changed vs base"
5313 } else {
5314 "unchanged vs base"
5315 };
5316 self.state.event(
5317 "fix",
5318 match &fix.failed {
5319 Some(reason) => {
5325 format!(
5326 "round {round}: fixer's adoption report was lost ({reason}); \
5327 {commit_note}, tree {tree_note}"
5328 )
5329 }
5330 None => format!(
5331 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5332 {tree_note}{}",
5333 fix.addressed.len(),
5334 fix.rejected.len(),
5335 if continuation.outcome == ContinuationOutcome::Resumed {
5336 format!(
5337 " (adoption report recovered after {} continuation(s))",
5338 continuation.attempts
5339 )
5340 } else {
5341 String::new()
5342 },
5343 ),
5344 },
5345 );
5346 round_record.fix = Some(fix);
5347 round_record.progressed = progressed;
5348 self.state.reviews.push(round_record);
5349 self.state.save()?;
5350
5351 if matches!(
5364 continuation.outcome,
5365 ContinuationOutcome::Exhausted
5366 | ContinuationOutcome::QuotaLost
5367 | ContinuationOutcome::NoSession
5368 ) {
5369 return self
5370 .stop_reviewing(
5371 "the fixer's adoption report never came back, even after resuming its \
5372 own seat; refusing to start another round against the same worktree \
5373 while that is unresolved",
5374 &shell,
5375 &winner.worktree,
5376 )
5377 .await;
5378 }
5379
5380 let streak = self
5381 .state
5382 .reviews
5383 .iter()
5384 .rev()
5385 .take_while(|r| !r.progressed)
5386 .count();
5387 if streak >= STAGNANT_LIMIT {
5388 return self
5389 .stop_reviewing(
5390 &format!(
5391 "the tree has not moved against base for {streak} round(s) in a row"
5392 ),
5393 &shell,
5394 &winner.worktree,
5395 )
5396 .await;
5397 }
5398 }
5399 Ok(())
5400 }
5401
5402 fn record_contested_handoff(&mut self) {
5436 if self.state.contested_handoff.is_some() {
5437 return;
5438 }
5439 let Some(contested) = self
5440 .state
5441 .reviews
5442 .last()
5443 .and_then(ReviewRound::contested_handoff)
5444 else {
5445 return;
5446 };
5447 self.state.event(
5448 "review",
5449 format!(
5450 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5451 wait for the owner's approval",
5452 contested.findings.len(),
5453 contested.rejecters.len()
5454 ),
5455 );
5456 self.state.contested_handoff = Some(contested);
5457 }
5458
5459 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5460 let round_idx = self.state.reviews.len() - 1;
5461 let needs_catchup_run = matches!(
5469 self.state.reviews[round_idx].e2e_status(),
5470 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5471 );
5472 if needs_catchup_run {
5473 let round = self.state.reviews[round_idx].round;
5474 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5475 let commands = self.state.config.verify.e2e.clone();
5476 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5477 let cache_dir = self.state.config.cache_dir();
5478 let context = format!(
5479 "round {round}: verification unresolved, catching up before the final decision"
5480 );
5481 let (outcomes, verify_retried) = with_cache_lease(
5482 &mut self.state,
5483 cache_dir.as_deref(),
5484 "e2e",
5485 "e2e",
5486 worktree,
5487 &attempted_head,
5488 timeout,
5489 &context,
5490 |state, budget| {
5491 let shell = shell.to_vec();
5492 let commands = commands.clone();
5493 let context = context.clone();
5494 async move {
5495 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5496 .await
5497 }
5498 },
5499 )
5500 .await;
5501 let last = &mut self.state.reviews[round_idx];
5502 last.e2e = outcomes;
5503 last.verify_retried = verify_retried;
5504 last.verified_head = Some(attempted_head);
5511 last.verified_at = Some(Timestamp::now());
5512 if verify_inconclusive(&last.e2e) {
5513 self.state.save()?;
5520 return Ok(());
5521 }
5522 last.e2e_deferred = false;
5523 }
5524 let last = &self.state.reviews[round_idx];
5525 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5526
5527 match last.e2e_status() {
5528 E2eStatus::Failed => {
5529 let red: Vec<String> = last
5530 .e2e
5531 .iter()
5532 .filter(|o| !o.ok())
5533 .map(|o| {
5534 format!(
5535 "`{}` -> {:?}\n{}",
5536 o.command,
5537 o.code,
5538 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5539 )
5540 })
5541 .collect();
5542 self.state
5543 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5544 self.state.status = RunStatus::Blocked;
5545 }
5546 E2eStatus::ResourceBlocked => {
5551 self.state.event(
5552 "review",
5553 format!(
5554 "{why}; e2e could not run (shared build cache unavailable); not \
5555 deciding yet"
5556 ),
5557 );
5558 }
5559 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5560 self.state.event(
5561 "review",
5562 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5563 );
5564 self.record_contested_handoff();
5565 self.state.status = RunStatus::Gating;
5566 }
5567 }
5568 self.state.save()?;
5569 Ok(())
5570 }
5571
5572 async fn gate(&mut self) -> Result<()> {
5575 if self.state.status == RunStatus::Failed
5587 || self
5588 .state
5589 .base_sync
5590 .as_ref()
5591 .is_some_and(|s| s.conflict.is_some())
5592 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5593 != Some(RunStatus::Gating)
5594 {
5595 return Ok(());
5596 }
5597 if self.state.gate_ran {
5598 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5609 self.state.status = RunStatus::Blocked;
5610 self.state.save()?;
5611 }
5612 return Ok(());
5613 }
5614 let Some(winner) = self.state.winner().cloned() else {
5615 return Ok(());
5616 };
5617 self.state.status = RunStatus::Gating;
5618 let mut outcomes = self.run_gate(&winner).await?;
5619 loop {
5620 if verify_inconclusive(&outcomes) {
5631 self.state.save()?;
5632 return Ok(());
5633 }
5634 if outcomes.iter().all(CommandOutcome::ok) {
5635 break;
5636 }
5637 match self.gate_fix_round(&winner, &outcomes).await? {
5638 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5639 GateFix::Stop => break,
5640 GateFix::Defer => {
5641 self.state.save()?;
5642 return Ok(());
5643 }
5644 }
5645 }
5646 let passed = outcomes.iter().all(CommandOutcome::ok);
5647 self.state.gate = outcomes;
5648 self.state.gate_ran = true;
5649 if !passed {
5650 self.state.status = RunStatus::Blocked;
5651 let spent = self.state.gate_fixes.len();
5652 self.state.event(
5653 "gate",
5654 if spent == 0 {
5655 "gate failed; not merging".to_owned()
5656 } else {
5657 format!("gate failed after {spent} gate-fix round(s); not merging")
5658 },
5659 );
5660 }
5661 self.state.save()?;
5662 Ok(())
5663 }
5664
5665 async fn run_pre_gate(&mut self, winner: &Candidate) {
5675 let commands = self.state.config.verify.pre_gate.clone();
5676 if commands.is_empty() {
5677 return;
5678 }
5679 let shell = self.state.config.shell();
5680 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5681 let (outcomes, _) = run_commands(
5682 &mut self.state,
5683 "pre_gate",
5684 "pre_gate",
5685 0,
5686 &shell,
5687 &commands,
5688 &winner.worktree,
5689 timeout,
5690 )
5691 .await;
5692 for o in &outcomes {
5693 if !o.ok() {
5694 tracing::warn!(
5695 "pre_gate `{}` failed ({:?}); the gate decides",
5696 o.command,
5697 o.code
5698 );
5699 }
5700 self.state.event(
5701 "pre_gate",
5702 format!(
5703 "`{}` -> {}",
5704 o.command,
5705 if o.ok() {
5706 "pass".to_owned()
5707 } else {
5708 format!(
5709 "FAIL ({:?})\n{}",
5710 o.code,
5711 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5712 )
5713 }
5714 ),
5715 );
5716 }
5717 self.state.pre_gate = outcomes;
5718 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5719 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5720 Ok(head) => {
5721 self.state
5722 .event("pre_gate", format!("committed mechanical fixes ({head})"));
5723 self.state.pre_gate_commit = Some(head);
5724 }
5725 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5726 },
5727 Ok(false) => {}
5728 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5729 }
5730 if let Err(e) = self.state.save() {
5731 tracing::warn!("could not persist the pre_gate record: {e:#}");
5732 }
5733 }
5734
5735 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5738 self.run_pre_gate(winner).await;
5739 let shell = self.state.config.shell();
5740 let gate_commands = self.state.config.verify.gate.clone();
5741 let outcomes = if gate_commands.is_empty() {
5750 Vec::new()
5751 } else {
5752 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5753 let cache_dir = self.state.config.cache_dir();
5754 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5755 let (outcomes, _) = with_cache_lease(
5756 &mut self.state,
5757 cache_dir.as_deref(),
5758 "gate",
5759 "gate",
5760 &winner.worktree,
5761 &head,
5762 timeout,
5763 "final gate",
5764 |state, budget| {
5765 let shell = shell.clone();
5766 let gate_commands = gate_commands.clone();
5767 let worktree = winner.worktree.clone();
5768 async move {
5769 let (outcomes, timed_out_pids) = run_commands(
5770 state,
5771 "gate",
5772 "gate",
5773 0,
5774 &shell,
5775 &gate_commands,
5776 &worktree,
5777 budget,
5778 )
5779 .await;
5780 (outcomes, false, timed_out_pids)
5781 }
5782 },
5783 )
5784 .await;
5785 outcomes
5786 };
5787 if outcomes.is_empty() {
5788 self.state.event(
5793 "gate",
5794 "no gate commands configured; nothing to check, passing",
5795 );
5796 }
5797 for o in &outcomes {
5798 self.state.event(
5799 "gate",
5800 format!(
5801 "`{}` -> {}",
5802 o.command,
5803 if o.ok() {
5804 "pass".to_owned()
5805 } else {
5806 format!(
5807 "FAIL ({:?})\n{}",
5808 o.code,
5809 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5810 )
5811 }
5812 ),
5813 );
5814 }
5815 Ok(outcomes)
5816 }
5817
5818 async fn gate_fix_round(
5831 &mut self,
5832 winner: &Candidate,
5833 outcomes: &[CommandOutcome],
5834 ) -> Result<GateFix> {
5835 let cap = self.state.config.graph.gate_fix_rounds;
5836 let spent = self.state.gate_fixes.len();
5837 if spent >= cap {
5838 if cap > 0 {
5839 self.state.event(
5840 "gate",
5841 format!("{spent} gate-fix round(s) spent and the gate still fails"),
5842 );
5843 }
5844 return Ok(GateFix::Stop);
5845 }
5846 if !gate_fixable(outcomes) {
5847 self.state.event(
5848 "gate",
5849 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5850 command or similar); not spending a fix round on it",
5851 );
5852 return Ok(GateFix::Stop);
5853 }
5854 let min_free = self.state.config.disk.min_free_bytes;
5855 if min_free > 0 {
5856 match crate::disk::free_bytes(&winner.worktree) {
5857 Ok(free) if crate::disk::enough_space(free, min_free) => {}
5858 Ok(free) => {
5859 self.state.event(
5860 "gate",
5861 format!(
5862 "only {free} bytes free ({min_free} required by `[disk] \
5863 min_free_bytes`); not spending a fix round on a failure the disk \
5864 may explain"
5865 ),
5866 );
5867 return Ok(GateFix::Stop);
5868 }
5869 Err(e) => {
5870 self.state.event(
5871 "gate",
5872 format!("free disk space could not be measured ({e:#}); no fix round"),
5873 );
5874 return Ok(GateFix::Stop);
5875 }
5876 }
5877 }
5878
5879 let attempt = spent + 1;
5880 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5881 let base = self.landing_base();
5882 let fix_prompt = prompt::gate_fix(
5883 &self.state.instruction,
5884 &failed,
5885 attempt,
5886 cap,
5887 &self.state.config.graph.language,
5888 );
5889 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5890 let sessions = self.state.config.graph.sessions;
5891 let artifacts = agent::artifacts_dir(&self.state.dir());
5892 self.state.event(
5893 "gate",
5894 format!("gate failed; gate-fix round {attempt} of {cap}"),
5895 );
5896 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5897 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5898 let (_, seat, out) = self
5899 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
5900 prompt: fix_prompt.clone(),
5901 spec,
5902 seat,
5903 cwd: winner.worktree.clone(),
5904 timeout,
5905 allow_write: true,
5906 sessions,
5907 artifacts: artifacts.clone(),
5908 stem: format!("gate-fix-{attempt}"),
5909 handover: None,
5910 })
5911 .await;
5912 let mut record = GateFixRecord {
5913 agent: seat.agent.clone(),
5914 failed,
5915 notes: String::new(),
5916 committed: false,
5917 error: None,
5918 };
5919 match out {
5920 AgentOutcome::Ok(o) => {
5921 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5924 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5925 }
5926 }
5927 AgentOutcome::Dropped(_) => {
5928 record.error = Some("the CLI dropped the stream".to_owned());
5929 }
5930 AgentOutcome::Quota(o) => {
5931 self.state.quota.push(QuotaLoss {
5932 seat: seat.key.clone(),
5933 node: "gate-fix".to_owned(),
5934 at: Timestamp::now(),
5935 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5936 });
5937 record.error = Some("rate limited (quota); fixer could not run".to_owned());
5938 }
5939 AgentOutcome::Failed(e) => record.error = Some(e),
5940 }
5941 self.state.seats.insert(seat.key.clone(), seat);
5942 if let Ok(r) = git::rescue_commit(
5943 &winner.worktree,
5944 &format!("magi: gate fix {attempt} (uncommitted work)"),
5945 )
5946 .await
5947 {
5948 self.state.note_withheld("gate-fix", &r.withheld);
5949 }
5950 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5951 record.committed = after != before;
5952 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5953 let note = record.error.clone();
5954 self.state.gate_fixes.push(record);
5955 self.state.save()?;
5956 if !changed {
5957 self.state.event(
5958 "gate",
5959 match note {
5960 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5961 None => format!("gate-fix round {attempt}: the tree did not change"),
5962 },
5963 );
5964 return Ok(GateFix::Stop);
5965 }
5966 self.state.event(
5967 "gate",
5968 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5969 );
5970
5971 let commands = self.state.config.verify.e2e.clone();
5972 if !commands.is_empty() {
5973 let shell = self.state.config.shell();
5974 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5975 let cache_dir = self.state.config.cache_dir();
5976 let context = format!("gate-fix round {attempt}");
5977 let (e2e, _) = with_cache_lease(
5978 &mut self.state,
5979 cache_dir.as_deref(),
5980 "e2e",
5981 "e2e",
5982 &winner.worktree,
5983 &after,
5984 timeout,
5985 &context,
5986 |state, budget| {
5987 let shell = shell.clone();
5988 let commands = commands.clone();
5989 let context = context.clone();
5990 let worktree = winner.worktree.clone();
5991 async move {
5992 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5993 .await
5994 }
5995 },
5996 )
5997 .await;
5998 if verify_inconclusive(&e2e) {
5999 return Ok(GateFix::Defer);
6000 }
6001 if e2e.iter().any(|o| !o.ok()) {
6002 self.state.event(
6003 "gate",
6004 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6005 );
6006 return Ok(GateFix::Stop);
6007 }
6008 }
6009 Ok(GateFix::Retry)
6010 }
6011
6012 async fn guarded_pr_message(
6019 &mut self,
6020 winner: &Candidate,
6021 facts: Option<&BranchFacts>,
6022 posting: bool,
6023 ) -> Result<Option<PrMessage>> {
6024 let mut pr = pr_message_raw(&self.state, winner.label, facts);
6025 if !posting {
6026 let identity = crate::scrub::Identity::current();
6027 return Ok(Some(PrMessage {
6028 title: crate::scrub::scrub(&pr.title, &identity),
6029 body: crate::scrub::scrub(&pr.body, &identity),
6030 }));
6031 }
6032 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6035 if let Some(known) = self
6036 .state
6037 .github_text
6038 .clone()
6039 .filter(|g| g.fingerprint == raw_fp)
6040 {
6041 return self.settle_withheld(winner, &pr, known).await;
6042 }
6043 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6044 let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6045 let identity = crate::scrub::Identity::current();
6046 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6047 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6048 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6049 {
6050 violations.push(crate::github_text::Violation::SensitiveData);
6051 }
6052 violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
6056 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6057 self.state.event(
6058 "github-text",
6059 format!("description rejected: {violations:?}; requesting one rewrite"),
6060 );
6061 let mut rewritten = false;
6062 if let Some(spec) = self
6063 .state
6064 .config
6065 .agents
6066 .iter()
6067 .find(|a| a.id == winner.agent)
6068 .cloned()
6069 {
6070 let key = format!("impl-{}", winner.label);
6071 let seat = self.seat(&key, &spec.id);
6072 let prompt = format!(
6073 "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{}",
6074 pr.title, pr.body
6075 );
6076 let job = SeatJob {
6077 spec,
6078 seat,
6079 cwd: winner.worktree.clone(),
6080 prompt,
6081 timeout: retry_budget(
6082 Duration::from_secs(self.state.config.graph.timeout_implement),
6083 true,
6084 ),
6085 allow_write: false,
6086 sessions: self.state.config.graph.sessions,
6087 artifacts: agent::artifacts_dir(&self.state.dir()),
6088 stem: "github-text-rewrite".to_owned(),
6089 handover: None,
6090 };
6091 let prompts = self.state.config.prompts.clone();
6092 let cache = self.state.config.cache_dir();
6093 let run = self.state.id.clone();
6094 let ctx = WaveCtx {
6095 run: &run,
6096 node: "github-text",
6097 prompts: &prompts,
6098 cache: cache.as_deref(),
6099 round: None,
6100 carry_seats: false,
6101 };
6102 let (seat, outcome) =
6103 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6104 self.state.seats.insert(seat.key.clone(), seat);
6105 if let AgentOutcome::Ok(output) = outcome
6106 && let Some(title) = summary_title(&output.text)
6107 {
6108 let mut body = summary_without_title(&output.text);
6109 let rewrite = PrMessage {
6110 title: title.clone(),
6111 body: body.clone(),
6112 };
6113 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6114 if !body.trim().is_empty()
6115 && crate::github_text::check_with(&title, &body, redecision).is_empty()
6116 {
6117 decision = redecision;
6118 let marker = format!("magi:run/{}", self.state.id);
6119 if !body.contains(&marker) {
6120 body.push_str(&format!("\n\n{marker}"));
6121 }
6122 pr = PrMessage { title, body };
6123 rewritten = true;
6124 self.state
6125 .event("github-text", "description rewrite passed");
6126 }
6127 }
6128 }
6129 if !rewritten {
6130 self.state.event(
6131 "github-text",
6132 "description rewrite unavailable or rejected; asking the owner",
6133 );
6134 if let Some(w) = crate::github_text::Withheld::from_violations(&violations) {
6135 self.retire_github_text_question("the withheld text changed");
6138 let record = crate::run::GithubTextAsk {
6139 fingerprint: raw_fp,
6140 title: w.title,
6141 body: w.body,
6142 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6143 question: None,
6144 asks: 0,
6145 resolved: false,
6146 chosen_title: None,
6147 };
6148 return self.settle_withheld(winner, &pr, record).await;
6149 }
6150 }
6151 }
6152 let (title, body) =
6153 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6154 Ok(Some(PrMessage { title, body }))
6155 }
6156
6157 fn retire_github_text_question(&mut self, why: &str) {
6159 let Some(id) = self
6160 .state
6161 .github_text
6162 .as_ref()
6163 .and_then(|g| g.question.clone())
6164 else {
6165 return;
6166 };
6167 let _ = ask::Questions::open().update(&id, |q| {
6168 q.abandon(why);
6169 Ok(())
6170 });
6171 }
6172
6173 async fn settle_withheld(
6177 &mut self,
6178 winner: &Candidate,
6179 pr: &PrMessage,
6180 mut g: crate::run::GithubTextAsk,
6181 ) -> Result<Option<PrMessage>> {
6182 use crate::github_text as gt;
6183 let store = ask::Questions::open();
6184 let timeout = self.state.config.graph.answer_timeout;
6185 if !g.resolved {
6186 let mut standing = g
6187 .question
6188 .as_ref()
6189 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6190 if let Some(q) = standing.as_ref()
6191 && q.status.open()
6192 && gt::expired(q, timeout)
6193 {
6194 let why = format!("no answer within {}s of asking", timeout.max(1));
6195 standing = store
6196 .update(&q.id, |q| {
6197 q.abandon(&why);
6198 Ok(())
6199 })
6200 .ok()
6201 .map(|(q, ())| q);
6202 }
6203 let mut ask_again: Option<(Vec<String>, bool)> = None;
6204 match standing {
6205 None if g.asks <= 1 => {
6208 g.asks = 0;
6209 ask_again = Some((g.categories.clone(), false));
6210 }
6211 None => {
6212 g.resolved = true;
6213 g.chosen_title = None;
6214 }
6215 Some(q) if q.status.open() => {
6216 self.park_on_github_text(&g)?;
6217 return Ok(None);
6218 }
6219 Some(q) => match gt::read_reply(&q) {
6220 gt::Reply::Fallback => {
6221 g.resolved = true;
6222 g.chosen_title = None;
6223 }
6224 gt::Reply::Title(text) => {
6225 let first = text
6226 .lines()
6227 .map(str::trim)
6228 .find(|l| !l.is_empty())
6229 .unwrap_or("");
6230 let decision = if first.is_empty() {
6231 None
6232 } else {
6233 let candidate = PrMessage {
6234 title: first.to_owned(),
6235 body: String::new(),
6236 };
6237 self.judge_pr_language(&winner.worktree, &candidate).await
6238 };
6239 match gt::vet_title(&text, decision) {
6240 Ok(title) => {
6241 g.resolved = true;
6242 g.chosen_title = Some(title);
6243 }
6244 Err(cats) if g.asks < 2 => {
6245 ask_again =
6246 Some((cats.into_iter().map(str::to_owned).collect(), true))
6247 }
6248 Err(_) => {
6249 g.resolved = true;
6250 g.chosen_title = None;
6251 }
6252 }
6253 }
6254 },
6255 }
6256 if let Some((categories, retry)) = ask_again {
6257 let w = gt::Withheld {
6258 title: g.title,
6259 body: g.body,
6260 categories,
6261 };
6262 let mut q = ask::Question::new(
6263 self.state.id.clone(),
6264 gt::ASK_NODE.to_owned(),
6265 gt::ASK_SEAT.to_owned(),
6266 gt::question_summary(&self.state.config.graph.language, &w),
6267 gt::question_detail(&w, &pr.title, &pr.body, retry),
6268 gt::question_choices(&w),
6269 );
6270 q.answer_timeout = timeout;
6271 g.question = Some(q.id.clone());
6274 g.asks += 1;
6275 self.state.github_text = Some(g.clone());
6276 self.state.event(
6277 "github-text",
6278 format!("asking the owner about the withheld text ({})", q.short()),
6279 );
6280 self.state.save()?;
6281 store
6282 .put(&mut q)
6283 .context("file the posting-gate question")?;
6284 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6285 tracing::warn!(
6286 "could not notify about posting-gate question {}: {e:#}",
6287 q.short()
6288 );
6289 }
6290 self.park_on_github_text(&g)?;
6291 return Ok(None);
6292 }
6293 }
6294 let resolved = g.chosen_title.clone();
6295 self.state.event(
6296 "github-text",
6297 if resolved.is_some() {
6298 "posting the owner's replacement title"
6299 } else {
6300 "posting the neutral text for the withheld fields"
6301 },
6302 );
6303 self.state.github_text = Some(g.clone());
6304 self.state.save()?;
6305 let title = match (g.title, resolved) {
6306 (true, Some(t)) => t,
6307 (true, None) => gt::NEUTRAL_TITLE.to_owned(),
6308 (false, _) => pr.title.clone(),
6309 };
6310 let body = if g.body {
6311 format!("{}\n\nmagi:run/{}", gt::NEUTRAL_BODY, self.state.id)
6312 } else {
6313 pr.body.clone()
6314 };
6315 let english = gt::LanguageDecision {
6317 title_english: true,
6318 body_english: true,
6319 body_complete: true,
6320 };
6321 let (title, body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6322 Ok(Some(PrMessage { title, body }))
6323 }
6324
6325 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6326 self.state.github_text = Some(g.clone());
6327 self.state.parked = true;
6328 self.state.event(
6329 "github-text",
6330 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6331 );
6332 self.state.save()
6333 }
6334
6335 async fn judge_pr_language(
6338 &mut self,
6339 cwd: &Path,
6340 pr: &PrMessage,
6341 ) -> Option<crate::github_text::LanguageDecision> {
6342 self.state.config.roles.language_judge.as_ref()?;
6343 let decision =
6344 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6345 self.state.event(
6346 "github-text",
6347 if decision.is_some() {
6348 "language decided by the language judge"
6349 } else {
6350 "language judge unavailable; using built-in heuristics"
6351 },
6352 );
6353 decision
6354 }
6355
6356 async fn merge(&mut self) -> Result<()> {
6357 if self
6372 .state
6373 .base_sync
6374 .as_ref()
6375 .is_some_and(|s| s.conflict.is_some())
6376 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6377 != Some(RunStatus::Gating)
6378 || !self.state.gate_status().ok()
6387 {
6388 return Ok(());
6389 }
6390 if self.state.merge.is_some() {
6399 return Ok(());
6400 }
6401 let Some(winner) = self.state.winner().cloned() else {
6402 return Ok(());
6403 };
6404 let repo = self.state.repo.clone();
6405 let base = self.state.base_branch.clone();
6406 let mode = self.state.config.merge.mode;
6407 let style = self.state.config.merge.style;
6408 let facts = if is_review_run(&self.state) {
6409 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6410 let start = review_base(
6411 &repo,
6412 &self.state.config.merge.remote,
6413 &base,
6414 &self.state.base_commit,
6415 &winner.branch,
6416 )
6417 .await;
6418 branch_facts(&repo, &start, &winner.branch).await
6419 } else {
6420 None
6421 };
6422 let leaked = if is_review_run(&self.state) {
6425 leaked_subjects(&self.state, &winner.branch).await
6426 } else {
6427 Some(Vec::new())
6428 };
6429 let Some(pr) = self
6433 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6434 .await?
6435 else {
6436 return Ok(());
6437 };
6438 let message = pr.commit_message();
6439
6440 let outcome = match mode {
6441 MergeMode::None => MergeOutcome {
6442 mode,
6443 ok: true,
6444 detail: manual_merge_command(style, &repo, &winner.branch, &message),
6445 empty: false,
6446 },
6447 MergeMode::Pr | MergeMode::Local
6448 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6449 {
6450 MergeOutcome {
6451 mode,
6452 ok: false,
6453 detail: empty_candidate_detail(&self.state, &base),
6454 empty: true,
6455 }
6456 }
6457 MergeMode::Local => {
6458 let on = git::current_branch(&repo).await?;
6459 if on.as_deref() != Some(base.as_str()) {
6460 MergeOutcome {
6461 mode,
6462 ok: false,
6463 detail: format!(
6464 "{} has {} checked out, not the base branch {base}",
6465 repo.display(),
6466 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6467 ),
6468 empty: false,
6469 }
6470 } else if !git::is_clean(&repo).await? {
6471 MergeOutcome {
6472 mode,
6473 ok: false,
6474 detail: format!("{} is dirty; refusing to merge", repo.display()),
6475 empty: false,
6476 }
6477 } else {
6478 let out = match style {
6479 MergeStyle::Merge => {
6480 git::merge_no_ff(&repo, &winner.branch, &message).await?
6481 }
6482 MergeStyle::Squash => {
6483 git::merge_squash(&repo, &winner.branch, &message).await?
6484 }
6485 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6486 };
6487 MergeOutcome {
6488 mode,
6489 ok: out.ok(),
6490 detail: if out.ok() { out.stdout } else { out.stderr },
6491 empty: false,
6492 }
6493 }
6494 }
6495 MergeMode::Pr => {
6496 let remote = self.state.config.merge.remote.clone();
6497 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6498 if !pushed.ok() {
6499 MergeOutcome {
6500 mode,
6501 ok: false,
6502 detail: pushed.stderr,
6503 empty: false,
6504 }
6505 } else {
6506 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6512 let out = match pr_merge_plan(found) {
6513 PrPlan::Create => {
6514 gh_pr_create(
6515 &winner.worktree,
6516 &base,
6517 &winner.branch,
6518 &pr.title,
6519 &pr.body,
6520 )
6521 .await
6522 }
6523 PrPlan::Adopt { url, title } => {
6524 self.state
6525 .event("merge", format!("Pr: adopted open pull request {url}"));
6526 if title != pr.title
6527 && (!is_review_run(&self.state)
6528 || leaked
6529 .as_deref()
6530 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6531 && let Err(e) = land::set_pr_title(
6532 &mut self.state,
6533 &winner.worktree,
6534 &url,
6535 &pr.title,
6536 )
6537 .await
6538 {
6539 tracing::warn!("could not refresh title of {url}: {e:#}");
6540 self.state
6541 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6542 }
6543 Ok(url)
6544 }
6545 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6546 };
6547 match out {
6548 Ok(url) => MergeOutcome {
6549 mode,
6550 ok: true,
6551 detail: url,
6552 empty: false,
6553 },
6554 Err(e) => MergeOutcome {
6555 mode,
6556 ok: false,
6557 detail: e.to_string(),
6558 empty: false,
6559 },
6560 }
6561 }
6562 }
6563 };
6564
6565 self.state.status = match (mode, outcome.ok) {
6566 (MergeMode::None, _) => RunStatus::Ready,
6567 (_, true) => RunStatus::Merged,
6568 (_, false) => RunStatus::Blocked,
6569 };
6570 self.state.event(
6571 "merge",
6572 format!(
6573 "{:?}: {}",
6574 mode,
6575 outcome.detail.lines().next().unwrap_or("")
6576 ),
6577 );
6578 self.state.merge = Some(outcome);
6579 self.state.save()?;
6580
6581 if self.state.config.graph.land
6587 && mode == MergeMode::Pr
6588 && self.state.status == RunStatus::Merged
6589 {
6590 self.run_land().await?;
6591 }
6592 self.settle_questions();
6597 Ok(())
6598 }
6599
6600 async fn run_land(&mut self) -> Result<()> {
6611 let url = self
6612 .state
6613 .merge
6614 .as_ref()
6615 .map(|m| m.detail.clone())
6616 .unwrap_or_default();
6617 let url = url.lines().next().unwrap_or("").trim().to_owned();
6618 if !url.starts_with("http") {
6619 return Ok(());
6620 }
6621 match land::land(&mut self.state, &url).await {
6624 Ok(pr) if self.state.parked => {
6625 let _ = pr;
6629 }
6630 Ok(pr) => {
6631 self.state.status = match pr.state {
6632 land::PrLifecycle::Merged => RunStatus::Merged,
6633 _ => RunStatus::Blocked,
6634 };
6635 if bump::should_release_bump(self.state.status)
6642 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6643 {
6644 self.state
6650 .event("bump", format!("release bump skipped: {e:#}"));
6651 }
6652 if self.state.status == RunStatus::Merged {
6655 crate::followup::after_merge(&mut self.state, &pr.url).await;
6656 }
6657 self.state.save()?;
6658 }
6659 Err(e) => {
6660 self.state.status = RunStatus::Blocked;
6661 self.state.event("land", format!("gave up: {e}"));
6662 self.state.save()?;
6663 }
6664 }
6665 Ok(())
6666 }
6667
6668 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6672 if let Some(existing) = self.state.seats.get(key)
6673 && existing.agent == agent
6674 {
6675 return existing.clone();
6676 }
6677 let fresh = if self.state.seats.contains_key(key) {
6680 handover_seat(key, agent, self.state.next_seat_seed())
6681 } else {
6682 SeatState::new(key, agent, self.state.seed)
6683 };
6684 self.state.seats.insert(key.to_owned(), fresh.clone());
6685 fresh
6686 }
6687
6688 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6694 match self.state.seats.get(key) {
6695 Some(s) if s.agent != spec.id => {
6696 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6697 }
6698 _ => spec,
6699 }
6700 }
6701
6702 fn view(&self, c: &Candidate) -> CandidateView {
6704 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6705 .unwrap_or_default();
6706 let (patch, _) = blind::sanitize_patch(
6707 &format!("candidate {} patch", c.label),
6708 &raw,
6709 &self.state.config.blind,
6710 );
6711 CandidateView {
6712 label: c.label,
6713 branch: c.branch.clone(),
6714 summary: c.summary.clone(),
6715 stat: c.stat.clone(),
6716 patch,
6717 }
6718 }
6719
6720 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6722 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6723 prompt::judge(
6724 "(see above)",
6725 &views,
6726 self.roles.judges.len(),
6727 base_short,
6728 "en",
6729 )
6730 }
6731
6732 fn vote_prompt_full(
6738 &self,
6739 j: usize,
6740 labels: &[char],
6741 language: &str,
6742 candidates: &[Candidate],
6743 base_short: &str,
6744 ) -> String {
6745 let mut text = format!(
6746 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6747 prompt::final_vote(labels, language),
6748 self.state.instruction,
6749 self.candidate_block(candidates, base_short)
6750 );
6751 if let Some(own) = self
6752 .state
6753 .judgements
6754 .get(j)
6755 .filter(|r| !r.ranking.is_empty())
6756 {
6757 let reasons = own
6758 .reasons
6759 .iter()
6760 .map(|(k, v)| format!("- {k}: {v}"))
6761 .collect::<Vec<_>>()
6762 .join("\n");
6763 text.push_str(&format!(
6764 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6765 own.ranking.iter().collect::<String>(),
6766 if reasons.is_empty() {
6767 ""
6768 } else {
6769 ", because:\n"
6770 }
6771 ));
6772 }
6773 if !self.state.deliberation.is_empty() {
6774 text.push_str("\n# What was argued before this vote\n");
6775 for t in self.transcript(&[], j) {
6776 text.push_str(&format!(
6777 "\n## {}{}\n\n{}\n",
6778 t.who,
6779 if t.is_self { " (you)" } else { "" },
6780 t.body.trim()
6781 ));
6782 }
6783 }
6784 text
6785 }
6786
6787 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6794 let mut turns = Vec::new();
6795 for j in &self.state.judgements {
6796 if j.ranking.is_empty() {
6797 continue;
6798 }
6799 let reasons = j
6800 .reasons
6801 .iter()
6802 .map(|(k, v)| format!("- {k}: {v}"))
6803 .collect::<Vec<_>>()
6804 .join("\n");
6805 turns.push(Turn {
6806 who: format!("Judge {} (opening ranking)", j.judge),
6807 is_self: j.judge == self_idx + 1,
6808 body: format!(
6809 "Ranked {}{}{reasons}",
6810 j.ranking.iter().collect::<String>(),
6811 if reasons.is_empty() {
6812 ""
6813 } else {
6814 ", because:\n"
6815 }
6816 ),
6817 });
6818 }
6819 for t in self
6820 .state
6821 .deliberation
6822 .iter()
6823 .flat_map(|r| r.turns.iter())
6824 .chain(current)
6825 {
6826 turns.push(Turn {
6827 who: format!("Judge {}", t.judge),
6828 is_self: t.judge == self_idx + 1,
6829 body: t.body.clone(),
6830 });
6831 }
6832 turns
6833 }
6834}
6835
6836fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6838 agent::has_session(spec.kind, seat, sessions)
6839}
6840
6841fn next_untried_in_roster<'a>(
6862 roster: &'a [AgentSpec],
6863 start: usize,
6864 tried: &BTreeSet<String>,
6865) -> Option<&'a AgentSpec> {
6866 roster
6867 .get(start + 1..)?
6868 .iter()
6869 .find(|s| !tried.contains(&s.id))
6870}
6871
6872fn pick_successor<'a>(
6893 roster: &'a [AgentSpec],
6894 start: usize,
6895 tried: &BTreeSet<String>,
6896 carried: Option<&BTreeSet<String>>,
6897 others: &BTreeSet<String>,
6898) -> Option<&'a AgentSpec> {
6899 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6900 roster
6901 .get(start + 1..)
6902 .and_then(|tail| {
6903 tail.iter()
6904 .filter(free)
6905 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6906 })
6907 .or_else(|| {
6908 carried
6909 .is_some()
6910 .then(|| roster.iter().find(free))
6911 .flatten()
6912 })
6913 .or_else(|| match carried {
6914 Some(c) => next_for_seat(roster, start, tried, c),
6915 None => next_untried_in_roster(roster, start, tried),
6916 })
6917}
6918
6919fn next_for_seat<'a>(
6929 roster: &'a [AgentSpec],
6930 start: usize,
6931 round_tried: &BTreeSet<String>,
6932 carried_failed: &BTreeSet<String>,
6933) -> Option<&'a AgentSpec> {
6934 roster
6935 .get(start + 1..)?
6936 .iter()
6937 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6938 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6939}
6940
6941fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6947 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6948 return spec;
6949 };
6950 let ok = |id: &str| !h.failed.contains(id);
6951 if let Some(last) = h.last_ok.as_deref()
6952 && ok(last)
6953 && let Some(s) = roster.iter().find(|s| s.id == last)
6954 {
6955 return s.clone();
6956 }
6957 if ok(&spec.id) {
6958 return spec;
6959 }
6960 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6961 roster
6962 .get(from + 1..)
6963 .into_iter()
6964 .flatten()
6965 .chain(roster.iter())
6966 .find(|s| ok(&s.id))
6967 .cloned()
6968 .unwrap_or(spec)
6969}
6970
6971pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6975 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6976}
6977
6978const TIMED_OUT: &str = "timed out";
6981
6982impl FailClass {
6983 fn of(out: &AgentOutcome) -> Option<Self> {
6985 match out {
6986 AgentOutcome::Ok(_) => None,
6987 AgentOutcome::Quota(_) => Some(Self::Quota),
6988 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6989 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6990 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6991 }
6992 }
6993
6994 fn stem_word(&self) -> &'static str {
6996 match self {
6997 Self::Quota => "quota",
6998 _ => "handover",
6999 }
7000 }
7001}
7002
7003fn failure_signature(msg: &str) -> String {
7007 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7008 let mut out = Vec::new();
7009 for word in line.split_whitespace() {
7010 if word.contains('/') || word.contains('\\') {
7011 out.push("<path>".to_owned());
7012 continue;
7013 }
7014 let mut w = String::new();
7015 let mut in_digits = false;
7016 for c in word.chars() {
7017 if c.is_ascii_digit() {
7018 if !in_digits {
7019 w.push('#');
7020 }
7021 in_digits = true;
7022 } else {
7023 in_digits = false;
7024 w.push(c);
7025 }
7026 }
7027 out.push(w);
7028 }
7029 out.join(" ").chars().take(120).collect()
7030}
7031
7032fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7039 match cur {
7040 FailClass::Quota | FailClass::Timeout => true,
7041 FailClass::Other(_) => prev != Some(cur),
7042 }
7043}
7044
7045fn fail_reason(out: &AgentOutcome) -> String {
7047 match out {
7048 AgentOutcome::Ok(_) => String::new(),
7049 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7050 AgentOutcome::Dropped(o) => format!(
7051 "the CLI dropped the stream ({})",
7052 o.dropped
7053 .as_ref()
7054 .map(|d| d.why.as_str())
7055 .unwrap_or("it ended without delivering its answer")
7056 ),
7057 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7058 }
7059}
7060
7061pub(crate) fn record_handover(
7064 state: &mut RunState,
7065 node: &str,
7066 seat: &str,
7067 from: &str,
7068 to: &str,
7069 class: &FailClass,
7070 reason: &str,
7071) {
7072 let message = if *class == FailClass::Quota {
7073 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7074 } else {
7075 format!("{seat}: handed over {from} -> {to} ({reason})")
7076 };
7077 state.event(node, message);
7078 state.handovers.push(Handover {
7079 at: Timestamp::now(),
7080 node: node.to_owned(),
7081 seat: seat.to_owned(),
7082 from: from.to_owned(),
7083 to: to.to_owned(),
7084 reason: reason.to_owned(),
7085 });
7086}
7087
7088fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7102 commands.iter().any(|c| c.exit_code.is_none())
7103}
7104
7105fn verified_noop_claim(
7118 usable: bool,
7119 commands: &[agent::CommandEvidence],
7120 text: &str,
7121) -> Option<String> {
7122 (usable && !has_unconfirmed_command(commands))
7123 .then(|| verdict::verified_noop(text))
7124 .flatten()
7125}
7126
7127fn short(commit: &str) -> String {
7128 commit.chars().take(7).collect()
7129}
7130
7131fn make_executable(path: &Path) -> Result<()> {
7132 #[cfg(unix)]
7133 {
7134 use std::os::unix::fs::PermissionsExt as _;
7135 let mut perms = std::fs::metadata(path)?.permissions();
7136 perms.set_mode(0o755);
7137 std::fs::set_permissions(path, perms)?;
7138 }
7139 #[cfg(not(unix))]
7140 {
7141 let _ = path;
7142 }
7143 Ok(())
7144}
7145
7146struct WaveCtx<'a> {
7153 run: &'a str,
7156 node: &'a str,
7158 prompts: &'a Prompts,
7159 cache: Option<&'a Path>,
7161 round: Option<usize>,
7164 carry_seats: bool,
7168}
7169
7170async fn run_one(
7172 job: SeatJob,
7173 sem: Arc<Semaphore>,
7174 ctx: &WaveCtx<'_>,
7175 state: &mut RunState,
7176 attempt: usize,
7177) -> (SeatState, AgentOutcome) {
7178 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7179 .await
7180 .pop()
7181 .expect("one job in, one result out");
7182 (seat, out)
7183}
7184
7185async fn wave(
7191 jobs: Vec<SeatJob>,
7192 sem: Arc<Semaphore>,
7193 ctx: &WaveCtx<'_>,
7194 state: &mut RunState,
7195 attempt: usize,
7196) -> Vec<(usize, SeatState, AgentOutcome)> {
7197 let WaveCtx {
7198 run,
7199 node,
7200 prompts,
7201 cache,
7202 round,
7203 carry_seats: _,
7204 } = *ctx;
7205 for job in &jobs {
7206 state.seat_started(node, &job.seat.key, job.timeout, attempt);
7207 }
7208 if let Err(e) = state.save() {
7209 tracing::warn!("could not persist in-progress seats: {e:#}");
7214 }
7215 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7231 let wait_started = Instant::now();
7232 let cache_guard = if let Some(cache_dir) = cache {
7233 if jobs_had_a_writer {
7234 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7235 let budget = jobs
7236 .iter()
7237 .map(|j| j.timeout)
7238 .max()
7239 .unwrap_or(Duration::from_secs(60));
7240 acquire_cache_lease(state, cache_dir, &owner, budget, node)
7241 .await
7242 .ok()
7243 } else {
7244 None
7245 }
7246 } else {
7247 None
7248 };
7249 let waited_for_lease = wait_started.elapsed();
7256 let mut set = tokio::task::JoinSet::new();
7257 let overlay = prompts.overlay(node);
7258 for (i, mut job) in jobs.into_iter().enumerate() {
7259 job.timeout = job.timeout.saturating_sub(waited_for_lease);
7260 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7261 if cache.is_some() {
7262 job.prompt.push('\n');
7263 job.prompt
7264 .push_str(&prompt::build_cache_note(node, job.allow_write));
7265 }
7266 let sem = Arc::clone(&sem);
7267 let run = run.to_owned();
7268 let node = node.to_owned();
7269 let attachments = if node == "implement" {
7272 state.attachments.clone()
7273 } else {
7274 Vec::new()
7275 };
7276 let cache = cache
7287 .filter(|_| job.allow_write && cache_guard.is_some())
7288 .map(Path::to_path_buf);
7289 set.spawn(async move {
7290 let _permit = sem.acquire().await;
7291 let mut seat = job.seat;
7292 let out = agent::invoke(
7293 &job.spec,
7294 &mut seat,
7295 &Invocation {
7296 cwd: &job.cwd,
7297 prompt: &job.prompt,
7298 timeout: job.timeout,
7299 allow_write: job.allow_write,
7300 unsandboxed: false,
7301 sessions: job.sessions,
7302 artifacts: &job.artifacts,
7303 stem: &job.stem,
7304 run: &run,
7305 node: &node,
7306 cache_dir: cache.as_deref(),
7307 attachments: &attachments,
7308 writable: &[],
7309 },
7310 )
7311 .await;
7312 let out = match out {
7313 Ok(o) if o.usable() => AgentOutcome::Ok(o),
7314 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7315 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7323 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7324 Ok(o) => AgentOutcome::Failed(format!(
7325 "exited with {:?} and no usable output",
7326 o.exit_code
7327 )),
7328 Err(e) => AgentOutcome::Failed(e.to_string()),
7329 };
7330 (i, seat, out)
7331 });
7332 }
7333 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7334 while let Some(joined) = set.join_next().await {
7335 let (i, seat, out) = match joined {
7336 Ok(v) => v,
7337 Err(e) => {
7341 tracing::error!("agent task panicked: {e}");
7342 continue;
7343 }
7344 };
7345 state.seat_finished(&seat.key);
7346 record_jobs(state, node, round, &seat.key, &out);
7347 if let Err(e) = state.save() {
7348 tracing::warn!("could not persist a seat's completion: {e:#}");
7349 }
7350 if collected.len() <= i {
7351 collected.resize_with(i + 1, || None);
7352 }
7353 collected[i] = Some((i, seat, out));
7354 }
7355 if state
7361 .active
7362 .values()
7363 .any(|a| a.node == node && a.attempt == attempt)
7364 {
7365 state
7366 .active
7367 .retain(|_, a| !(a.node == node && a.attempt == attempt));
7368 if let Err(e) = state.save() {
7369 tracing::warn!("could not persist the end of a wave: {e:#}");
7370 }
7371 }
7372 if let Some(cache_dir) = cache
7379 && jobs_had_a_writer
7380 {
7381 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7382 }
7383 if let Some(guard) = cache_guard {
7384 guard.release();
7385 }
7386 collected.into_iter().flatten().collect()
7387}
7388
7389fn record_jobs(
7400 state: &mut RunState,
7401 node: &str,
7402 round: Option<usize>,
7403 seat: &str,
7404 out: &AgentOutcome,
7405) {
7406 let commands: &[agent::CommandEvidence] = match out {
7407 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7408 AgentOutcome::Failed(_) => &[],
7409 };
7410 let checked_at = Timestamp::now();
7411 for c in commands {
7412 state.jobs.push(JobRecord {
7413 node: node.to_owned(),
7414 round,
7415 seat: seat.to_owned(),
7416 id: c.id.clone(),
7417 description: c.description.clone(),
7418 checked_at,
7419 status: match c.exit_code {
7420 Some(0) => JobStatus::Completed,
7421 Some(_) => JobStatus::Failed,
7422 None => JobStatus::Unknown,
7423 },
7424 exit_code: c.exit_code,
7425 result_summary: c.result_summary.clone(),
7426 source: c.source.clone(),
7427 });
7428 }
7429}
7430
7431fn round_is_clean(
7452 blocking: usize,
7453 e2e_ok: bool,
7454 answered: usize,
7455 expected: usize,
7456 quota_missing: usize,
7457 policy: IncompleteReviewPolicy,
7458) -> bool {
7459 if blocking != 0 || !e2e_ok {
7460 return false;
7461 }
7462 if answered == expected || policy == IncompleteReviewPolicy::Warn {
7463 return true;
7464 }
7465 answered > 0 && expected - answered <= quota_missing
7466}
7467
7468fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7492 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7493 return Some(RunStatus::Gating);
7494 }
7495 let last = reviews.last()?;
7496 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7497 if reviews.len() < max_rounds && !stagnant {
7498 return None;
7499 }
7500 if last.incomplete() && last.blocking == 0 {
7501 return Some(RunStatus::Blocked);
7502 }
7503 if last.e2e_status() == E2eStatus::ResourceBlocked {
7504 return None;
7505 }
7506 Some(if last.e2e.iter().all(CommandOutcome::ok) {
7507 RunStatus::Gating
7508 } else {
7509 RunStatus::Blocked
7510 })
7511}
7512
7513pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7528 if nudged {
7529 (full / 4).max(Duration::from_secs(120)).min(full)
7530 } else {
7531 full
7532 }
7533}
7534
7535#[allow(clippy::too_many_arguments)]
7564async fn ask_json_wave<T>(
7565 jobs: Vec<SeatJob>,
7566 sem: Arc<Semaphore>,
7567 retries: usize,
7568 roster: &[AgentSpec],
7569 ctx: &WaveCtx<'_>,
7570 losses: &mut Vec<QuotaLoss>,
7571 state: &mut RunState,
7572 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7573) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7574where
7575 T: serde::de::DeserializeOwned + Send + 'static,
7576{
7577 ask_wave_with(
7578 jobs,
7579 sem,
7580 retries,
7581 roster,
7582 ctx,
7583 losses,
7584 state,
7585 &|text: &str| {
7586 let v = verdict::extract_json::<T>(text)?;
7587 validate(&v)?;
7588 Ok(v)
7589 },
7590 )
7591 .await
7592}
7593
7594#[allow(clippy::too_many_arguments)]
7603async fn ask_wave_with<T>(
7604 jobs: Vec<SeatJob>,
7605 sem: Arc<Semaphore>,
7606 retries: usize,
7607 roster: &[AgentSpec],
7608 ctx: &WaveCtx<'_>,
7609 losses: &mut Vec<QuotaLoss>,
7610 state: &mut RunState,
7611 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7612) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7613where
7614 T: Send + 'static,
7615{
7616 let n = jobs.len();
7617 let originals: Vec<SeatJob> = jobs;
7618 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7619 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7620 let mut nudges: Vec<usize> = vec![0; n];
7627 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7631 let mut tried: Vec<BTreeSet<String>> = specs
7632 .iter()
7633 .map(|s| BTreeSet::from([s.id.clone()]))
7634 .collect();
7635 let starts: Vec<usize> = specs
7636 .iter()
7637 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7638 .collect();
7639 let carry = ctx.carry_seats && !roster.is_empty();
7640 let carried: Vec<BTreeSet<String>> = originals
7643 .iter()
7644 .map(|j| {
7645 state
7646 .seat_history
7647 .get(&j.seat.key)
7648 .filter(|_| carry)
7649 .map(|h| h.failed.clone())
7650 .unwrap_or_default()
7651 })
7652 .collect();
7653 let mut prev: Vec<Option<FailClass>> = originals
7654 .iter()
7655 .map(|j| {
7656 state
7657 .seat_history
7658 .get(&j.seat.key)
7659 .filter(|_| carry)
7660 .and_then(|h| h.last_fail.clone())
7661 })
7662 .collect();
7663 let next_agent =
7664 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7665 let others: BTreeSet<String> = specs
7666 .iter()
7667 .enumerate()
7668 .filter(|(j, _)| *j != i)
7669 .map(|(_, s)| s.id.clone())
7670 .collect();
7671 pick_successor(
7672 roster,
7673 starts[i],
7674 tried,
7675 carry.then(|| &carried[i]),
7676 &others,
7677 )
7678 .cloned()
7679 };
7680 let mut fresh: Vec<Option<String>> = vec![None; n];
7681 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7682 let mut pending: Vec<usize> = (0..n).collect();
7683
7684 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7689 for round in 0..max_rounds {
7690 if pending.is_empty() {
7691 break;
7692 }
7693 let mut batch = Vec::with_capacity(pending.len());
7694 let mut renudged: Vec<&str> = Vec::new();
7695 for &i in &pending {
7696 let src = &originals[i];
7697 let full: &str = match &src.handover {
7702 Some(h) if specs[i].id != src.spec.id => h,
7703 _ => &src.prompt,
7704 };
7705 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7708 (
7709 full.to_owned(),
7710 src.timeout,
7711 format!("{}-{word}-{}", src.stem, specs[i].id),
7712 )
7713 } else if nudges[i] == 0 {
7714 (full.to_owned(), src.timeout, src.stem.clone())
7715 } else {
7716 renudged.push(src.seat.key.as_str());
7717 let why = done[i]
7718 .as_ref()
7719 .and_then(|r| r.as_ref().err().map(ToString::to_string))
7720 .unwrap_or_else(|| "no parsable answer".to_owned());
7721 let nudge = prompt::nudge(&why);
7722 let nudged = has_context(&specs[i], &seats[i], src.sessions);
7723 let prompt = if nudged {
7724 nudge
7725 } else {
7726 format!("{full}\n\n---\n\n{nudge}")
7727 };
7728 (
7729 prompt,
7730 retry_budget(src.timeout, nudged),
7731 format!("{}-retry{}", src.stem, nudges[i]),
7732 )
7733 };
7734 batch.push(SeatJob {
7735 spec: specs[i].clone(),
7736 seat: seats[i].clone(),
7737 cwd: src.cwd.clone(),
7738 prompt,
7739 timeout,
7740 allow_write: src.allow_write,
7741 sessions: src.sessions,
7742 artifacts: src.artifacts.clone(),
7743 stem,
7744 handover: None,
7745 });
7746 }
7747
7748 if !renudged.is_empty() {
7749 state.event(
7750 ctx.node,
7751 format!("retry {round}: re-asking {}", renudged.join(", ")),
7752 );
7753 }
7754 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7755 let mut still = Vec::new();
7756 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7757 seats[i] = seat;
7758 let class = FailClass::of(&out);
7759 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7762 && nudges[i] < retries
7763 && !roster.is_empty();
7764 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7765 let next = should_hand_over(prev[i].as_ref(), &cur)
7766 .then(|| next_agent(i, &tried[i], &specs))
7767 .flatten();
7768 if carry {
7769 let h = state
7770 .seat_history
7771 .entry(originals[i].seat.key.clone())
7772 .or_default();
7773 h.failed.insert(specs[i].id.clone());
7774 h.last_fail = Some(cur.clone());
7775 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7776 h.last_ok = None;
7777 }
7778 if let Err(e) = state.save() {
7781 tracing::warn!("could not persist a seat's failure history: {e:#}");
7782 }
7783 }
7784 if let Some(next) = next {
7785 record_handover(
7786 state,
7787 ctx.node,
7788 &originals[i].seat.key,
7789 &specs[i].id,
7790 &next.id,
7791 &cur,
7792 &fail_reason(&out),
7793 );
7794 tried[i].insert(next.id.clone());
7795 prev[i] = Some(cur.clone());
7796 seats[i] =
7797 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7798 specs[i] = next;
7799 fresh[i] = Some(cur.stem_word().to_owned());
7800 nudges[i] = 0;
7801 done[i] = Some(Err(anyhow::anyhow!(
7802 "handed over after: {}",
7803 fail_reason(&out)
7804 )));
7805 still.push(i);
7806 continue;
7807 }
7808 }
7809 if carry
7810 && !nudge_first
7811 && let Some(cur) = class.clone()
7812 {
7813 let h = state
7815 .seat_history
7816 .entry(originals[i].seat.key.clone())
7817 .or_default();
7818 h.failed.insert(specs[i].id.clone());
7819 h.last_fail = Some(cur);
7820 }
7821 let parsed = match out {
7822 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7823 AgentOutcome::Quota(o) => {
7824 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7825 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7826 }
7827 AgentOutcome::Dropped(o) => {
7831 let why = o
7832 .dropped
7833 .as_ref()
7834 .map(|d| d.why.as_str())
7835 .unwrap_or("the CLI ended the stream without delivering its answer");
7836 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7837 }
7838 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7839 };
7840 let quota = class == Some(FailClass::Quota);
7841 let failed = parsed.is_err();
7842 done[i] = Some(parsed);
7843 if carry && !failed {
7844 let h = state
7845 .seat_history
7846 .entry(originals[i].seat.key.clone())
7847 .or_default();
7848 h.failed.remove(&specs[i].id);
7849 h.last_ok = Some(specs[i].id.clone());
7850 h.last_fail = None;
7851 }
7852 let agent_failure = class.is_some()
7860 && !nudge_first
7861 && !roster.is_empty()
7862 && next_agent(i, &tried[i], &specs).is_some();
7863 if failed && !quota && !agent_failure && nudges[i] < retries {
7864 nudges[i] += 1;
7865 still.push(i);
7866 }
7867 }
7868 pending = still;
7869 }
7870
7871 for (i, q) in last_quota.into_iter().enumerate() {
7874 if let Some(reset) = q {
7875 losses.push(QuotaLoss {
7876 seat: originals[i].seat.key.clone(),
7877 node: ctx.node.to_owned(),
7878 at: Timestamp::now(),
7879 reset,
7880 });
7881 }
7882 }
7883
7884 seats
7885 .into_iter()
7886 .zip(done)
7887 .zip(nudges)
7888 .map(|((seat, res), attempts)| {
7889 (
7890 seat,
7891 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7892 attempts,
7893 )
7894 })
7895 .collect()
7896}
7897
7898async fn acquire_cache_lease(
7911 state: &mut RunState,
7912 cache_dir: &Path,
7913 owner: &crate::cache::Owner,
7914 budget: Duration,
7915 context: &str,
7916) -> Result<crate::cache::Guard> {
7917 let home = crate::run::home();
7918 let started = Instant::now();
7919 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7920 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7921 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7922 Err(e) => {
7923 state.event(
7924 "verify",
7925 format!("{context}: could not check the shared build cache: {e:#}"),
7926 );
7927 if let Err(e2) = state.save() {
7928 tracing::warn!("could not persist a cache-check failure: {e2:#}");
7929 }
7930 return Err(e);
7931 }
7932 };
7933 state.event(
7934 "verify",
7935 format!(
7936 "{context}: waiting for the shared build cache at {} ({})",
7937 cache_dir.display(),
7938 busy.describe()
7939 ),
7940 );
7941 if let Err(e) = state.save() {
7942 tracing::warn!("could not persist a cache wait: {e:#}");
7943 }
7944 let remaining = budget.saturating_sub(started.elapsed());
7945 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7946 Ok(g) => Ok(g),
7947 Err(e) => {
7948 state.event("verify", format!("{context}: {e:#}"));
7949 if let Err(e2) = state.save() {
7950 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7951 }
7952 Err(e)
7953 }
7954 }
7955}
7956
7957#[allow(clippy::too_many_arguments)]
7978async fn with_cache_lease<'s, F, Fut>(
7979 state: &'s mut RunState,
7980 cache_dir: Option<&Path>,
7981 node: &str,
7982 seat: &str,
7983 worktree: &Path,
7984 head: &str,
7985 budget: Duration,
7986 context: &str,
7987 body: F,
7988) -> (Vec<CommandOutcome>, bool)
7989where
7990 F: FnOnce(&'s mut RunState, Duration) -> Fut,
7991 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7992{
7993 let Some(cache_dir) = cache_dir else {
7994 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7995 return (outcomes, retried);
7996 };
7997 let home = crate::run::home();
7998 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7999 let started = Instant::now();
8000 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8001 Ok(g) => g,
8002 Err(e) => {
8003 return (
8004 vec![CommandOutcome {
8005 command: "(waiting for the shared build cache)".to_owned(),
8006 code: None,
8007 output_tail: e.to_string(),
8008 duration_ms: started.elapsed().as_millis() as u64,
8009 resource_blocked: true,
8010 }],
8011 false,
8012 );
8013 }
8014 };
8015 let identity = crate::cache::Identity::new(worktree, head);
8016 if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
8017 state.event(
8025 "verify",
8026 format!(
8027 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8028 worktree.display(),
8029 short(head)
8030 ),
8031 );
8032 guard.release();
8033 return (
8034 vec![CommandOutcome {
8035 command: "(confirming the shared build cache is fresh)".to_owned(),
8036 code: None,
8037 output_tail: e.to_string(),
8038 duration_ms: started.elapsed().as_millis() as u64,
8039 resource_blocked: true,
8040 }],
8041 false,
8042 );
8043 }
8044 let remaining = budget.saturating_sub(started.elapsed());
8045 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8046 if !timed_out_pids.is_empty() {
8051 wait_for_timed_out_children_to_die(&timed_out_pids).await;
8052 }
8053 guard.release();
8054 (outcomes, retried)
8055}
8056
8057async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8069 wait_for_pids_with(
8070 pids,
8071 crate::proc::pid_alive,
8072 LEASE_RELEASE_POLL,
8073 LEASE_RELEASE_MAX_WAIT,
8074 )
8075 .await;
8076}
8077
8078async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8084 pids: &[u32],
8085 alive: F,
8086 poll: Duration,
8087 max_wait: Duration,
8088) {
8089 let deadline = Instant::now() + max_wait;
8090 loop {
8091 if pids.iter().all(|&pid| !alive(pid)) {
8092 return;
8093 }
8094 if Instant::now() >= deadline {
8095 return;
8096 }
8097 tokio::time::sleep(poll).await;
8098 }
8099}
8100
8101fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8108 outcomes.iter().any(|o| o.resource_blocked)
8109}
8110
8111enum GateFix {
8113 Retry,
8115 Stop,
8118 Defer,
8121}
8122
8123fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8131 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8132 red.peek().is_some()
8133 && red.all(|o| {
8134 !o.resource_blocked
8135 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8136 && !o.output_tail.trim().is_empty()
8137 })
8138}
8139
8140fn e2e_outcome_label(o: &CommandOutcome) -> String {
8144 if o.ok() {
8145 return "pass".to_owned();
8146 }
8147 let reason = if o.build_failed() {
8148 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8149 } else {
8150 format!("FAIL ({:?})", o.code)
8151 };
8152 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8153}
8154
8155async fn run_e2e_with_retry(
8163 state: &mut RunState,
8164 shell: &[String],
8165 commands: &[String],
8166 worktree: &Path,
8167 timeout: Duration,
8168 context: &str,
8169) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8170 let (mut e2e, mut timed_out_pids) = run_commands(
8171 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8172 )
8173 .await;
8174 for o in &e2e {
8175 state.event(
8176 "verify",
8177 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8178 );
8179 }
8180 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8184 if verify_retried {
8185 state.event(
8186 "verify",
8187 format!(
8188 "{context}: verify could not build/link, not a test result — retrying once \
8189 before concluding"
8190 ),
8191 );
8192 let retried = run_commands(
8193 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8194 )
8195 .await;
8196 e2e = retried.0;
8197 timed_out_pids.extend(retried.1);
8200 for o in &e2e {
8201 state.event(
8202 "verify",
8203 format!(
8204 "{context}: retry `{}` -> {}",
8205 o.command,
8206 e2e_outcome_label(o)
8207 ),
8208 );
8209 }
8210 }
8211 (e2e, verify_retried, timed_out_pids)
8212}
8213
8214#[allow(clippy::too_many_arguments)]
8230async fn run_commands(
8231 state: &mut RunState,
8232 node: &str,
8233 task: &str,
8234 attempt: usize,
8235 shell: &[String],
8236 commands: &[String],
8237 cwd: &Path,
8238 timeout: Duration,
8239) -> (Vec<CommandOutcome>, Vec<u32>) {
8240 if commands.is_empty() {
8241 return (Vec::new(), Vec::new());
8246 }
8247 let mut out = Vec::new();
8248 let mut timed_out_pids = Vec::new();
8249 let total = commands.len();
8250 for (idx, command) in commands.iter().enumerate() {
8251 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8252 if let Err(e) = state.save() {
8253 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8254 }
8255 let started = Instant::now();
8256 let mut cmd = tokio::process::Command::new(&shell[0]);
8257 cmd.quiet();
8258 cmd.args(&shell[1..])
8259 .arg(command)
8260 .current_dir(cwd)
8261 .stdin(std::process::Stdio::null())
8262 .stdout(std::process::Stdio::piped())
8263 .stderr(std::process::Stdio::piped())
8264 .kill_on_drop(true);
8265 let spawned = cmd.spawn();
8266 let (code, body) = match spawned {
8267 Ok(child) => {
8268 let pid = child.id();
8273 match tokio::time::timeout(timeout, child.wait_with_output()).await {
8274 Ok(Ok(o)) => {
8275 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8276 body.push_str(&String::from_utf8_lossy(&o.stderr));
8277 (o.status.code(), body)
8278 }
8279 Ok(Err(e)) => (None, format!("failed to run: {e}")),
8280 Err(_) => {
8281 if let Some(pid) = pid {
8282 timed_out_pids.push(pid);
8283 }
8284 (None, format!("timed out after {}s", timeout.as_secs()))
8285 }
8286 }
8287 }
8288 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8289 };
8290 out.push(CommandOutcome {
8291 command: command.clone(),
8292 code,
8293 output_tail: tail(&body, OUTPUT_TAIL),
8294 duration_ms: started.elapsed().as_millis() as u64,
8295 resource_blocked: false,
8296 });
8297 }
8298 state.task_finished(task);
8299 if let Err(e) = state.save() {
8300 tracing::warn!("could not persist the end of {task}: {e:#}");
8301 }
8302 (out, timed_out_pids)
8303}
8304
8305fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8317 let repo = repo.display();
8318 match style {
8319 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8320 MergeStyle::Squash => {
8321 let subject = message
8324 .lines()
8325 .next()
8326 .unwrap_or(branch)
8327 .replace(['\\', '"', '$', '`'], "");
8328 format!(
8329 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8330 )
8331 }
8332 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8333 }
8334}
8335
8336const PR_TITLE_MAX: usize = 240;
8347
8348fn english_title(text: &str, max: usize) -> Option<String> {
8359 let line = queue::first_line(text)?;
8360 let chars: Vec<char> = line.chars().collect();
8361 let (kept, mark) = if chars.len() <= max {
8362 (line.to_owned(), "")
8363 } else if let Some(end) = sentence_end(&chars, max) {
8364 (chars[..end].iter().collect::<String>(), "")
8365 } else {
8366 let room = max.saturating_sub(3);
8367 let cut = if chars[room].is_whitespace() {
8370 room
8371 } else {
8372 chars[..room]
8373 .iter()
8374 .rposition(|c| c.is_whitespace())
8375 .unwrap_or(room)
8376 };
8377 let head: String = chars[..cut].iter().collect();
8378 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8379 (head.to_owned(), "...")
8380 };
8381 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8382 return None;
8383 }
8384 Some(format!("{kept}{mark}"))
8385}
8386
8387fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8391 const MIN: usize = 20;
8392 for i in MIN..max.min(chars.len()) {
8393 if !matches!(chars[i], '.' | '!' | '?') {
8394 continue;
8395 }
8396 let Some(&next) = chars.get(i + 1) else {
8397 continue;
8398 };
8399 if !next.is_whitespace() {
8400 continue;
8401 }
8402 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8403 if after.is_some_and(|c| c.is_ascii_lowercase()) {
8404 continue;
8405 }
8406 let word: String = chars[..i]
8407 .iter()
8408 .rev()
8409 .take_while(|c| !c.is_whitespace())
8410 .collect::<Vec<_>>()
8411 .into_iter()
8412 .rev()
8413 .collect();
8414 let word = word.to_ascii_lowercase();
8415 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8416 continue;
8417 }
8418 let end = chars[..i]
8419 .iter()
8420 .rposition(|c| !c.is_whitespace())
8421 .map_or(i, |p| p + 1);
8422 return Some(end);
8423 }
8424 None
8425}
8426
8427struct PrMessage {
8432 title: String,
8433 body: String,
8434}
8435
8436impl PrMessage {
8437 fn commit_message(&self) -> String {
8441 format!("{}\n\n{}", self.title, self.body)
8442 }
8443}
8444
8445fn title_marker(line: &str) -> Option<&str> {
8447 let line = line.trim();
8448 let head = line.get(..6)?;
8449 head.eq_ignore_ascii_case("title:")
8450 .then(|| line[6..].trim())
8451}
8452
8453fn summary_title(summary: &str) -> Option<String> {
8458 let first = summary.lines().find(|l| !l.trim().is_empty())?;
8459 let raw = title_marker(first)?;
8460 if raw.is_empty() {
8461 return None;
8462 }
8463 let title = queue::title_from(raw, PR_TITLE_MAX);
8464 let lower = title.to_ascii_lowercase();
8465 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8466 return None;
8467 }
8468 Some(title)
8469}
8470
8471const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8473
8474const EXISTING_BRANCH: &str = "(existing branch)";
8476
8477fn is_review_run(state: &RunState) -> bool {
8481 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8482}
8483
8484fn review_title(state: &RunState) -> Option<String> {
8489 english_subject(state.reviewed_commits.as_ref()?.first()?)
8490}
8491
8492fn english_subject(raw: &str) -> Option<String> {
8495 let raw = raw.trim();
8496 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8497 return None;
8498 }
8499 let title = queue::title_from(raw, PR_TITLE_MAX);
8500 let lower = title.to_ascii_lowercase();
8501 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8502 return None;
8503 }
8504 Some(title)
8505}
8506
8507#[derive(Debug, Clone, PartialEq, Eq)]
8510struct BranchFacts {
8511 commits: Vec<(String, String)>,
8513 stat: String,
8515}
8516
8517const STAT_FILE_LINES: usize = 25;
8519const COMMITS_MAX_CHARS: usize = 20_000;
8521
8522async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8525 let commits = git::commit_log(repo, base, branch).await.ok()?;
8526 if commits.is_empty() {
8527 return None;
8528 }
8529 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8530 let lines: Vec<&str> = stat.lines().collect();
8531 let stat = if lines.len() > STAT_FILE_LINES + 1 {
8532 let omitted = lines.len() - 1 - STAT_FILE_LINES;
8533 let more = format!(" ... {omitted} more file(s)");
8534 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8535 kept.push(&more);
8536 kept.push(lines[lines.len() - 1]);
8537 kept.join("\n")
8538 } else {
8539 lines.join("\n")
8540 };
8541 Some(BranchFacts { commits, stat })
8542}
8543
8544fn markdown_safe(text: &str) -> String {
8547 text.replace("</details>", "</details>")
8548 .replace("\x60\x60\x60", "~~~")
8549}
8550
8551fn neutral_title(state: &RunState, winner: char) -> String {
8552 format!(
8553 "chore: land candidate {} of run {}",
8554 winner.to_ascii_uppercase(),
8555 state.id
8556 )
8557}
8558
8559pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8565 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8566 ""
8567 } else {
8568 pr_title
8569 }
8570}
8571
8572pub fn landing_subject_source(state: &RunState) -> String {
8576 if is_review_run(state) {
8577 let winner = state.candidates.first().map_or('A', |c| c.label);
8578 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8579 }
8580 state.instruction.clone()
8581}
8582
8583fn summary_without_title(summary: &str) -> String {
8586 let mut lines = summary.trim().lines().peekable();
8587 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8588 lines.next();
8589 }
8590 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8591}
8592
8593#[cfg(test)]
8607fn pr_message(state: &RunState, winner: char) -> PrMessage {
8608 pr_message_with(state, winner, None)
8609}
8610
8611#[cfg(test)]
8614fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8615 let raw = pr_message_raw(state, winner, facts);
8616 let id = crate::scrub::Identity::current();
8617 PrMessage {
8618 title: crate::scrub::scrub(&raw.title, &id),
8619 body: crate::scrub::scrub(&raw.body, &id),
8620 }
8621}
8622
8623fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8624 let summary = state
8625 .candidates
8626 .iter()
8627 .find(|c| c.label == winner)
8628 .map(|c| c.summary.as_str())
8629 .unwrap_or_default();
8630 let review = is_review_run(state);
8633 let title = if review {
8634 facts
8635 .and_then(|f| english_subject(&f.commits.first()?.0))
8636 .or_else(|| review_title(state))
8637 .or_else(|| {
8638 state
8639 .candidates
8640 .iter()
8641 .find(|c| c.label == winner)
8642 .filter(|c| !c.branch.starts_with("magi/"))
8643 .and_then(|c| english_subject(&c.branch))
8644 })
8645 .unwrap_or_else(|| neutral_title(state, winner))
8646 } else {
8647 summary_title(summary).unwrap_or_else(|| {
8648 english_title(&state.instruction, PR_TITLE_MAX)
8649 .unwrap_or_else(|| neutral_title(state, winner))
8650 })
8651 };
8652
8653 let mut body = String::new();
8654 let what = summary_without_title(summary);
8655 if !what.is_empty() {
8656 body.push_str("## Summary\n\n");
8657 body.push_str(&what);
8658 body.push_str("\n\n");
8659 }
8660
8661 let notes: Vec<(usize, &str)> = state
8664 .reviews
8665 .iter()
8666 .filter_map(|r| {
8667 let n = r.fix.as_ref()?.notes.trim();
8668 (!n.is_empty()).then_some((r.round, n))
8669 })
8670 .collect();
8671 if !notes.is_empty() {
8672 body.push_str("## Review fixes\n\n");
8673 if let [(_, only)] = notes.as_slice() {
8674 body.push_str(only);
8675 body.push_str("\n\n");
8676 } else {
8677 for (round, n) in ¬es {
8678 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8679 }
8680 }
8681 }
8682 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8683
8684 let open = state.open_findings();
8685 if !open.is_empty() {
8686 body.push_str("## Open review findings\n\n");
8687 for f in &open {
8688 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8689 }
8690 body.push('\n');
8691 }
8692
8693 if let Some(fix) = fix
8694 && !fix.rejected.is_empty()
8695 {
8696 body.push_str("## Declined by the fixer\n\n");
8697 for r in &fix.rejected {
8698 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8699 }
8700 body.push('\n');
8701 }
8702
8703 if review {
8704 body.push_str("## Commits under review\n\n");
8706 if let Some(facts) = facts {
8707 let mut left = COMMITS_MAX_CHARS;
8708 for (i, (subject, text)) in facts.commits.iter().enumerate() {
8709 let mut entry = format!("- {}\n", markdown_safe(subject));
8710 for l in markdown_safe(text).lines() {
8711 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
8712 }
8713 if left == 0 {
8714 body.push_str(&format!(
8715 "- ... {} more commit(s)\n",
8716 facts.commits.len() - i
8717 ));
8718 break;
8719 }
8720 if entry.len() > left {
8721 let mut end = left;
8724 while !entry.is_char_boundary(end) {
8725 end -= 1;
8726 }
8727 entry.truncate(end);
8728 entry.push_str("\n ... (truncated)\n");
8729 left = 0;
8730 } else {
8731 left -= entry.len();
8732 }
8733 body.push_str(&entry);
8734 }
8735 if !facts.stat.trim().is_empty() {
8736 body.push_str(&format!(
8737 "\n## Diff stat\n\n```\n{}\n```\n",
8738 markdown_safe(facts.stat.trim())
8739 ));
8740 }
8741 } else {
8742 match &state.reviewed_commits {
8743 Some(subjects) => {
8744 for s in subjects {
8745 body.push_str(&format!("- {}\n", s.trim()));
8746 }
8747 }
8748 None => {
8749 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8752 body.push_str(rest.trim());
8753 body.push('\n');
8754 }
8755 }
8756 }
8757 } else {
8758 let task = state.instruction.trim();
8759 let task = if task.is_empty() {
8760 "(empty task)"
8761 } else {
8762 task
8763 };
8764 body.push_str(&format!(
8765 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8766 task.replace("</details>", "</details>")
8767 ));
8768 }
8769
8770 body.push_str(&format!(
8771 "\n---\nmagi:run/{} magi:candidate-{}\n",
8772 state.id,
8773 winner.to_ascii_lowercase()
8774 ));
8775
8776 PrMessage { title, body }
8777}
8778
8779fn seeded_instruction(state: &RunState) -> String {
8783 match refs::describe(&state.seeds) {
8784 Some(facts) => format!(
8785 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8786 Candidates start from the unmerged branch named above, when there \
8787 is one, and carry any unmerged commit named by sha as a \
8788 cherry-pick. Check that this is what the task meant before \
8789 building on it.",
8790 state.instruction
8791 ),
8792 None => state.instruction.clone(),
8793 }
8794}
8795
8796async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8800 let base = &state.base_branch;
8801 let mut against = base.clone();
8802 if mode == MergeMode::Pr {
8803 let remote = &state.config.merge.remote;
8807 let tracking = format!("{remote}/{base}");
8808 let fetched = git::fetch(repo, remote, base).await;
8809 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8810 against = tracking;
8811 } else {
8812 return false;
8813 }
8814 }
8815 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8816}
8817
8818fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8821 let mut detail = format!(
8822 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8823 nothing to open a pull request for"
8824 );
8825 match refs::describe(&state.seeds) {
8826 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8827 None => detail.push_str(
8828 "\nThe task names no existing branch or commit; if it means to land work \
8829 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8830 ),
8831 }
8832 detail
8833}
8834
8835#[derive(Debug, PartialEq, Eq)]
8838enum PrPlan {
8839 Create,
8840 Adopt { url: String, title: String },
8841 Stop(String),
8842}
8843
8844fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8847 match found {
8848 Ok(land::OpenPr::None) => PrPlan::Create,
8849 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8850 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8851 "several open pull requests exist for this branch, not picking one: {}",
8852 urls.join(" ")
8853 )),
8854 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8855 }
8856}
8857
8858async fn gh_pr_create(
8860 cwd: &Path,
8861 base: &str,
8862 head: &str,
8863 title: &str,
8864 body: &str,
8865) -> Result<String> {
8866 let out = tokio::process::Command::new("gh")
8867 .args([
8868 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8869 ])
8870 .current_dir(cwd)
8871 .quiet()
8872 .stdin(std::process::Stdio::null())
8873 .output()
8874 .await
8875 .context("spawn gh")?;
8876 if out.status.success() {
8877 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8878 } else {
8879 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8880 }
8881}
8882
8883pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8892 let repo = state.repo.clone();
8893 let root = state.worktree_root();
8894 let winner = state.tally.as_ref().map(|t| t.winner);
8895 let mut removed = Vec::new();
8896
8897 for i in 0..state.candidates.len() {
8898 let c = state.candidates[i].clone();
8899 let is_winner = Some(c.label) == winner;
8900 if is_winner && !drop_winner {
8901 continue;
8902 }
8903 if c.worktree.exists() {
8904 git::worktree_remove(&repo, &c.worktree).await.ok();
8905 removed.push(c.worktree.to_string_lossy().into_owned());
8906 }
8907 let handed_over = state.released_branches.contains(&c.branch);
8910 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8911 git::branch_delete(&repo, &c.branch).await.ok();
8912 removed.push(c.branch.clone());
8913 }
8914 state.candidates[i].folded = true;
8915 }
8916
8917 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8918 let path = name.path();
8919 let keep = !drop_winner
8920 && winner.is_some_and(|w| {
8921 path.file_name()
8922 .is_some_and(|n| n == format!("cand-{w}").as_str())
8923 });
8924 if keep {
8925 continue;
8926 }
8927 git::worktree_remove(&repo, &path).await.ok();
8928 removed.push(path.to_string_lossy().into_owned());
8929 }
8930
8931 remove_if_empty(&root);
8940
8941 if state.enabled_worktree_config && drop_winner {
8942 git::release_worktree_config(&repo).await.ok();
8946 state.enabled_worktree_config = false;
8947 }
8948 state.save_under(home)?;
8949 Ok(removed)
8950}
8951
8952fn remove_if_empty(dir: &Path) {
8963 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8964 std::fs::remove_dir(dir).ok();
8965 }
8966}
8967
8968pub fn worst_open(state: &RunState) -> Option<Severity> {
8970 state
8971 .reviews
8972 .last()?
8973 .reviews
8974 .iter()
8975 .flat_map(|r| r.findings.iter())
8976 .map(|f| f.severity)
8977 .max()
8978}
8979
8980async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
8984 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
8985 git::acquire_worktree_config(repo).await?;
8986 state.enabled_worktree_config = true;
8987 }
8988 Ok(())
8989}
8990
8991#[cfg(test)]
8992mod tests {
8993 #[test]
8994 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8995 let leaked = vec!["chore(deps): update a crate".to_owned()];
8996 let own = "fix(daemon): apply a chosen action";
8997 assert!(should_retitle("", own, &leaked));
8998 assert!(should_retitle(
8999 &format!("{REVIEW_PROMPT_OPENING} `x`"),
9000 own,
9001 &leaked
9002 ));
9003 assert!(should_retitle(
9004 "chore: land candidate A of run 1",
9005 own,
9006 &leaked
9007 ));
9008 assert!(should_retitle(
9009 "magi: candidate A (uncommitted work)",
9010 own,
9011 &leaked
9012 ));
9013 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9014 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9015 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9016 }
9017
9018 #[test]
9019 fn pr_merge_plan_creates_adopts_or_stops() {
9020 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9021 assert_eq!(
9022 pr_merge_plan(Ok(land::OpenPr::One {
9023 url: "u".into(),
9024 title: "t".into()
9025 })),
9026 PrPlan::Adopt {
9027 url: "u".into(),
9028 title: "t".into()
9029 }
9030 );
9031 let PrPlan::Stop(many) =
9032 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9033 else {
9034 panic!("many must stop");
9035 };
9036 assert!(many.contains('a') && many.contains('b'));
9037 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9038 panic!("a failed lookup must stop");
9039 };
9040 assert!(err.contains("bad token"));
9041 }
9042
9043 use super::*;
9044 use crate::run::GateStatus;
9045 use std::collections::BTreeMap;
9046 use std::time::Duration;
9047
9048 fn conductor() -> AgentSpec {
9049 AgentSpec {
9050 id: "conductor".to_owned(),
9051 kind: crate::config::AgentKind::Command,
9052 model: None,
9053 command: vec!["true".to_owned()],
9054 extra_args: Vec::new(),
9055 env: BTreeMap::new(),
9056 prompt_delivery: None,
9057 }
9058 }
9059
9060 fn spec(id: &str) -> AgentSpec {
9061 AgentSpec {
9062 id: id.to_owned(),
9063 kind: crate::config::AgentKind::Command,
9064 model: None,
9065 command: vec!["true".to_owned()],
9066 extra_args: Vec::new(),
9067 env: BTreeMap::new(),
9068 prompt_delivery: None,
9069 }
9070 }
9071
9072 fn ids(xs: &[&str]) -> BTreeSet<String> {
9073 xs.iter().map(|s| (*s).to_owned()).collect()
9074 }
9075
9076 #[test]
9077 fn pick_successor_skips_an_agent_another_seat_holds() {
9078 let roster = [spec("a"), spec("b"), spec("c")];
9080 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9081 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9082 }
9083
9084 #[test]
9085 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9086 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9088 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9089 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9090 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9091 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9092 }
9093
9094 #[test]
9095 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9096 let roster = [spec("a"), spec("b")];
9097 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9098 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9099 let carried = ids(&["b"]);
9100 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9101 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9102 }
9103
9104 #[test]
9105 fn pick_successor_returns_none_without_any_untried_successor() {
9106 let roster = [spec("a"), spec("b")];
9107 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9108 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9109 let carried = ids(&["a"]);
9110 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9111 }
9112
9113 #[test]
9114 fn pick_successor_rescue_avoids_another_seats_occupant() {
9115 let roster = [spec("a"), spec("b"), spec("c")];
9117 let carried = ids(&["b", "c"]);
9118 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9119 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9120 }
9121
9122 #[test]
9123 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9124 let roster = [spec("a"), spec("b"), spec("c")];
9125 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9126 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9127 }
9128
9129 #[test]
9130 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9131 let roster = [spec("a"), spec("b"), spec("c")];
9132 let failed = ids(&["a", "b", "c"]);
9133 let mut tried = ids(&["b"]);
9135 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9136 assert_eq!(first.id, "a");
9137 tried.insert(first.id.clone());
9138 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9139 assert_eq!(second.id, "c");
9140 tried.insert(second.id.clone());
9141 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9142 }
9143
9144 #[test]
9145 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9146 let roster = [spec("a"), spec("b")];
9147 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9148 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9149 }
9150
9151 #[test]
9152 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9153 let roster = [spec("a"), spec("b"), spec("c")];
9154 let h = SeatHistory {
9155 failed: ids(&["a"]),
9156 last_ok: Some("b".to_owned()),
9157 last_fail: None,
9158 };
9159 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9160 let gone = SeatHistory {
9162 last_ok: Some("zzz".to_owned()),
9163 ..h.clone()
9164 };
9165 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9166 let failed = SeatHistory {
9167 failed: ids(&["a", "b"]),
9168 last_ok: Some("b".to_owned()),
9169 last_fail: None,
9170 };
9171 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9172 }
9173
9174 #[test]
9175 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9176 let first = SeatState::new("review-1", "alpha", 7);
9177 let next = handover_seat("review-1", "gamma", 7);
9178 assert_ne!(first.claude_session, next.claude_session);
9179 }
9180
9181 #[test]
9182 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9183 let mut state = state_with_summary("x", "y");
9184 let a = handover_seat("review-1", "beta", state.next_seat_seed());
9185 let b = handover_seat("review-1", "beta", state.next_seat_seed());
9186 assert_ne!(a.claude_session, b.claude_session);
9187 }
9188
9189 #[test]
9190 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9191 let roster = [spec("a"), spec("b")];
9192 let h = SeatHistory {
9193 failed: ids(&["a", "b"]),
9194 ..SeatHistory::default()
9195 };
9196 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9197 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9198 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9199 }
9200
9201 #[test]
9208 fn failure_signature_ignores_numbers_and_paths() {
9209 assert_eq!(
9210 failure_signature("exited with Some(2) and no usable output"),
9211 failure_signature("exited with Some(137) and no usable output")
9212 );
9213 assert_eq!(
9214 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9215 failure_signature("cannot open /var/x.txt: denied")
9216 );
9217 assert_ne!(failure_signature("boom"), failure_signature("bang"));
9218 }
9219
9220 #[test]
9221 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9222 let other = FailClass::Other("x".into());
9223 assert!(should_hand_over(None, &FailClass::Quota));
9224 assert!(should_hand_over(Some(&other), &FailClass::Quota));
9225 assert!(should_hand_over(
9226 Some(&FailClass::Timeout),
9227 &FailClass::Timeout
9228 ));
9229 assert!(should_hand_over(None, &other));
9230 assert!(!should_hand_over(Some(&other), &other));
9231 assert!(should_hand_over(
9232 Some(&other),
9233 &FailClass::Other("y".into())
9234 ));
9235 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9237 assert!(should_hand_over(Some(&FailClass::Quota), &other));
9238 }
9239
9240 #[test]
9241 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9242 let a = SeatState::new("judge-1", "alpha", 7);
9243 let b = handover_seat("judge-1", "beta", 7);
9244 assert_ne!(a.claude_session, b.claude_session);
9245 assert_eq!(b.turns, 0);
9246 }
9247
9248 #[test]
9249 fn a_timeout_is_classified_apart_from_other_failures() {
9250 assert_eq!(
9251 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9252 Some(FailClass::Timeout)
9253 );
9254 assert!(matches!(
9255 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9256 Some(FailClass::Other(_))
9257 ));
9258 }
9259
9260 #[test]
9261 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9262 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9263 let tried = BTreeSet::from(["beta".to_owned()]);
9264 let next = next_untried_in_roster(&roster, 1, &tried);
9267 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9268 }
9269
9270 #[test]
9271 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9272 let roster = vec![spec("alpha"), spec("beta")];
9273 let tried = BTreeSet::from(["beta".to_owned()]);
9274 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9278 }
9279
9280 #[test]
9281 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9282 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9283 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9284 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9288 }
9289
9290 #[test]
9291 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9292 let roster = vec![spec("a"), spec("a"), spec("b")];
9293 let tried = BTreeSet::from(["a".to_owned()]);
9294 let next = next_untried_in_roster(&roster, 0, &tried);
9295 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9296 }
9297
9298 #[test]
9299 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9300 let roster = vec![spec("a"), spec("b")];
9301 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9302 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9303 }
9304
9305 #[test]
9306 fn remove_if_empty_only_ever_takes_a_bare_directory() {
9307 let dir = tempfile::tempdir().unwrap();
9308 let bay = dir.path().join("ffff");
9309
9310 remove_if_empty(&bay);
9312 assert!(!bay.exists());
9313
9314 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9317 remove_if_empty(&bay);
9318 assert!(bay.exists(), "non-empty directory must survive");
9319
9320 std::fs::remove_dir(bay.join("cand-A")).unwrap();
9322 remove_if_empty(&bay);
9323 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9324 }
9325
9326 #[test]
9335 fn a_full_panel_that_found_nothing_is_clean() {
9336 assert!(round_is_clean(
9337 0,
9338 true,
9339 2,
9340 2,
9341 0,
9342 IncompleteReviewPolicy::Block
9343 ));
9344 }
9345
9346 #[test]
9347 fn a_missing_seat_is_never_clean_under_the_default_policy() {
9348 assert!(!round_is_clean(
9349 0,
9350 true,
9351 1,
9352 2,
9353 0,
9354 IncompleteReviewPolicy::Block
9355 ));
9356 }
9357
9358 #[test]
9359 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9360 assert!(!round_is_clean(
9361 1,
9362 true,
9363 1,
9364 2,
9365 0,
9366 IncompleteReviewPolicy::Warn
9367 ));
9368 }
9369
9370 #[test]
9371 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9372 assert!(round_is_clean(
9373 0,
9374 true,
9375 1,
9376 2,
9377 0,
9378 IncompleteReviewPolicy::Warn
9379 ));
9380 }
9381
9382 #[test]
9383 fn a_full_panel_with_an_open_finding_is_not_clean() {
9384 assert!(!round_is_clean(
9385 1,
9386 true,
9387 2,
9388 2,
9389 0,
9390 IncompleteReviewPolicy::Block
9391 ));
9392 }
9393
9394 #[test]
9395 fn a_full_panel_with_a_red_e2e_is_not_clean() {
9396 assert!(!round_is_clean(
9397 0,
9398 false,
9399 2,
9400 2,
9401 0,
9402 IncompleteReviewPolicy::Block
9403 ));
9404 }
9405
9406 #[test]
9413 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9414 assert!(round_is_clean(
9417 0,
9418 true,
9419 1,
9420 2,
9421 1,
9422 IncompleteReviewPolicy::Block
9423 ));
9424 }
9425
9426 #[test]
9427 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9428 assert!(!round_is_clean(
9431 0,
9432 true,
9433 1,
9434 2,
9435 0,
9436 IncompleteReviewPolicy::Block
9437 ));
9438 }
9439
9440 #[test]
9441 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9442 assert!(!round_is_clean(
9443 1,
9444 true,
9445 1,
9446 2,
9447 1,
9448 IncompleteReviewPolicy::Block
9449 ));
9450 assert!(!round_is_clean(
9451 0,
9452 false,
9453 1,
9454 2,
9455 1,
9456 IncompleteReviewPolicy::Block
9457 ));
9458 }
9459
9460 #[test]
9461 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9462 assert!(!round_is_clean(
9466 0,
9467 true,
9468 0,
9469 2,
9470 2,
9471 IncompleteReviewPolicy::Block
9472 ));
9473 }
9474
9475 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9476 CommandOutcome {
9477 command: "test".to_owned(),
9478 code,
9479 output_tail: String::new(),
9480 duration_ms: 0,
9481 resource_blocked,
9482 }
9483 }
9484
9485 #[test]
9486 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9487 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9488 assert!(
9489 !verify_inconclusive(&[outcome(Some(1), false)]),
9490 "an ordinary failure is still evidence about the patch"
9491 );
9492 assert!(verify_inconclusive(&[outcome(None, true)]));
9493 assert!(
9494 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9495 "one inconclusive outcome taints the whole batch"
9496 );
9497 assert!(!verify_inconclusive(&[]));
9498 }
9499
9500 #[tokio::test]
9501 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9502 let calls = std::sync::atomic::AtomicUsize::new(0);
9506 let started = Instant::now();
9507 wait_for_pids_with(
9508 &[123],
9509 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9510 Duration::from_millis(5),
9511 Duration::from_secs(5),
9512 )
9513 .await;
9514 assert!(
9515 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9516 "must keep checking rather than deciding on the first answer"
9517 );
9518 assert!(
9519 started.elapsed() < Duration::from_secs(1),
9520 "must return the moment it is confirmed dead, not wait out the ceiling"
9521 );
9522 }
9523
9524 #[tokio::test]
9525 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9526 let started = Instant::now();
9527 wait_for_pids_with(
9528 &[123],
9529 |_| true, Duration::from_millis(5),
9531 Duration::from_millis(30),
9532 )
9533 .await;
9534 let elapsed = started.elapsed();
9535 assert!(
9536 elapsed >= Duration::from_millis(30),
9537 "must not give up before its own ceiling: {elapsed:?}"
9538 );
9539 assert!(
9540 elapsed < Duration::from_secs(1),
9541 "must not wait past its own ceiling either: {elapsed:?}"
9542 );
9543 }
9544
9545 #[tokio::test]
9546 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9547 let started = Instant::now();
9548 wait_for_pids_with(
9549 &[],
9550 |_| true,
9551 Duration::from_secs(5),
9552 Duration::from_secs(5),
9553 )
9554 .await;
9555 assert!(
9556 started.elapsed() < Duration::from_millis(200),
9557 "an empty pid list has nothing to confirm"
9558 );
9559 }
9560
9561 fn review_round(
9567 clean: bool,
9568 blocking: usize,
9569 answered: usize,
9570 expected: usize,
9571 progressed: bool,
9572 e2e_ok: bool,
9573 ) -> ReviewRound {
9574 ReviewRound {
9575 round: 1,
9576 head: "h".to_owned(),
9577 verified_head: None,
9578 verified_at: None,
9579 reviews: Vec::new(),
9580 e2e: vec![CommandOutcome {
9581 command: "test".to_owned(),
9582 code: Some(if e2e_ok { 0 } else { 1 }),
9583 output_tail: String::new(),
9584 duration_ms: 0,
9585 resource_blocked: false,
9586 }],
9587 verify_retried: false,
9588 e2e_deferred: false,
9589 e2e_defer_reason: None,
9590 fix: None,
9591 blocking,
9592 answered,
9593 expected,
9594 clean,
9595 progressed,
9596 vote_split: false,
9597 reconsideration: Vec::new(),
9598 verdict: None,
9599 }
9600 }
9601
9602 #[test]
9603 fn review_conclusion_is_none_when_nothing_has_run() {
9604 assert_eq!(review_conclusion(&[], 3), None);
9605 }
9606
9607 #[test]
9608 fn review_conclusion_is_none_while_rounds_remain() {
9609 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9610 assert_eq!(review_conclusion(&rounds, 3), None);
9611 }
9612
9613 #[test]
9614 fn review_conclusion_is_gating_once_a_round_is_clean() {
9615 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9616 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9617 }
9618
9619 #[test]
9620 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9621 let rounds = vec![
9622 review_round(false, 1, 2, 2, true, true),
9623 review_round(false, 1, 2, 2, true, true),
9624 ];
9625 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9626 }
9627
9628 #[test]
9629 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9630 let rounds = vec![
9631 review_round(false, 1, 2, 2, true, true),
9632 review_round(false, 1, 2, 2, true, false),
9633 ];
9634 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9635 }
9636
9637 #[test]
9638 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9639 let mut blocked = review_round(false, 1, 2, 2, true, false);
9646 blocked.e2e[0].resource_blocked = true;
9647 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9648 assert_eq!(review_conclusion(&rounds, 2), None);
9649 }
9650
9651 #[test]
9652 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9653 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9655 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9656 }
9657
9658 #[test]
9659 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9660 let rounds = vec![
9661 review_round(false, 1, 2, 2, false, true),
9662 review_round(false, 1, 2, 2, false, true),
9663 ];
9664 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9665 }
9666
9667 fn secs(n: u64) -> Duration {
9668 Duration::from_secs(n)
9669 }
9670
9671 fn init_repo(dir: &Path) {
9674 let run = |args: &[&str]| {
9675 let out = std::process::Command::new("git")
9676 .args(args)
9677 .current_dir(dir)
9678 .quiet()
9679 .output()
9680 .expect("spawn git");
9681 assert!(
9682 out.status.success(),
9683 "git {args:?} failed: {}",
9684 String::from_utf8_lossy(&out.stderr)
9685 );
9686 };
9687 run(&["init", "-b", "main"]);
9688 run(&["config", "user.name", "magi test"]);
9689 run(&["config", "user.email", "magi@example.com"]);
9690 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9691 run(&["add", "-A"]);
9692 run(&["commit", "-m", "init"]);
9693 }
9694
9695 fn ask_test_home() {
9703 crate::run::pin_test_home();
9704 }
9705
9706 fn runner_at(status: RunStatus) -> Runner {
9709 let mut state = RunState::new(
9710 PathBuf::from("/nonexistent/repo"),
9711 "main".to_owned(),
9712 "deadbeef".to_owned(),
9713 "task".to_owned(),
9714 Config::default(),
9715 );
9716 state.status = status;
9717 Runner {
9718 state,
9719 roles: ResolvedRoles {
9720 implementers: Vec::new(),
9721 judges: Vec::new(),
9722 reviewers: Vec::new(),
9723 fixer: None,
9724 conductor: conductor(),
9725 implementer_roster: Vec::new(),
9726 judge_roster: Vec::new(),
9727 reviewer_roster: Vec::new(),
9728 },
9729 sem: Arc::new(Semaphore::new(1)),
9730 pause: Pause::new(),
9731 interrupt: Pause::new(),
9732 }
9733 }
9734
9735 #[test]
9739 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9740 crate::run::pin_test_home();
9741 let mut runner = runner_at(RunStatus::Implementing);
9742 let interrupt = Pause::new();
9743 runner.watch_interrupt(interrupt.clone());
9744
9745 interrupt.park_because("task a1b2 asked to run first");
9746
9747 assert!(runner.park_here().expect("park_here"));
9748 assert!(runner.state.parked);
9749 let last = runner.state.events.last().expect("a park event");
9750 assert_eq!(last.node, "park");
9751 assert!(
9752 last.message.contains("task a1b2 asked to run first"),
9753 "expected the interrupt reason in {:?}",
9754 last.message
9755 );
9756 }
9757
9758 #[test]
9766 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9767 crate::run::pin_test_home();
9768 let mut runner = runner_at(RunStatus::Implementing);
9769 let shutdown = Pause::new();
9770 runner.on_pause(shutdown.clone());
9771 let interrupt = Pause::new();
9772 runner.watch_interrupt(interrupt.clone());
9773
9774 assert!(!runner.park_here().expect("park_here"));
9776 assert!(!runner.state.parked);
9777
9778 interrupt.park_because("test");
9780 assert!(!shutdown.parked());
9781 assert!(runner.park_here().expect("park_here"));
9782 }
9783
9784 #[tokio::test]
9798 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9799 crate::run::pin_test_home();
9800 let mut runner = runner_at(RunStatus::Implementing);
9801 let interrupt = Pause::new();
9802 runner.watch_interrupt(interrupt.clone());
9803
9804 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9805 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9806
9807 let node = async move {
9811 started_tx.send(()).expect("send started");
9812 finish_rx.await.expect("recv finish");
9813 "node finished"
9814 };
9815
9816 let interrupter = async move {
9817 started_rx.await.expect("recv started");
9818 interrupt.park_because("higher-priority task waiting");
9820 tokio::task::yield_now().await;
9824 finish_tx.send(()).expect("send finish");
9825 };
9826
9827 let (node_result, ()) = tokio::join!(node, interrupter);
9828 assert_eq!(
9829 node_result, "node finished",
9830 "the in-flight call ran to completion"
9831 );
9832
9833 assert!(runner.park_here().expect("park_here"));
9836 assert!(runner.state.parked);
9837 }
9838
9839 #[test]
9845 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9846 crate::run::pin_test_home();
9847 let mut runner = runner_at(RunStatus::Judging);
9848 runner.state.config.agents = vec![conductor()];
9852 runner.state.candidates = vec![Candidate {
9853 index: 0,
9854 label: 'A',
9855 agent: "alpha".to_owned(),
9856 branch: "magi/x/A".to_owned(),
9857 worktree: PathBuf::from("/nonexistent/worktree"),
9858 summary: "did the thing".to_owned(),
9859 stat: "1 file changed".to_owned(),
9860 files: 1,
9861 commits: 1,
9862 empty: false,
9863 failed: None,
9864 verified_noop: None,
9865 duration_ms: 1234,
9866 folded: false,
9867 }];
9868 let run_id = runner.state.id.clone();
9869
9870 let interrupt = Pause::new();
9871 runner.watch_interrupt(interrupt.clone());
9872 interrupt.park_because("task c3d4 asked to run first");
9873 assert!(runner.park_here().expect("park_here"));
9874
9875 let resumed = Runner::resume(&run_id).expect("resume");
9876 assert_eq!(resumed.state.candidates.len(), 1);
9877 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9878 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9879 assert_eq!(resumed.state.status, runner.state.status);
9880 assert!(
9881 resumed.state.parked,
9882 "still parked until `execute` actually walks the graph again"
9883 );
9884 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9885 }
9886
9887 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9889 let mut q = ask::Question::new(
9890 run.to_owned(),
9891 "implement".to_owned(),
9892 "impl-A".to_owned(),
9893 "Which storage backend should the cache use?".to_owned(),
9894 String::new(),
9895 vec!["SQLite".to_owned(), "Redis".to_owned()],
9896 );
9897 store.put(&mut q).unwrap();
9898 q
9899 }
9900
9901 #[test]
9902 fn a_failed_runs_open_question_is_abandoned() {
9903 ask_test_home();
9904 let store = ask::Questions::open();
9905 let mut runner = runner_at(RunStatus::Failed);
9906 let run = runner.state.id.clone();
9907 let q = ask_open_question(&store, &run);
9908
9909 runner.settle_questions();
9910
9911 let back = store.get(&q.id).unwrap();
9912 assert!(
9913 !back.status.open(),
9914 "the seat that asked died with the run; nobody is left to read an answer"
9915 );
9916 assert!(
9917 back.detail.contains(&run) && back.detail.contains("failed"),
9918 "the reason names what the run became, not just that it is gone: {}",
9919 back.detail
9920 );
9921 }
9922
9923 #[test]
9924 fn a_merged_runs_open_question_is_abandoned_too() {
9925 ask_test_home();
9926 let store = ask::Questions::open();
9927 for status in [RunStatus::Merged, RunStatus::Ready] {
9930 let mut runner = runner_at(status);
9931 let run = runner.state.id.clone();
9932 let q = ask_open_question(&store, &run);
9933
9934 runner.settle_questions();
9935
9936 let back = store.get(&q.id).unwrap();
9937 assert!(
9938 !back.status.open(),
9939 "{status:?} run's question must not outlive the run"
9940 );
9941 }
9942 }
9943
9944 #[test]
9945 fn a_still_resumable_runs_open_question_is_left_alone() {
9946 ask_test_home();
9947 let store = ask::Questions::open();
9948 for status in [RunStatus::Blocked, RunStatus::Stalled] {
9954 let mut runner = runner_at(status);
9955 let run = runner.state.id.clone();
9956 let q = ask_open_question(&store, &run);
9957
9958 runner.settle_questions();
9959
9960 let back = store.get(&q.id).unwrap();
9961 assert!(
9962 back.status.open(),
9963 "{status:?} is still alive; the question must still be waiting"
9964 );
9965 }
9966 }
9967
9968 #[test]
9969 fn settle_questions_never_touches_an_already_answered_question() {
9970 ask_test_home();
9971 let store = ask::Questions::open();
9972 let mut runner = runner_at(RunStatus::Failed);
9973 let run = runner.state.id.clone();
9974 let mut q = ask_open_question(&store, &run);
9975 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9976 .unwrap();
9977 store.put(&mut q).unwrap();
9978
9979 runner.settle_questions();
9984 runner.settle_questions();
9985
9986 let back = store.get(&q.id).unwrap();
9987 assert_eq!(
9988 back.status,
9989 ask::QuestionStatus::Answered,
9990 "a real answer is a decision on record, never overwritten by a sweep"
9991 );
9992 }
9993
9994 #[tokio::test]
10005 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10006 crate::run::pin_test_home();
10007 let tmp = tempfile::tempdir().expect("tempdir");
10008 let repo = tmp.path().join("repo");
10009 std::fs::create_dir_all(&repo).unwrap();
10010 init_repo(&repo);
10011
10012 let mut config = Config::default();
10013 config.graph.worktree_root = Some(tmp.path().join("wt"));
10014
10015 let mut state = RunState::new(
10016 repo.clone(),
10017 "main".to_owned(),
10018 "deadbeef".to_owned(),
10019 "task".to_owned(),
10020 config,
10021 );
10022 let root = state.worktree_root();
10023 let wt_a = root.join("cand-A");
10024 let wt_b = root.join("cand-B");
10025 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10026 .await
10027 .expect("worktree A");
10028 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10029 .await
10030 .expect("worktree B");
10031
10032 state.candidates = vec![
10033 Candidate {
10034 index: 0,
10035 label: 'A',
10036 agent: "alpha".to_owned(),
10037 branch: "magi/x/A".to_owned(),
10038 worktree: wt_a.clone(),
10039 summary: String::new(),
10040 stat: String::new(),
10041 files: 0,
10042 commits: 0,
10043 empty: false,
10044 failed: None,
10045 verified_noop: None,
10046 duration_ms: 0,
10047 folded: false,
10048 },
10049 Candidate {
10050 index: 1,
10051 label: 'B',
10052 agent: "beta".to_owned(),
10053 branch: "magi/x/B".to_owned(),
10054 worktree: wt_b.clone(),
10055 summary: String::new(),
10056 stat: String::new(),
10057 files: 0,
10058 commits: 0,
10059 empty: false,
10060 failed: None,
10061 verified_noop: None,
10062 duration_ms: 0,
10063 folded: false,
10064 },
10065 ];
10066 state.tally = Some(Tally {
10067 first_choice: BTreeMap::from([('A', 1)]),
10068 borda: BTreeMap::new(),
10069 winner: 'A',
10070 rankings: 1,
10071 unanimous_initial: true,
10072 deliberated: false,
10073 changed_votes: 0,
10074 unanimous_final: true,
10075 tie_break: None,
10076 judges: 1,
10077 present: 1,
10078 quorum: 1,
10079 met_quorum: true,
10080 uncontested: None,
10081 });
10082 state.status = RunStatus::Ready;
10083
10084 fold_run(&mut state, false, &crate::run::home())
10085 .await
10086 .expect("fold_run");
10087
10088 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10089 assert!(
10090 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10091 "the unmerged winner's branch survives"
10092 );
10093 assert!(
10094 !state.candidates[0].folded,
10095 "the winner is not marked folded"
10096 );
10097
10098 assert!(!wt_b.exists(), "the loser's worktree is removed");
10099 assert!(
10100 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10101 "the loser's branch is removed"
10102 );
10103 assert!(state.candidates[1].folded, "the loser is marked folded");
10104 }
10105
10106 #[tokio::test]
10109 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10110 let tmp = tempfile::tempdir().expect("tempdir");
10111 let repo = tmp.path().join("repo");
10112 std::fs::create_dir_all(&repo).unwrap();
10113 init_repo(&repo);
10114 let home = tmp.path().join("home");
10115
10116 let mut config = Config::default();
10117 config.graph.worktree_root = Some(tmp.path().join("wt"));
10118 let mut state = RunState::new(
10119 repo.clone(),
10120 "main".to_owned(),
10121 "deadbeef".to_owned(),
10122 "task".to_owned(),
10123 config,
10124 );
10125 git::git(&repo, &["branch", "magi/x/A", "main"])
10127 .await
10128 .expect("branch");
10129 state.candidates = vec![Candidate {
10130 index: 0,
10131 label: 'A',
10132 agent: "alpha".to_owned(),
10133 branch: "magi/x/A".to_owned(),
10134 worktree: state.worktree_root().join("cand-A"),
10135 summary: String::new(),
10136 stat: String::new(),
10137 files: 0,
10138 commits: 0,
10139 empty: false,
10140 failed: None,
10141 verified_noop: None,
10142 duration_ms: 0,
10143 folded: true,
10144 }];
10145 state.released_to = Some("20260901-000000-new1".to_owned());
10146 state.released_branches = vec!["magi/x/A".to_owned()];
10147
10148 fold_run(&mut state, true, &home).await.expect("fold_run");
10149
10150 assert!(
10151 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10152 "the handed-over branch survives a fold"
10153 );
10154 }
10155
10156 #[tokio::test]
10160 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10161 let tmp = tempfile::tempdir().expect("tempdir");
10162 let repo = tmp.path().join("repo");
10163 std::fs::create_dir_all(&repo).unwrap();
10164 init_repo(&repo);
10165 let run = |args: &[&str]| {
10166 let out = std::process::Command::new("git")
10167 .quiet()
10168 .args(args)
10169 .current_dir(&repo)
10170 .output()
10171 .expect("spawn git");
10172 assert!(out.status.success(), "git {args:?}");
10173 };
10174 run(&["branch", "magi/x/A"]);
10175 run(&["checkout", "-q", "-b", "magi/x/B"]);
10176 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10177 run(&["add", "-A"]);
10178 run(&["commit", "-q", "-m", "work"]);
10179 run(&["checkout", "-q", "main"]);
10180
10181 let probe = RunState::new(
10185 repo.clone(),
10186 "main".to_owned(),
10187 "deadbeef".to_owned(),
10188 "task".to_owned(),
10189 Config::default(),
10190 );
10191 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10192 let bare = tmp.path().join("origin.git");
10193 let out = std::process::Command::new("git")
10194 .quiet()
10195 .args(["init", "-q", "--bare"])
10196 .arg(&bare)
10197 .output()
10198 .expect("spawn git");
10199 assert!(out.status.success(), "git init --bare");
10200 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10201 run(&["push", "-q", "origin", "main"]);
10202
10203 let mut state = RunState::new(
10204 repo.clone(),
10205 "main".to_owned(),
10206 "deadbeef".to_owned(),
10207 "task".to_owned(),
10208 Config::default(),
10209 );
10210 state.seeds = vec![refs::Seed {
10211 token: "magi/27b2/A".to_owned(),
10212 kind: refs::SeedKind::Unresolved,
10213 sha: String::new(),
10214 branch: true,
10215 detail: "no branch or commit named magi/27b2/A".to_owned(),
10216 }];
10217
10218 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10219 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10220 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10221 let detail = empty_candidate_detail(&state, "main");
10222 assert!(detail.starts_with("empty candidate"), "{detail}");
10223 assert!(detail.contains("magi/27b2/A"), "{detail}");
10224 }
10225
10226 #[tokio::test]
10235 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10236 let tmp = tempfile::tempdir().expect("tempdir");
10237 let repo = tmp.path().join("repo");
10238 std::fs::create_dir_all(&repo).unwrap();
10239 init_repo(&repo);
10240
10241 let mut config = Config::default();
10242 config.merge.mode = MergeMode::Local;
10243
10244 let mut state = RunState::new(
10245 repo.clone(),
10246 "main".to_owned(),
10247 "deadbeef".to_owned(),
10248 "task".to_owned(),
10249 config,
10250 );
10251 state.candidates = vec![Candidate {
10252 index: 0,
10253 label: 'A',
10254 agent: "alpha".to_owned(),
10255 branch: "does-not-exist".to_owned(),
10256 worktree: repo.clone(),
10257 summary: String::new(),
10258 stat: String::new(),
10259 files: 0,
10260 commits: 0,
10261 empty: false,
10262 failed: None,
10263 verified_noop: None,
10264 duration_ms: 0,
10265 folded: false,
10266 }];
10267 state.tally = Some(Tally {
10268 first_choice: BTreeMap::from([('A', 1)]),
10269 borda: BTreeMap::new(),
10270 winner: 'A',
10271 rankings: 1,
10272 unanimous_initial: true,
10273 deliberated: false,
10274 changed_votes: 0,
10275 unanimous_final: true,
10276 tie_break: None,
10277 judges: 0,
10278 present: 0,
10279 quorum: 0,
10280 met_quorum: true,
10281 uncontested: Some("only candidate A produced a change".to_owned()),
10282 });
10283 state.reviews = vec![ReviewRound {
10284 round: 1,
10285 head: "deadbeef".to_owned(),
10286 verified_head: None,
10287 verified_at: None,
10288 reviews: Vec::new(),
10289 e2e: Vec::new(),
10290 fix: None,
10291 blocking: 0,
10292 answered: 0,
10293 expected: 0,
10294 clean: true,
10295 verify_retried: false,
10296 e2e_deferred: false,
10297 e2e_defer_reason: None,
10298 progressed: false,
10299 vote_split: false,
10300 reconsideration: Vec::new(),
10301 verdict: None,
10302 }];
10303 state.gate = vec![CommandOutcome {
10304 command: "test".to_owned(),
10305 code: Some(0),
10306 output_tail: String::new(),
10307 duration_ms: 0,
10308 resource_blocked: false,
10309 }];
10310 state.gate_ran = true;
10311 state.status = RunStatus::Ready;
10316 state.merge = Some(MergeOutcome {
10317 mode: MergeMode::Local,
10318 ok: false,
10319 detail: "already concluded".to_owned(),
10320 empty: false,
10321 });
10322
10323 let mut runner = Runner {
10324 state,
10325 roles: ResolvedRoles {
10326 implementers: Vec::new(),
10327 judges: Vec::new(),
10328 reviewers: Vec::new(),
10329 fixer: None,
10330 conductor: conductor(),
10331 implementer_roster: Vec::new(),
10332 judge_roster: Vec::new(),
10333 reviewer_roster: Vec::new(),
10334 },
10335 sem: Arc::new(Semaphore::new(1)),
10336 pause: Pause::new(),
10337 interrupt: Pause::new(),
10338 };
10339
10340 runner.merge().await.expect("merge");
10341
10342 assert_eq!(
10343 runner.state.status,
10344 RunStatus::Ready,
10345 "a concluded run's status must not change on reentry"
10346 );
10347 assert_eq!(
10348 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10349 Some("already concluded"),
10350 "merge must not run again once the node already recorded an outcome"
10351 );
10352 }
10353
10354 #[tokio::test]
10363 async fn merge_refuses_a_gate_that_has_not_actually_run() {
10364 let tmp = tempfile::tempdir().expect("tempdir");
10365 let repo = tmp.path().join("repo");
10366 std::fs::create_dir_all(&repo).unwrap();
10367 init_repo(&repo);
10368
10369 let mut config = Config::default();
10370 config.merge.mode = MergeMode::Local;
10371
10372 let mut state = RunState::new(
10373 repo.clone(),
10374 "main".to_owned(),
10375 "deadbeef".to_owned(),
10376 "task".to_owned(),
10377 config,
10378 );
10379 state.candidates = vec![Candidate {
10380 index: 0,
10381 label: 'A',
10382 agent: "alpha".to_owned(),
10383 branch: "does-not-exist".to_owned(),
10384 worktree: repo.clone(),
10385 summary: String::new(),
10386 stat: String::new(),
10387 files: 0,
10388 commits: 0,
10389 empty: false,
10390 failed: None,
10391 verified_noop: None,
10392 duration_ms: 0,
10393 folded: false,
10394 }];
10395 state.tally = Some(Tally {
10396 first_choice: BTreeMap::from([('A', 1)]),
10397 borda: BTreeMap::new(),
10398 winner: 'A',
10399 rankings: 1,
10400 unanimous_initial: true,
10401 deliberated: false,
10402 changed_votes: 0,
10403 unanimous_final: true,
10404 tie_break: None,
10405 judges: 0,
10406 present: 0,
10407 quorum: 0,
10408 met_quorum: true,
10409 uncontested: Some("only candidate A produced a change".to_owned()),
10410 });
10411 state.reviews = vec![ReviewRound {
10412 round: 1,
10413 head: "deadbeef".to_owned(),
10414 verified_head: None,
10415 verified_at: None,
10416 reviews: Vec::new(),
10417 e2e: Vec::new(),
10418 fix: None,
10419 blocking: 0,
10420 answered: 0,
10421 expected: 0,
10422 clean: true,
10423 verify_retried: false,
10424 e2e_deferred: false,
10425 e2e_defer_reason: None,
10426 progressed: false,
10427 vote_split: false,
10428 reconsideration: Vec::new(),
10429 verdict: None,
10430 }];
10431 state.gate = Vec::new();
10433 state.gate_ran = false;
10434 state.status = RunStatus::Gating;
10435
10436 let mut runner = Runner {
10437 state,
10438 roles: ResolvedRoles {
10439 implementers: Vec::new(),
10440 judges: Vec::new(),
10441 reviewers: Vec::new(),
10442 fixer: None,
10443 conductor: conductor(),
10444 implementer_roster: Vec::new(),
10445 judge_roster: Vec::new(),
10446 reviewer_roster: Vec::new(),
10447 },
10448 sem: Arc::new(Semaphore::new(1)),
10449 pause: Pause::new(),
10450 interrupt: Pause::new(),
10451 };
10452
10453 runner.merge().await.expect("merge");
10454
10455 assert!(
10456 runner.state.merge.is_none(),
10457 "an empty gate must never be read as a passing one: {:?}",
10458 runner.state.merge
10459 );
10460 }
10461
10462 #[tokio::test]
10469 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10470 let tmp = tempfile::tempdir().expect("tempdir");
10471 let repo = tmp.path().join("repo");
10472 std::fs::create_dir_all(&repo).unwrap();
10473 init_repo(&repo);
10474
10475 let config = Config::default();
10477
10478 let mut state = RunState::new(
10479 repo.clone(),
10480 "main".to_owned(),
10481 "deadbeef".to_owned(),
10482 "task".to_owned(),
10483 config,
10484 );
10485 state.candidates = vec![Candidate {
10486 index: 0,
10487 label: 'A',
10488 agent: "alpha".to_owned(),
10489 branch: "does-not-exist".to_owned(),
10490 worktree: repo.clone(),
10491 summary: String::new(),
10492 stat: String::new(),
10493 files: 0,
10494 commits: 0,
10495 empty: false,
10496 failed: None,
10497 verified_noop: None,
10498 duration_ms: 0,
10499 folded: false,
10500 }];
10501 state.tally = Some(Tally {
10502 first_choice: BTreeMap::from([('A', 1)]),
10503 borda: BTreeMap::new(),
10504 winner: 'A',
10505 rankings: 1,
10506 unanimous_initial: true,
10507 deliberated: false,
10508 changed_votes: 0,
10509 unanimous_final: true,
10510 tie_break: None,
10511 judges: 0,
10512 present: 0,
10513 quorum: 0,
10514 met_quorum: true,
10515 uncontested: Some("only candidate A produced a change".to_owned()),
10516 });
10517 state.reviews = vec![ReviewRound {
10518 round: 1,
10519 head: "deadbeef".to_owned(),
10520 verified_head: None,
10521 verified_at: None,
10522 reviews: Vec::new(),
10523 e2e: Vec::new(),
10524 fix: None,
10525 blocking: 0,
10526 answered: 0,
10527 expected: 0,
10528 clean: true,
10529 verify_retried: false,
10530 e2e_deferred: false,
10531 e2e_defer_reason: None,
10532 progressed: false,
10533 vote_split: false,
10534 reconsideration: Vec::new(),
10535 verdict: None,
10536 }];
10537
10538 let mut runner = Runner {
10539 state,
10540 roles: ResolvedRoles {
10541 implementers: Vec::new(),
10542 judges: Vec::new(),
10543 reviewers: Vec::new(),
10544 fixer: None,
10545 conductor: conductor(),
10546 implementer_roster: Vec::new(),
10547 judge_roster: Vec::new(),
10548 reviewer_roster: Vec::new(),
10549 },
10550 sem: Arc::new(Semaphore::new(1)),
10551 pause: Pause::new(),
10552 interrupt: Pause::new(),
10553 };
10554
10555 runner.gate().await.expect("gate");
10556 assert!(
10557 runner.state.gate_ran,
10558 "zero configured commands is still a real attempt, not an unrun gate"
10559 );
10560 assert!(runner.state.gate.is_empty());
10561 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10562 assert_ne!(
10563 runner.state.status,
10564 RunStatus::Blocked,
10565 "a gate with nothing to check must not read as failed"
10566 );
10567
10568 runner.merge().await.expect("merge");
10569 assert_eq!(
10570 runner.state.status,
10571 RunStatus::Ready,
10572 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10573 );
10574 }
10575
10576 #[tokio::test]
10587 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10588 crate::run::pin_test_home();
10589 let home = crate::run::home();
10590
10591 let tmp = tempfile::tempdir().expect("tempdir");
10592 let repo = tmp.path().join("repo");
10593 std::fs::create_dir_all(&repo).unwrap();
10594 init_repo(&repo);
10595 let cache_dir = tmp.path().join("target");
10598
10599 let mut config = Config::default();
10600 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10601 config.graph.timeout_verify = Some(2);
10604
10605 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10606 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10607 .expect("no io error acquiring directly")
10608 {
10609 crate::cache::AcquireOutcome::Acquired(g) => g,
10610 crate::cache::AcquireOutcome::Busy(b) => {
10611 panic!("expected the direct acquire to win the lease first: {b:?}")
10612 }
10613 };
10614
10615 let mut state = RunState::new(
10616 repo.clone(),
10617 "main".to_owned(),
10618 "deadbeef".to_owned(),
10619 "task".to_owned(),
10620 config,
10621 );
10622 state.candidates = vec![Candidate {
10623 index: 0,
10624 label: 'A',
10625 agent: "alpha".to_owned(),
10626 branch: "does-not-exist".to_owned(),
10627 worktree: repo.clone(),
10628 summary: String::new(),
10629 stat: String::new(),
10630 files: 0,
10631 commits: 0,
10632 empty: false,
10633 failed: None,
10634 verified_noop: None,
10635 duration_ms: 0,
10636 folded: false,
10637 }];
10638 state.tally = Some(Tally {
10639 first_choice: BTreeMap::from([('A', 1)]),
10640 borda: BTreeMap::new(),
10641 winner: 'A',
10642 rankings: 1,
10643 unanimous_initial: true,
10644 deliberated: false,
10645 changed_votes: 0,
10646 unanimous_final: true,
10647 tie_break: None,
10648 judges: 0,
10649 present: 0,
10650 quorum: 0,
10651 met_quorum: true,
10652 uncontested: Some("only candidate A produced a change".to_owned()),
10653 });
10654 state.reviews = vec![ReviewRound {
10655 round: 1,
10656 head: "deadbeef".to_owned(),
10657 verified_head: None,
10658 verified_at: None,
10659 reviews: Vec::new(),
10660 e2e: Vec::new(),
10661 fix: None,
10662 blocking: 0,
10663 answered: 0,
10664 expected: 0,
10665 clean: true,
10666 verify_retried: false,
10667 e2e_deferred: false,
10668 e2e_defer_reason: None,
10669 progressed: false,
10670 vote_split: false,
10671 reconsideration: Vec::new(),
10672 verdict: None,
10673 }];
10674
10675 let mut runner = Runner {
10676 state,
10677 roles: ResolvedRoles {
10678 implementers: Vec::new(),
10679 judges: Vec::new(),
10680 reviewers: Vec::new(),
10681 fixer: None,
10682 conductor: conductor(),
10683 implementer_roster: Vec::new(),
10684 judge_roster: Vec::new(),
10685 reviewer_roster: Vec::new(),
10686 },
10687 sem: Arc::new(Semaphore::new(1)),
10688 pause: Pause::new(),
10689 interrupt: Pause::new(),
10690 };
10691
10692 let started = std::time::Instant::now();
10693 runner.gate().await.expect("gate");
10694 assert!(
10695 started.elapsed() < Duration::from_secs(1),
10696 "a gate with nothing to run must never wait on a lease it never needed"
10697 );
10698 assert!(
10699 runner.state.gate_ran,
10700 "zero commands is still a real, immediate attempt"
10701 );
10702 assert!(runner.state.gate.is_empty());
10703 assert_ne!(
10704 runner.state.status,
10705 RunStatus::Blocked,
10706 "must not read as resource-blocked on a lease it never asked for"
10707 );
10708 }
10709
10710 #[tokio::test]
10720 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10721 crate::run::pin_test_home();
10722
10723 let tmp = tempfile::tempdir().expect("tempdir");
10724 let repo = tmp.path().join("repo");
10725 std::fs::create_dir_all(&repo).unwrap();
10726 init_repo(&repo);
10727
10728 let mut config = Config::default();
10729 config.verify.gate = vec![
10730 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10731 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10732 .to_owned(),
10733 ];
10734
10735 let mut state = RunState::new(
10736 repo.clone(),
10737 "main".to_owned(),
10738 "deadbeef".to_owned(),
10739 "task".to_owned(),
10740 config,
10741 );
10742 let run_id = state.id.clone();
10743 state.candidates = vec![Candidate {
10744 index: 0,
10745 label: 'A',
10746 agent: "alpha".to_owned(),
10747 branch: "does-not-exist".to_owned(),
10748 worktree: repo.clone(),
10749 summary: String::new(),
10750 stat: String::new(),
10751 files: 0,
10752 commits: 0,
10753 empty: false,
10754 failed: None,
10755 verified_noop: None,
10756 duration_ms: 0,
10757 folded: false,
10758 }];
10759 state.tally = Some(Tally {
10760 first_choice: BTreeMap::from([('A', 1)]),
10761 borda: BTreeMap::new(),
10762 winner: 'A',
10763 rankings: 1,
10764 unanimous_initial: true,
10765 deliberated: false,
10766 changed_votes: 0,
10767 unanimous_final: true,
10768 tie_break: None,
10769 judges: 0,
10770 present: 0,
10771 quorum: 0,
10772 met_quorum: true,
10773 uncontested: Some("only candidate A produced a change".to_owned()),
10774 });
10775 state.reviews = vec![ReviewRound {
10776 round: 1,
10777 head: "deadbeef".to_owned(),
10778 verified_head: None,
10779 verified_at: None,
10780 reviews: Vec::new(),
10781 e2e: Vec::new(),
10782 fix: None,
10783 blocking: 0,
10784 answered: 0,
10785 expected: 0,
10786 clean: true,
10787 verify_retried: false,
10788 e2e_deferred: false,
10789 e2e_defer_reason: None,
10790 progressed: false,
10791 vote_split: false,
10792 reconsideration: Vec::new(),
10793 verdict: None,
10794 }];
10795
10796 let mut runner = Runner {
10797 state,
10798 roles: ResolvedRoles {
10799 implementers: Vec::new(),
10800 judges: Vec::new(),
10801 reviewers: Vec::new(),
10802 fixer: None,
10803 conductor: conductor(),
10804 implementer_roster: Vec::new(),
10805 judge_roster: Vec::new(),
10806 reviewer_roster: Vec::new(),
10807 },
10808 sem: Arc::new(Semaphore::new(1)),
10809 pause: Pause::new(),
10810 interrupt: Pause::new(),
10811 };
10812
10813 let started_marker = repo.join("started.marker");
10814 let release_marker = repo.join("release.marker");
10815 let poller = tokio::spawn(async move {
10816 for _ in 0..100 {
10821 if started_marker.exists()
10822 && let Ok(s) = crate::run::RunState::load(&run_id)
10823 && let Some(a) = s.active.get("gate")
10824 {
10825 std::fs::write(&release_marker, b"go").expect("release marker");
10826 return Some(a.clone());
10827 }
10828 tokio::time::sleep(Duration::from_millis(50)).await;
10829 }
10830 None
10831 });
10832
10833 runner.gate().await.expect("gate");
10834 let captured = poller.await.expect("poller task");
10835 let captured = captured.expect(
10836 "the poller never saw a `gate` task entry in run.json while the command was \
10837 still blocked on its own release marker",
10838 );
10839
10840 assert_eq!(captured.task.as_deref(), Some("gate"));
10841 assert_eq!(captured.node, "gate");
10842 assert_eq!(captured.index, Some(1));
10843 assert_eq!(captured.total, Some(1));
10844 assert!(
10845 captured
10846 .command
10847 .as_deref()
10848 .is_some_and(|c| c.contains("started.marker")),
10849 "{captured:?}"
10850 );
10851
10852 assert!(
10853 runner.state.active.is_empty(),
10854 "the entry must be cleared once the command actually finished: {:?}",
10855 runner.state.active
10856 );
10857 assert!(runner.state.gate_ran);
10858 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10859 }
10860
10861 #[tokio::test]
10864 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10865 use crate::verdict::{Finding, ReviewVote, Severity};
10866 crate::run::pin_test_home();
10867 let tmp = tempfile::tempdir().expect("tempdir");
10868 let repo = tmp.path().join("repo");
10869 std::fs::create_dir_all(&repo).unwrap();
10870 init_repo(&repo);
10871
10872 for (severity, vote, expect) in [
10873 (Severity::Major, ReviewVote::Reject, true),
10874 (Severity::Minor, ReviewVote::Reject, false),
10875 (Severity::Major, ReviewVote::Approve, false),
10876 ] {
10877 let mut round = review_round(false, 1, 1, 1, false, true);
10878 round.reviews = vec![ReviewRecord {
10879 reviewer: 1,
10880 agent: "alpha".to_owned(),
10881 summary: String::new(),
10882 findings: vec![Finding {
10883 id: "R1-1-1".to_owned(),
10884 severity,
10885 file: None,
10886 line: None,
10887 title: "t".to_owned(),
10888 detail: String::new(),
10889 }],
10890 vote: Some(vote),
10891 failed: None,
10892 duration_ms: 0,
10893 attempts: 0,
10894 }];
10895 let mut state = RunState::new(
10896 repo.clone(),
10897 "main".to_owned(),
10898 "deadbeef".to_owned(),
10899 "task".to_owned(),
10900 Config::default(),
10901 );
10902 state.reviews = vec![round];
10903 let mut runner = Runner {
10904 state,
10905 roles: ResolvedRoles {
10906 implementers: Vec::new(),
10907 judges: Vec::new(),
10908 reviewers: Vec::new(),
10909 fixer: None,
10910 conductor: conductor(),
10911 implementer_roster: Vec::new(),
10912 judge_roster: Vec::new(),
10913 reviewer_roster: Vec::new(),
10914 },
10915 sem: Arc::new(Semaphore::new(1)),
10916 pause: Pause::new(),
10917 interrupt: Pause::new(),
10918 };
10919 let shell = runner.state.config.shell();
10920 runner
10921 .stop_reviewing("round budget spent", &shell, &repo)
10922 .await
10923 .expect("stop_reviewing");
10924 assert_eq!(runner.state.status, RunStatus::Gating);
10925 assert_eq!(
10926 runner.state.contested_handoff.is_some(),
10927 expect,
10928 "{severity:?} + {vote:?}"
10929 );
10930 }
10931 }
10932
10933 #[tokio::test]
10946 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10947 crate::run::pin_test_home();
10948 let home = crate::run::home();
10949
10950 let tmp = tempfile::tempdir().expect("tempdir");
10951 let repo = tmp.path().join("repo");
10952 std::fs::create_dir_all(&repo).unwrap();
10953 init_repo(&repo);
10954 let head = crate::git::rev_parse(&repo, "HEAD")
10955 .await
10956 .expect("rev-parse");
10957 let cache_dir = tmp.path().join("target");
10960
10961 let mut config = Config::default();
10962 config.verify.e2e = vec![format!(
10963 "CARGO_TARGET_DIR='{}' test -f README.md",
10964 cache_dir.display()
10965 )];
10966 config.graph.review_rounds = 1;
10967 config.graph.timeout_verify = Some(2);
10970
10971 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10972 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10973 .expect("no io error acquiring directly")
10974 {
10975 crate::cache::AcquireOutcome::Acquired(g) => g,
10976 crate::cache::AcquireOutcome::Busy(b) => {
10977 panic!("expected the direct acquire to win the lease first: {b:?}")
10978 }
10979 };
10980
10981 let mut state = RunState::new(
10982 repo.clone(),
10983 "main".to_owned(),
10984 head.clone(),
10985 "task".to_owned(),
10986 config,
10987 );
10988 state.candidates = vec![Candidate {
10989 index: 0,
10990 label: 'A',
10991 agent: "alpha".to_owned(),
10992 branch: "does-not-exist".to_owned(),
10993 worktree: repo.clone(),
10994 summary: String::new(),
10995 stat: String::new(),
10996 files: 0,
10997 commits: 0,
10998 empty: false,
10999 failed: None,
11000 verified_noop: None,
11001 duration_ms: 0,
11002 folded: false,
11003 }];
11004 state.tally = Some(Tally {
11005 first_choice: BTreeMap::from([('A', 1)]),
11006 borda: BTreeMap::new(),
11007 winner: 'A',
11008 rankings: 1,
11009 unanimous_initial: true,
11010 deliberated: false,
11011 changed_votes: 0,
11012 unanimous_final: true,
11013 tie_break: None,
11014 judges: 0,
11015 present: 0,
11016 quorum: 0,
11017 met_quorum: true,
11018 uncontested: Some("only candidate A produced a change".to_owned()),
11019 });
11020 state.reviews = vec![ReviewRound {
11024 round: 1,
11025 head: head.clone(),
11026 verified_head: None,
11027 verified_at: None,
11028 reviews: Vec::new(),
11029 e2e: Vec::new(),
11030 fix: None,
11031 blocking: 1,
11032 answered: 1,
11033 expected: 1,
11034 clean: false,
11035 verify_retried: false,
11036 e2e_deferred: true,
11037 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11038 progressed: false,
11039 vote_split: false,
11040 reconsideration: Vec::new(),
11041 verdict: None,
11042 }];
11043
11044 let mut runner = Runner {
11045 state,
11046 roles: ResolvedRoles {
11047 implementers: Vec::new(),
11048 judges: Vec::new(),
11049 reviewers: Vec::new(),
11050 fixer: None,
11051 conductor: conductor(),
11052 implementer_roster: Vec::new(),
11053 judge_roster: Vec::new(),
11054 reviewer_roster: Vec::new(),
11055 },
11056 sem: Arc::new(Semaphore::new(1)),
11057 pause: Pause::new(),
11058 interrupt: Pause::new(),
11059 };
11060
11061 let shell = runner.state.config.shell();
11062 runner
11063 .stop_reviewing("round budget spent", &shell, &repo)
11064 .await
11065 .expect("stop_reviewing");
11066
11067 let last = runner.state.reviews.last().expect("round record");
11068 assert_eq!(
11069 last.e2e_status(),
11070 E2eStatus::ResourceBlocked,
11071 "the shared cache is still held; the attempt must read as blocked, not deferred or \
11072 failed: {last:?}"
11073 );
11074 assert_eq!(
11075 last.verified_head.as_deref(),
11076 Some(head.as_str()),
11077 "which commit this attempt targeted is known even though nothing finished checking \
11078 it"
11079 );
11080 let first_attempt_at = last
11081 .verified_at
11082 .expect("when this attempt ran is known too");
11083 assert_ne!(
11084 runner.state.status,
11085 RunStatus::Blocked,
11086 "contention is evidence about the machine, not the patch — it must not settle the \
11087 run as blocked: {:?}",
11088 runner.state.status
11089 );
11090 assert!(
11091 !runner
11092 .state
11093 .events
11094 .iter()
11095 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11096 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11097 runner.state.events
11098 );
11099
11100 runner
11105 .stop_reviewing("round budget spent", &shell, &repo)
11106 .await
11107 .expect("stop_reviewing retry");
11108 assert_eq!(
11109 runner.state.reviews.len(),
11110 1,
11111 "no new round was started: {:?}",
11112 runner.state.reviews
11113 );
11114 let last = runner.state.reviews.last().expect("round record");
11115 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11116 assert!(
11117 last.verified_at.expect("still known") > first_attempt_at,
11118 "a second reentry must be a fresh attempt, not a stale copy of the first"
11119 );
11120 assert_ne!(runner.state.status, RunStatus::Blocked);
11121
11122 held.release();
11123 }
11124
11125 #[tokio::test]
11137 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11138 crate::run::pin_test_home();
11139 let home = crate::run::home();
11140
11141 let tmp = tempfile::tempdir().expect("tempdir");
11142 let repo = tmp.path().join("repo");
11143 std::fs::create_dir_all(&repo).unwrap();
11144 init_repo(&repo);
11145 let head = crate::git::rev_parse(&repo, "HEAD")
11146 .await
11147 .expect("rev-parse");
11148 let cache_dir = tmp.path().join("target");
11149
11150 let mut config = Config::default();
11151 config.verify.e2e = vec![format!(
11152 "CARGO_TARGET_DIR='{}' test -f README.md",
11153 cache_dir.display()
11154 )];
11155 config.graph.review_rounds = 1;
11156 config.graph.timeout_verify = Some(2);
11157
11158 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11159 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11160 .expect("no io error acquiring directly")
11161 {
11162 crate::cache::AcquireOutcome::Acquired(g) => g,
11163 crate::cache::AcquireOutcome::Busy(b) => {
11164 panic!("expected the direct acquire to win the lease first: {b:?}")
11165 }
11166 };
11167
11168 let mut state = RunState::new(
11169 repo.clone(),
11170 "main".to_owned(),
11171 head.clone(),
11172 "task".to_owned(),
11173 config,
11174 );
11175 state.candidates = vec![Candidate {
11176 index: 0,
11177 label: 'A',
11178 agent: "alpha".to_owned(),
11179 branch: "does-not-exist".to_owned(),
11180 worktree: repo.clone(),
11181 summary: String::new(),
11182 stat: String::new(),
11183 files: 0,
11184 commits: 0,
11185 empty: false,
11186 failed: None,
11187 verified_noop: None,
11188 duration_ms: 0,
11189 folded: false,
11190 }];
11191 state.tally = Some(Tally {
11192 first_choice: BTreeMap::from([('A', 1)]),
11193 borda: BTreeMap::new(),
11194 winner: 'A',
11195 rankings: 1,
11196 unanimous_initial: true,
11197 deliberated: false,
11198 changed_votes: 0,
11199 unanimous_final: true,
11200 tie_break: None,
11201 judges: 0,
11202 present: 0,
11203 quorum: 0,
11204 met_quorum: true,
11205 uncontested: Some("only candidate A produced a change".to_owned()),
11206 });
11207 state.reviews = vec![ReviewRound {
11211 round: 1,
11212 head: head.clone(),
11213 verified_head: Some(head.clone()),
11214 verified_at: Some(jiff::Timestamp::now()),
11215 reviews: Vec::new(),
11216 e2e: vec![CommandOutcome {
11217 command: format!(
11218 "CARGO_TARGET_DIR='{}' test -f README.md",
11219 cache_dir.display()
11220 ),
11221 code: None,
11222 output_tail: "waiting for the shared build cache".to_owned(),
11223 duration_ms: 0,
11224 resource_blocked: true,
11225 }],
11226 fix: None,
11227 blocking: 1,
11228 answered: 1,
11229 expected: 1,
11230 clean: false,
11231 verify_retried: false,
11232 e2e_deferred: false,
11233 e2e_defer_reason: None,
11234 progressed: false,
11235 vote_split: false,
11236 reconsideration: Vec::new(),
11237 verdict: None,
11238 }];
11239
11240 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11241 let mut runner = Runner {
11242 state,
11243 roles: ResolvedRoles {
11244 implementers: Vec::new(),
11245 judges: Vec::new(),
11246 reviewers: Vec::new(),
11247 fixer: None,
11248 conductor: conductor(),
11249 implementer_roster: Vec::new(),
11250 judge_roster: Vec::new(),
11251 reviewer_roster: Vec::new(),
11252 },
11253 sem: Arc::new(Semaphore::new(1)),
11254 pause: Pause::new(),
11255 interrupt: Pause::new(),
11256 };
11257
11258 runner.review_loop().await.expect("review_loop");
11263
11264 assert_eq!(
11265 runner.state.reviews.len(),
11266 1,
11267 "no new round was started on top of the unresolved one: {:?}",
11268 runner.state.reviews
11269 );
11270 let last = &runner.state.reviews[0];
11271 assert_eq!(
11272 last.e2e_status(),
11273 E2eStatus::ResourceBlocked,
11274 "still contended: {last:?}"
11275 );
11276 assert!(
11277 last.verified_at.expect("still known") > first_attempt_at,
11278 "review_loop must have actually retried the check, not left it exactly as found"
11279 );
11280 assert_ne!(
11281 runner.state.status,
11282 RunStatus::Blocked,
11283 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11284 runner.state.status
11285 );
11286
11287 held.release();
11288 }
11289
11290 #[tokio::test]
11291 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11292 crate::run::pin_test_home();
11293 let tmp = tempfile::tempdir().expect("tempdir");
11294 let repo = tmp.path().join("repo");
11295 std::fs::create_dir_all(&repo).unwrap();
11296 init_repo(&repo);
11297
11298 let mut config = Config::default();
11299 config.merge.mode = MergeMode::Pr;
11300 config.graph.land = true;
11301 config.graph.land_approval = false;
11302
11303 let mut state = RunState::new(
11304 repo.clone(),
11305 "main".to_owned(),
11306 "deadbeef".to_owned(),
11307 "task".to_owned(),
11308 config,
11309 );
11310 state.candidates = vec![Candidate {
11311 index: 0,
11312 label: 'A',
11313 agent: "alpha".to_owned(),
11314 branch: "does-not-exist".to_owned(),
11315 worktree: repo.clone(),
11316 summary: String::new(),
11317 stat: String::new(),
11318 files: 0,
11319 commits: 0,
11320 empty: false,
11321 failed: None,
11322 verified_noop: None,
11323 duration_ms: 0,
11324 folded: false,
11325 }];
11326 state.tally = Some(Tally {
11327 first_choice: BTreeMap::from([('A', 1)]),
11328 borda: BTreeMap::new(),
11329 winner: 'A',
11330 rankings: 1,
11331 unanimous_initial: true,
11332 deliberated: false,
11333 changed_votes: 0,
11334 unanimous_final: true,
11335 tie_break: None,
11336 judges: 0,
11337 present: 0,
11338 quorum: 0,
11339 met_quorum: true,
11340 uncontested: Some("only candidate A produced a change".to_owned()),
11341 });
11342 state.reviews = vec![ReviewRound {
11343 round: 1,
11344 head: "deadbeef".to_owned(),
11345 verified_head: None,
11346 verified_at: None,
11347 reviews: Vec::new(),
11348 e2e: Vec::new(),
11349 fix: None,
11350 blocking: 0,
11351 answered: 0,
11352 expected: 0,
11353 clean: true,
11354 verify_retried: false,
11355 e2e_deferred: false,
11356 e2e_defer_reason: None,
11357 progressed: false,
11358 vote_split: false,
11359 reconsideration: Vec::new(),
11360 verdict: None,
11361 }];
11362 state.gate = vec![CommandOutcome {
11363 command: "test".to_owned(),
11364 code: Some(0),
11365 output_tail: String::new(),
11366 duration_ms: 0,
11367 resource_blocked: false,
11368 }];
11369 state.gate_ran = true;
11370 state.status = RunStatus::Landing;
11374 state.merge = Some(MergeOutcome {
11375 mode: MergeMode::Pr,
11376 ok: true,
11377 detail: "https://example.invalid/x/y/pull/1".to_owned(),
11378 empty: false,
11379 });
11380
11381 ask_test_home();
11385 let store = ask::Questions::open();
11386 let q = ask_open_question(&store, &state.id);
11387
11388 let mut runner = Runner {
11389 state,
11390 roles: ResolvedRoles {
11391 implementers: Vec::new(),
11392 judges: Vec::new(),
11393 reviewers: Vec::new(),
11394 fixer: None,
11395 conductor: conductor(),
11396 implementer_roster: Vec::new(),
11397 judge_roster: Vec::new(),
11398 reviewer_roster: Vec::new(),
11399 },
11400 sem: Arc::new(Semaphore::new(1)),
11401 pause: Pause::new(),
11402 interrupt: Pause::new(),
11403 };
11404
11405 runner.execute().await.expect("execute");
11410
11411 assert_eq!(
11412 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11413 Some("https://example.invalid/x/y/pull/1"),
11414 "reentry must not push again or open a second pull request over the \
11415 one `land` is already watching"
11416 );
11417 assert_ne!(
11418 runner.state.status,
11419 RunStatus::Landing,
11420 "land could not actually reach the fake pull request, so it must \
11421 have given up rather than left the run silently parked forever"
11422 );
11423 assert_eq!(runner.state.status, RunStatus::Blocked);
11427 assert!(
11428 store.get(&q.id).unwrap().status.open(),
11429 "Blocked is still alive; settle_questions must have been a no-op here"
11430 );
11431 }
11432
11433 fn state_with_round(round: ReviewRound) -> RunState {
11434 let mut s = RunState::new(
11435 PathBuf::from("/repo"),
11436 "main".to_owned(),
11437 "abc1234".to_owned(),
11438 "add retries".to_owned(),
11439 Config::default(),
11440 );
11441 s.reviews = vec![round];
11442 s
11443 }
11444
11445 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11446 crate::verdict::Finding {
11447 id: id.to_owned(),
11448 severity,
11449 file: None,
11450 line: None,
11451 title: title.to_owned(),
11452 detail: String::new(),
11453 }
11454 }
11455
11456 #[test]
11457 fn pr_body_names_open_findings_and_declined_ones() {
11458 let round = ReviewRound {
11459 round: 2,
11460 head: "deadbee".to_owned(),
11461 verified_head: None,
11462 verified_at: None,
11463 reviews: vec![ReviewRecord {
11464 attempts: 0,
11465 reviewer: 1,
11466 agent: "alpha".to_owned(),
11467 summary: String::new(),
11468 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11469 vote: None,
11470 failed: None,
11471 duration_ms: 0,
11472 }],
11473 e2e: vec![CommandOutcome {
11474 command: "cargo test".to_owned(),
11475 code: Some(0),
11476 output_tail: String::new(),
11477 duration_ms: 0,
11478 resource_blocked: false,
11479 }],
11480 verify_retried: false,
11481 e2e_deferred: false,
11482 e2e_defer_reason: None,
11483 fix: Some(FixRecord {
11484 agent: "alpha".to_owned(),
11485 addressed: Vec::new(),
11486 rejected: vec![crate::verdict::Rejection {
11487 id: "R1-1-1".to_owned(),
11488 why: "not reachable from any caller".to_owned(),
11489 }],
11490 notes: String::new(),
11491 committed: true,
11492 failed: None,
11493 duration_ms: 0,
11494 continuation: None,
11495 }),
11496 blocking: 0,
11497 answered: 1,
11498 expected: 1,
11499 clean: false,
11500 progressed: true,
11501 vote_split: false,
11502 reconsideration: Vec::new(),
11503 verdict: None,
11504 };
11505 let state = state_with_round(round);
11506 let body = pr_message(&state, 'A').body;
11507
11508 assert!(body.contains("add retries"), "the task must still be there");
11509 assert!(body.contains("R2-1-1"), "{body}");
11510 assert!(body.contains("unused import"), "{body}");
11511 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11512 assert!(
11513 body.contains("not reachable from any caller"),
11514 "the reason it was declined: {body}"
11515 );
11516 }
11517
11518 #[test]
11519 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11520 let round = ReviewRound {
11521 round: 1,
11522 head: "deadbee".to_owned(),
11523 verified_head: None,
11524 verified_at: None,
11525 reviews: vec![ReviewRecord {
11526 attempts: 0,
11527 reviewer: 1,
11528 agent: "alpha".to_owned(),
11529 summary: String::new(),
11530 findings: Vec::new(),
11531 vote: None,
11532 failed: None,
11533 duration_ms: 0,
11534 }],
11535 e2e: Vec::new(),
11536 verify_retried: false,
11537 e2e_deferred: false,
11538 e2e_defer_reason: None,
11539 fix: None,
11540 blocking: 0,
11541 answered: 1,
11542 expected: 1,
11543 clean: true,
11544 progressed: false,
11545 vote_split: false,
11546 reconsideration: Vec::new(),
11547 verdict: None,
11548 };
11549 let state = state_with_round(round);
11550 let body = pr_message(&state, 'A').body;
11551 assert!(!body.contains("Open review findings"), "{body}");
11552 assert!(!body.contains("Declined"), "{body}");
11553 }
11554
11555 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11556 let mut state = RunState::new(
11557 PathBuf::from("/repo"),
11558 "main".to_owned(),
11559 "abc1234".to_owned(),
11560 instruction.to_owned(),
11561 Config::default(),
11562 );
11563 state.candidates.push(Candidate {
11564 index: 0,
11565 label: 'A',
11566 agent: "alpha".to_owned(),
11567 branch: "magi/x/A".to_owned(),
11568 worktree: PathBuf::from("/wt"),
11569 summary: summary.to_owned(),
11570 stat: String::new(),
11571 files: 1,
11572 commits: 1,
11573 empty: false,
11574 failed: None,
11575 verified_noop: None,
11576 folded: false,
11577 duration_ms: 0,
11578 });
11579 state
11580 }
11581
11582 fn review_state(subjects: &[&str]) -> RunState {
11583 let mut state = state_with_summary(
11584 "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",
11585 "",
11586 );
11587 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11588 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11589 state
11590 }
11591
11592 #[tokio::test]
11595 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11596 ask_test_home();
11597 let tmp = tempfile::tempdir().unwrap();
11598 let repo = tmp.path().join("repo");
11599 std::fs::create_dir_all(&repo).unwrap();
11600 init_repo(&repo);
11601 let origin = tmp.path().join("origin.git");
11602 let g = |dir: &Path, args: &[&str]| {
11603 let out = std::process::Command::new("git")
11604 .args(args)
11605 .current_dir(dir)
11606 .quiet()
11607 .output()
11608 .expect("spawn git");
11609 assert!(
11610 out.status.success(),
11611 "git {args:?}: {}",
11612 String::from_utf8_lossy(&out.stderr)
11613 );
11614 };
11615 g(
11616 tmp.path(),
11617 &[
11618 "clone",
11619 "--bare",
11620 "-q",
11621 repo.to_str().unwrap(),
11622 origin.to_str().unwrap(),
11623 ],
11624 );
11625 g(
11626 &repo,
11627 &["remote", "add", "origin", origin.to_str().unwrap()],
11628 );
11629 let c1 = git::rev_parse(&repo, "main").await.unwrap();
11630
11631 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11633 g(&repo, &["add", "-A"]);
11634 g(
11635 &repo,
11636 &["commit", "-q", "-m", "chore(deps): update a crate"],
11637 );
11638 g(&repo, &["push", "-q", "origin", "main"]);
11639 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11640 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11641 g(&repo, &["add", "-A"]);
11642 g(
11643 &repo,
11644 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11645 );
11646 g(&repo, &["checkout", "-q", "main"]);
11647
11648 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11649 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11650 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11652 assert_eq!(fallback, c1);
11653
11654 let mut state = review_state(&[
11655 "chore(deps): update a crate",
11656 "fix(daemon): apply a chosen action",
11657 ]);
11658 state.repo = repo.clone();
11659 state.base_branch = "main".to_owned();
11660 state.base_commit = c1;
11661 refresh_reviewed_commits(&mut state, "feat/own").await;
11662 assert_eq!(
11663 state.reviewed_commits,
11664 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11665 );
11666 assert_eq!(
11667 review_title(&state).as_deref(),
11668 Some("fix(daemon): apply a chosen action")
11669 );
11670 assert_eq!(
11671 leaked_subjects(&state, "feat/own").await,
11672 Some(vec!["chore(deps): update a crate".to_owned()])
11673 );
11674 state.config.merge.remote = "nowhere".to_owned();
11677 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11678 }
11679
11680 #[test]
11681 fn pr_message_review_single_commit_uses_its_subject() {
11682 let state = review_state(&["feat(nats): per-role user"]);
11683 let m = pr_message(&state, 'A');
11684 assert_eq!(m.title, "feat(nats): per-role user");
11685 assert!(!m.body.contains("Review the work already"), "{}", m.body);
11686 assert!(m.body.contains("## Commits under review"), "{}", m.body);
11687 }
11688
11689 #[test]
11690 fn pr_message_review_multi_commit_takes_the_oldest() {
11691 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11692 let m = pr_message(&state, 'A');
11693 assert_eq!(m.title, "feat: the change");
11694 for s in ["feat: the change", "fix: typo", "fix: again"] {
11695 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11696 }
11697 }
11698
11699 #[test]
11700 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11701 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11702 let mut state = review_state(&[first, "fix: later fixup"]);
11703 state.candidates[0].branch = "機能/ログイン".to_owned();
11704 let m = pr_message(&state, 'A');
11705 assert!(
11706 m.title.starts_with("chore: land candidate A of run"),
11707 "{}",
11708 m.title
11709 );
11710 }
11711 }
11712
11713 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11714 BranchFacts {
11715 commits: commits
11716 .iter()
11717 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11718 .collect(),
11719 stat: stat.to_owned(),
11720 }
11721 }
11722
11723 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11724 let mut r = review_round(true, 0, 1, 1, true, true);
11725 r.round = round;
11726 r.fix = notes.map(|n| FixRecord {
11727 agent: "fixer".to_owned(),
11728 addressed: Vec::new(),
11729 rejected: Vec::new(),
11730 notes: n.to_owned(),
11731 committed: true,
11732 failed: None,
11733 duration_ms: 0,
11734 continuation: None,
11735 });
11736 r
11737 }
11738
11739 #[test]
11740 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11741 let state = review_state(&["ignored"]);
11742 let f = facts(
11743 &[
11744 (
11745 "fix(login): resolve PATH on macOS",
11746 "Login shells skip rc files.",
11747 ),
11748 ("fix: address review", ""),
11749 ],
11750 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11751 );
11752 let m = pr_message_with(&state, 'A', Some(&f));
11753 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11754 assert!(
11755 m.body
11756 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
11757 "{}",
11758 m.body
11759 );
11760 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11761 assert!(m.body.contains("## Diff stat"), "{}", m.body);
11762 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11763 for banned in [
11764 "Review the work already",
11765 "no task statement",
11766 "Original task",
11767 ] {
11768 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11769 }
11770 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11771 }
11772
11773 #[test]
11774 fn pr_message_review_truncates_a_huge_first_commit_body() {
11775 let state = review_state(&["ignored"]);
11776 let f = facts(
11777 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11778 "s",
11779 );
11780 let m = pr_message_with(&state, 'A', Some(&f));
11781 assert!(m.body.len() < 30_000, "{}", m.body.len());
11782 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11783 assert!(
11784 m.body.contains("- ... 1 more commit(s)"),
11785 "{}",
11786 m.body.len()
11787 );
11788 assert!(m.body.ends_with("magi:candidate-a\n"));
11789 }
11790
11791 #[test]
11792 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11793 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11794 assert_eq!(m.title, "feat: x");
11795 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11796 assert!(!m.body.contains("Diff stat"), "{}", m.body);
11797 assert!(!m.body.contains("no task statement"), "{}", m.body);
11798 }
11799
11800 #[test]
11801 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11802 let mut state = review_state(&["日本語の件名"]);
11803 state.candidates[0].branch = "fix/macos-login-path".to_owned();
11804 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11805 state.candidates[0].branch = "機能/ログイン".to_owned();
11806 assert!(
11807 pr_message(&state, 'A')
11808 .title
11809 .starts_with("chore: land candidate A")
11810 );
11811 }
11812
11813 #[test]
11814 fn pr_message_review_fixes_survive_a_clean_final_round() {
11815 let mut state = review_state(&["feat: x"]);
11816 state.reviews = vec![
11817 round_with_notes(1, Some("handled the PATH case")),
11818 round_with_notes(2, None),
11819 ];
11820 let body = pr_message(&state, 'A').body;
11821 assert!(
11822 body.contains("## Review fixes\n\nhandled the PATH case\n"),
11823 "{body}"
11824 );
11825 assert!(!body.contains("### Round"), "{body}");
11826
11827 state.reviews = vec![
11828 round_with_notes(1, Some("first fix")),
11829 round_with_notes(2, Some("")),
11830 round_with_notes(3, Some("second fix")),
11831 round_with_notes(4, None),
11832 ];
11833 let body = pr_message(&state, 'A').body;
11834 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11835 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11836 assert!(!body.contains("### Round 2"), "{body}");
11837 }
11838
11839 #[test]
11840 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11841 let state = state_with_summary(
11842 "add retries to the client",
11843 "TITLE: feat: retries\n\nDid it.",
11844 );
11845 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11846 assert_eq!(m.title, "feat: retries");
11847 assert!(
11848 m.body.contains("<summary>Original task</summary>"),
11849 "{}",
11850 m.body
11851 );
11852 assert!(!m.body.contains("Commits under review"), "{}", m.body);
11853 assert!(
11854 m.body
11855 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11856 "{}",
11857 m.body
11858 );
11859 }
11860
11861 #[test]
11862 fn pr_message_review_bounds_a_long_english_subject() {
11863 let long = format!("feat: {}", "word ".repeat(100));
11864 let m = pr_message(&review_state(&[&long]), 'A');
11865 assert!(m.title.starts_with("feat: word"), "{}", m.title);
11866 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11867 }
11868
11869 #[test]
11870 fn pr_message_implementation_run_is_unchanged_by_review_support() {
11871 let state = state_with_summary("add retries\n\ndetails", "- did some things");
11872 let m = pr_message(&state, 'A');
11873 assert_eq!(m.title, "add retries");
11874 assert!(m.body.contains("<summary>Original task</summary>"));
11875 assert!(!m.body.contains("Commits under review"));
11876 assert_eq!(landing_subject_source(&state), state.instruction);
11877 }
11878
11879 #[test]
11880 fn review_run_squash_subject_is_the_change_not_the_prompt() {
11881 let state = review_state(&["feat: the change", "fix: typo"]);
11882 let source = landing_subject_source(&state);
11883 assert_eq!(land::merge_subject("", &source), "feat: the change");
11884 assert_eq!(
11885 land::merge_subject("magi: candidate A (uncommitted work)", &source),
11886 "feat: the change"
11887 );
11888 assert_eq!(
11890 land::merge_subject("feat: renamed by hand", &source),
11891 "feat: renamed by hand"
11892 );
11893 let blank = review_state(&["日本語"]);
11894 assert!(
11895 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11896 );
11897 }
11898
11899 #[test]
11900 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11901 let state = review_state(&["feat: the change"]);
11902 let source = landing_subject_source(&state);
11903 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11904 assert_eq!(
11905 land::merge_subject(landing_title(&state, old), &source),
11906 "feat: the change"
11907 );
11908 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11909 let task = state_with_summary("add retries", "");
11910 assert_eq!(landing_title(&task, old), old);
11911 }
11912
11913 #[test]
11914 fn pr_message_describes_the_change_not_the_task() {
11915 let state = state_with_summary(
11916 "今回やってほしいこと: results projector を直す",
11917 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11918 );
11919 let m = pr_message(&state, 'A');
11920 assert_eq!(m.title, "fix(web): batch the runs list reads");
11921 assert!(
11922 m.body.starts_with("## Summary\n\n- reads run.json once"),
11923 "{}",
11924 m.body
11925 );
11926 assert!(!m.body.contains("TITLE:"), "{}", m.body);
11927 let task_at = m.body.find("今回やってほしいこと").unwrap();
11928 let details_at = m.body.find("<details>").unwrap();
11929 assert!(
11930 details_at < task_at,
11931 "the task lives inside <details>: {}",
11932 m.body
11933 );
11934 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11935 assert!(m.body.contains("magi:candidate-a"));
11936 }
11937
11938 #[test]
11939 fn pr_message_falls_back_to_the_task_without_a_title_line() {
11940 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11941 let m = pr_message(&state, 'A');
11942 assert_eq!(m.title, "add retries");
11943 assert!(
11944 m.body.contains("## Summary\n\n- did some things"),
11945 "{}",
11946 m.body
11947 );
11948
11949 let none = RunState::new(
11950 PathBuf::from("/repo"),
11951 "main".to_owned(),
11952 "abc1234".to_owned(),
11953 "add retries".to_owned(),
11954 Config::default(),
11955 );
11956 let m = pr_message(&none, 'A');
11957 assert_eq!(m.title, "add retries");
11958 assert!(!m.body.contains("## Summary"), "{}", m.body);
11959 }
11960
11961 #[test]
11962 fn pr_message_refuses_the_candidate_commit_subject() {
11963 for bad in [
11964 "TITLE: magi: candidate A (uncommitted work)",
11965 "TITLE: chore: stuff (uncommitted work)",
11966 "TITLE: ",
11967 ] {
11968 let state = state_with_summary("add retries", bad);
11969 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11970 }
11971 }
11972
11973 #[test]
11974 fn pr_message_bounds_a_very_long_task_and_title() {
11975 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11976 let state = state_with_summary(&long, "- nothing");
11977 let m = pr_message(&state, 'A');
11978 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11979 assert!(!m.title.contains('\n'));
11980
11981 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11982 let m = pr_message(&state, 'A');
11983 assert!(m.title.starts_with("feat: "));
11984 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11985 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11986 }
11987
11988 fn long_title_of(instruction: &str) -> String {
11989 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11990 }
11991
11992 #[test]
11993 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11994 let first = "Make the landing path keep a readable title for long tasks";
11995 let line = format!(
11996 "{first}. {}",
11997 "And then keep going with more words ".repeat(20)
11998 );
11999 let t = long_title_of(&line);
12000 assert_eq!(t, first);
12001 assert!(!t.starts_with("chore: land"));
12002 }
12003
12004 #[test]
12005 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12006 let line = "word ".repeat(200);
12007 let t = long_title_of(&line);
12008 assert!(t.ends_with("word..."), "{t}");
12009 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12010 }
12011
12012 #[test]
12013 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12014 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12015 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12016 }
12017 }
12018
12019 #[test]
12020 fn pr_message_title_limit_is_exact() {
12021 let at = "a".repeat(PR_TITLE_MAX);
12022 assert_eq!(long_title_of(&at), at);
12023 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12024 assert!(over.ends_with("..."), "{over}");
12025 assert_eq!(over.chars().count(), PR_TITLE_MAX);
12026 }
12027
12028 #[test]
12029 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12030 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12031 let t = long_title_of(&line);
12032 assert!(t.ends_with("..."), "{t}");
12033 assert!(t.is_ascii(), "{t}");
12034 }
12035
12036 #[test]
12037 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12038 let line = format!(
12039 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12040 "plus more filler words ".repeat(20)
12041 );
12042 let t = long_title_of(&line);
12043 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12044 }
12045
12046 #[test]
12047 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12048 let line = format!("\n\n# {}", "title words ".repeat(40));
12049 let state = state_with_summary(&line, "- nothing");
12050 let m = pr_message(&state, 'A');
12051 assert!(m.title.starts_with("title words"), "{}", m.title);
12052 assert_eq!(
12053 land::merge_subject(&m.title, &landing_subject_source(&state)),
12054 m.title
12055 );
12056 assert_eq!(
12058 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12059 "feat: renamed by hand"
12060 );
12061 }
12062
12063 #[tokio::test]
12064 async fn github_text_rewrite_is_bounded_and_falls_back() {
12065 crate::run::pin_test_home();
12066 for (reply, accepted) in [
12067 (
12068 "TITLE: fix: retries\nAdd retries for failed requests.",
12069 true,
12070 ),
12071 (
12072 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12073 false,
12074 ),
12075 ("TITLE: fix: retries\nUse token=secret", false),
12076 ] {
12077 let dir = tempfile::tempdir().unwrap();
12078 let mut runner = runner_at(RunStatus::Gating);
12079 runner.state = state_with_summary(
12080 "add retries",
12081 "TITLE: fix: retries\n日本語の説明を書きます。",
12082 );
12083 runner.state.repo = dir.path().to_owned();
12084 runner.state.candidates[0].worktree = dir.path().to_owned();
12085 let mut author = spec("alpha");
12086 author.command = vec![
12087 "sh".into(),
12088 "-c".into(),
12089 "printf '%s' \"$REWRITE_REPLY\"".into(),
12090 ];
12091 author.env.insert("REWRITE_REPLY".into(), reply.into());
12092 runner.state.config.agents = vec![author];
12093 let winner = runner.state.candidates[0].clone();
12094 let first = runner
12095 .guarded_pr_message(&winner, None, true)
12096 .await
12097 .unwrap();
12098 let message = if accepted {
12099 first.expect("a passing rewrite never asks")
12100 } else {
12101 assert!(first.is_none());
12103 assert!(runner.state.parked);
12104 let store = ask::Questions::open();
12105 let asked: Vec<_> = store
12106 .list()
12107 .into_iter()
12108 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12109 .collect();
12110 assert_eq!(asked.len(), 1);
12111 assert!(!asked[0].detail.contains("secret"));
12112 store
12115 .update(&asked[0].id, |q| {
12116 q.abandon("test");
12117 Ok(())
12118 })
12119 .unwrap();
12120 let again = runner
12121 .guarded_pr_message(&winner, None, true)
12122 .await
12123 .unwrap()
12124 .expect("an abandoned question falls back");
12125 let n = store
12126 .list()
12127 .into_iter()
12128 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12129 .count();
12130 assert_eq!(n, 1, "asked once per rejected text");
12131 again
12132 };
12133 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12134 assert_eq!(
12135 message.body.contains("Add retries for failed requests."),
12136 accepted
12137 );
12138 assert_eq!(
12139 runner.state.seats["impl-A"].turns, 1,
12140 "only one rewrite invocation"
12141 );
12142 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12143 if !accepted {
12144 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12145 assert!(
12146 message
12147 .body
12148 .contains(&format!("magi:run/{}", runner.state.id))
12149 );
12150 }
12151 }
12152 }
12153
12154 #[tokio::test]
12155 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12156 crate::run::pin_test_home();
12157 let dir = tempfile::tempdir().unwrap();
12158 let mut runner = runner_at(RunStatus::Gating);
12159 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12160 runner.state.repo = dir.path().to_owned();
12161 runner.state.candidates[0].worktree = dir.path().to_owned();
12162 let winner = runner.state.candidates[0].clone();
12163 assert!(
12164 runner
12165 .guarded_pr_message(&winner, None, true)
12166 .await
12167 .unwrap()
12168 .is_none()
12169 );
12170 let store = ask::Questions::open();
12171 let find = |run: &str| {
12172 store
12173 .list()
12174 .into_iter()
12175 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12176 .unwrap()
12177 };
12178 let run = runner.state.id.clone();
12179 let q = find(&run);
12180 assert!(q.cwd.is_none());
12181 assert_eq!(q.choices, ["use fallback", "use my text"]);
12182 let say = |id: &str, text: &str, choice: &str| {
12184 store
12185 .update(id, |q| {
12186 q.thread.push(ask::Turn {
12187 who: ask::Who::Operator,
12188 body: text.to_owned(),
12189 at: jiff::Timestamp::now(),
12190 note: None,
12191 });
12192 q.answer(ask::Answer::Choice(choice.to_owned()))
12193 })
12194 .unwrap();
12195 };
12196 say(&q.id, "まだ日本語", "use my text");
12197 assert!(
12198 runner
12199 .guarded_pr_message(&winner, None, true)
12200 .await
12201 .unwrap()
12202 .is_none()
12203 );
12204 let second = find(&run);
12205 assert_ne!(second.id, q.id);
12206 assert!(second.detail.contains("did not pass"));
12207 say(&second.id, "fix: retry failed requests", "use my text");
12208 let message = runner
12209 .guarded_pr_message(&winner, None, true)
12210 .await
12211 .unwrap()
12212 .expect("a vetted replacement is posted");
12213 assert_eq!(message.title, "fix: retry failed requests");
12214 assert_eq!(
12215 runner
12216 .state
12217 .github_text
12218 .as_ref()
12219 .unwrap()
12220 .chosen_title
12221 .as_deref(),
12222 Some("fix: retry failed requests")
12223 );
12224 assert!(runner.state.github_text.as_ref().unwrap().resolved);
12225 let again = runner
12227 .guarded_pr_message(&winner, None, true)
12228 .await
12229 .unwrap()
12230 .unwrap();
12231 assert_eq!(again.title, message.title);
12232 assert_eq!(
12233 store
12234 .list()
12235 .into_iter()
12236 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12237 .count(),
12238 2
12239 );
12240 }
12241
12242 #[test]
12243 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12244 let mut state = state_with_summary(
12248 "add retries",
12249 "TITLE: fix(web): batch reads\n- reads run.json once",
12250 );
12251 state.config.graph.language = "ja".to_owned();
12252 let m = pr_message(&state, 'A');
12253 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
12254 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
12255
12256 let task = "今回やってほしいこと: results projector を直す";
12259 let mut state = state_with_summary(task, "- no title line");
12260 state.config.graph.language = "ja".to_owned();
12261 let m = pr_message(&state, 'A');
12262 assert_eq!(
12263 m.title,
12264 format!("chore: land candidate A of run {}", state.id)
12265 );
12266 assert!(
12267 m.body.contains(&format!(
12268 "<summary>Original task</summary>\n\n{task}\n\n</details>"
12269 )),
12270 "{}",
12271 m.body
12272 );
12273 }
12274
12275 #[test]
12276 fn pr_message_scrubs_home_paths_and_addresses() {
12277 let state = state_with_summary(
12278 "fix it in /Users/someone/src/x",
12279 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
12280 );
12281 let m = pr_message(&state, 'A');
12282 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
12283 assert!(!m.body.contains(leak), "{}", m.body);
12284 }
12285 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
12286 }
12287
12288 #[test]
12289 fn pr_message_survives_a_task_that_closes_details() {
12290 let state = state_with_summary("a </details> b", "TITLE: fix: x");
12291 let m = pr_message(&state, 'A');
12292 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
12293 }
12294
12295 #[test]
12296 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
12297 let cmd = manual_merge_command(
12298 MergeStyle::Squash,
12299 Path::new("/repo"),
12300 "b",
12301 "fix: \"quoted\" $(x) `y`\n\nbody",
12302 );
12303 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
12304 }
12305
12306 #[test]
12307 fn manual_merge_command_matches_the_configured_style() {
12308 let repo = Path::new("/repo");
12309 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
12310
12311 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
12312 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
12313
12314 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
12315 assert_eq!(
12316 squash,
12317 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
12318 \"Merge magi run 0832 (candidate A)\""
12319 );
12320
12321 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
12322 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
12323 }
12324
12325 #[test]
12326 fn a_nudge_gets_a_quarter_of_the_budget() {
12327 assert_eq!(retry_budget(secs(1200), true), secs(300));
12329 assert_eq!(retry_budget(secs(3600), true), secs(900));
12330 }
12331
12332 #[test]
12333 fn a_resent_prompt_keeps_the_whole_budget() {
12334 assert_eq!(retry_budget(secs(1200), false), secs(1200));
12337 assert_eq!(retry_budget(secs(60), false), secs(60));
12338 }
12339
12340 #[test]
12341 fn the_floor_never_exceeds_the_original_budget() {
12342 assert_eq!(retry_budget(secs(60), true), secs(60));
12346 assert_eq!(retry_budget(secs(480), true), secs(120));
12347 assert_eq!(retry_budget(secs(0), true), secs(0));
12348 }
12349
12350 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12351 agent::CommandEvidence {
12352 id: "item1".to_owned(),
12353 description: "cargo test".to_owned(),
12354 exit_code,
12355 result_summary: String::new(),
12356 source: "codex".to_owned(),
12357 }
12358 }
12359
12360 #[test]
12361 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12362 assert!(!has_unconfirmed_command(&[]));
12366 }
12367
12368 #[test]
12369 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12370 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12374 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12375 assert!(!has_unconfirmed_command(&[
12376 evidence(Some(0)),
12377 evidence(Some(101))
12378 ]));
12379 }
12380
12381 #[test]
12382 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12383 assert!(has_unconfirmed_command(&[
12384 evidence(Some(0)),
12385 evidence(None)
12386 ]));
12387 }
12388
12389 #[test]
12390 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12391 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12392 assert_eq!(
12393 verified_noop_claim(true, &[], text).as_deref(),
12394 Some("already fixed by b32cfc4, on main.")
12395 );
12396 }
12397
12398 #[test]
12399 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12400 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12403 assert!(verified_noop_claim(false, &[], text).is_none());
12404 }
12405
12406 #[test]
12407 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12408 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12409 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12410 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12412 }
12413
12414 #[test]
12415 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12416 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12417 }
12418
12419 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12422 runner.state.candidates = shape
12423 .iter()
12424 .enumerate()
12425 .map(|(i, &(empty, verified))| Candidate {
12426 index: i,
12427 label: (b'A' + i as u8) as char,
12428 agent: "sonnet".to_owned(),
12429 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12430 worktree: PathBuf::from(format!("/wt/{i}")),
12431 summary: String::new(),
12432 stat: String::new(),
12433 files: 0,
12434 commits: 0,
12435 empty,
12436 failed: None,
12437 verified_noop: verified.map(str::to_owned),
12438 duration_ms: 0,
12439 folded: false,
12440 })
12441 .collect();
12442 }
12443
12444 #[test]
12445 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12446 ask_test_home();
12447 let mut runner = runner_at(RunStatus::Implementing);
12448 set_candidates(
12449 &mut runner,
12450 &[
12451 (true, Some("already on main at b32cfc4")),
12452 (true, Some("same fix, see the existing test")),
12453 ],
12454 );
12455
12456 runner
12457 .after_implement()
12458 .expect("a verified no-op is not an error");
12459
12460 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12461 }
12462
12463 #[test]
12464 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12465 ask_test_home();
12466 let mut runner = runner_at(RunStatus::Implementing);
12467 set_candidates(
12471 &mut runner,
12472 &[(true, Some("already on main at b32cfc4")), (true, None)],
12473 );
12474
12475 let err = runner
12476 .after_implement()
12477 .expect_err("an unverified empty candidate must still fail the run");
12478
12479 assert!(
12480 err.to_string().contains("no candidate produced a change"),
12481 "{err}"
12482 );
12483 assert_eq!(runner.state.status, RunStatus::Failed);
12484 }
12485
12486 #[test]
12487 fn after_implement_still_fails_an_ordinary_all_empty_run() {
12488 ask_test_home();
12489 let mut runner = runner_at(RunStatus::Implementing);
12490 set_candidates(&mut runner, &[(true, None), (true, None)]);
12491
12492 let err = runner
12493 .after_implement()
12494 .expect_err("no candidate declared anything; this is an ordinary failure");
12495
12496 assert!(
12497 err.to_string().contains("no candidate produced a change"),
12498 "{err}"
12499 );
12500 assert_eq!(runner.state.status, RunStatus::Failed);
12501 }
12502}