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 let has_language = violations
6056 .iter()
6057 .any(|v| *v != crate::github_text::Violation::SensitiveData);
6058 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6059 self.state.event(
6060 "github-text",
6061 if has_language {
6062 format!("description rejected: {violations:?}; requesting one rewrite")
6063 } else {
6064 format!("description rejected: {violations:?}; asking the owner")
6065 },
6066 );
6067 let mut rewritten = false;
6068 if has_language
6069 && let Some(spec) = self
6070 .state
6071 .config
6072 .agents
6073 .iter()
6074 .find(|a| a.id == winner.agent)
6075 .cloned()
6076 {
6077 let key = format!("impl-{}", winner.label);
6078 let seat = self.seat(&key, &spec.id);
6079 let prompt = format!(
6080 "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{}",
6081 pr.title, pr.body
6082 );
6083 let job = SeatJob {
6084 spec,
6085 seat,
6086 cwd: winner.worktree.clone(),
6087 prompt,
6088 timeout: retry_budget(
6089 Duration::from_secs(self.state.config.graph.timeout_implement),
6090 true,
6091 ),
6092 allow_write: false,
6093 sessions: self.state.config.graph.sessions,
6094 artifacts: agent::artifacts_dir(&self.state.dir()),
6095 stem: "github-text-rewrite".to_owned(),
6096 handover: None,
6097 };
6098 let prompts = self.state.config.prompts.clone();
6099 let cache = self.state.config.cache_dir();
6100 let run = self.state.id.clone();
6101 let ctx = WaveCtx {
6102 run: &run,
6103 node: "github-text",
6104 prompts: &prompts,
6105 cache: cache.as_deref(),
6106 round: None,
6107 carry_seats: false,
6108 };
6109 let (seat, outcome) =
6110 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6111 self.state.seats.insert(seat.key.clone(), seat);
6112 if let AgentOutcome::Ok(output) = outcome
6113 && let Some(title) = summary_title(&output.text)
6114 {
6115 let mut body = summary_without_title(&output.text);
6116 let rewrite = PrMessage {
6117 title: title.clone(),
6118 body: body.clone(),
6119 };
6120 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6121 if !body.trim().is_empty()
6122 && crate::github_text::check_with(&title, &body, redecision).is_empty()
6123 && crate::github_text::shareable(&title)
6124 && crate::github_text::shareable(&body)
6125 {
6126 decision = redecision;
6127 let marker = format!("magi:run/{}", self.state.id);
6128 if !body.contains(&marker) {
6129 body.push_str(&format!("\n\n{marker}"));
6130 }
6131 pr = PrMessage { title, body };
6132 rewritten = true;
6133 self.state
6134 .event("github-text", "description rewrite passed");
6135 }
6136 }
6137 }
6138 if !rewritten {
6139 self.state.event(
6140 "github-text",
6141 "description rewrite unavailable or rejected; asking the owner",
6142 );
6143 if let Some(w) =
6144 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6145 {
6146 self.retire_github_text_question("the withheld text changed");
6149 let record = crate::run::GithubTextAsk {
6150 fingerprint: raw_fp,
6151 title: w.title,
6152 body: w.body,
6153 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6154 question: None,
6155 asks: 0,
6156 resolved: false,
6157 chosen_title: None,
6158 };
6159 return self.settle_withheld(winner, &pr, record).await;
6160 }
6161 }
6162 }
6163 let (title, body) =
6164 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6165 Ok(Some(PrMessage { title, body }))
6166 }
6167
6168 fn retire_github_text_question(&mut self, why: &str) {
6170 let Some(id) = self
6171 .state
6172 .github_text
6173 .as_ref()
6174 .and_then(|g| g.question.clone())
6175 else {
6176 return;
6177 };
6178 let _ = ask::Questions::open().update(&id, |q| {
6179 q.abandon(why);
6180 Ok(())
6181 });
6182 }
6183
6184 async fn settle_withheld(
6188 &mut self,
6189 winner: &Candidate,
6190 pr: &PrMessage,
6191 mut g: crate::run::GithubTextAsk,
6192 ) -> Result<Option<PrMessage>> {
6193 use crate::github_text as gt;
6194 let store = ask::Questions::open();
6195 let timeout = self.state.config.graph.answer_timeout;
6196 if !g.resolved {
6197 let mut standing = g
6198 .question
6199 .as_ref()
6200 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6201 if let Some(q) = standing.as_ref()
6202 && q.status.open()
6203 && gt::expired(q, timeout)
6204 {
6205 let why = format!("no answer within {}s of asking", timeout.max(1));
6206 standing = store
6207 .update(&q.id, |q| {
6208 q.abandon(&why);
6209 Ok(())
6210 })
6211 .ok()
6212 .map(|(q, ())| q);
6213 }
6214 let mut ask_again: Option<(Vec<String>, bool)> = None;
6215 match standing {
6216 None if g.asks <= 1 => {
6219 g.asks = 0;
6220 ask_again = Some((g.categories.clone(), false));
6221 }
6222 None => {
6223 g.resolved = true;
6224 g.chosen_title = None;
6225 }
6226 Some(q) if q.status.open() => {
6227 self.park_on_github_text(&g)?;
6228 return Ok(None);
6229 }
6230 Some(q) => match gt::read_reply(&q) {
6231 gt::Reply::Fallback => {
6232 g.resolved = true;
6233 g.chosen_title = None;
6234 }
6235 gt::Reply::Title(text) => {
6236 let first = text
6237 .lines()
6238 .map(str::trim)
6239 .find(|l| !l.is_empty())
6240 .unwrap_or("");
6241 let decision = if first.is_empty() {
6242 None
6243 } else {
6244 let candidate = PrMessage {
6245 title: first.to_owned(),
6246 body: String::new(),
6247 };
6248 self.judge_pr_language(&winner.worktree, &candidate).await
6249 };
6250 match gt::vet_title(&text, decision) {
6251 Ok(title) => {
6252 g.resolved = true;
6253 g.chosen_title = Some(title);
6254 }
6255 Err(cats) if g.asks < 2 => {
6256 ask_again =
6257 Some((cats.into_iter().map(str::to_owned).collect(), true))
6258 }
6259 Err(_) => {
6260 g.resolved = true;
6261 g.chosen_title = None;
6262 }
6263 }
6264 }
6265 },
6266 }
6267 if let Some((categories, retry)) = ask_again {
6268 let w = gt::Withheld {
6269 title: g.title,
6270 body: g.body,
6271 categories,
6272 };
6273 let mut q = ask::Question::new(
6274 self.state.id.clone(),
6275 gt::ASK_NODE.to_owned(),
6276 gt::ASK_SEAT.to_owned(),
6277 gt::question_summary(&self.state.config.graph.language, &w),
6278 gt::question_detail(&w, &pr.title, &pr.body, retry),
6279 gt::question_choices(&w),
6280 );
6281 q.answer_timeout = timeout;
6282 g.question = Some(q.id.clone());
6285 g.asks += 1;
6286 self.state.github_text = Some(g.clone());
6287 self.state.event(
6288 "github-text",
6289 format!("asking the owner about the withheld text ({})", q.short()),
6290 );
6291 self.state.save()?;
6292 store
6293 .put(&mut q)
6294 .context("file the posting-gate question")?;
6295 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6296 tracing::warn!(
6297 "could not notify about posting-gate question {}: {e:#}",
6298 q.short()
6299 );
6300 }
6301 self.park_on_github_text(&g)?;
6302 return Ok(None);
6303 }
6304 }
6305 let resolved = g.chosen_title.clone();
6306 self.state.event(
6307 "github-text",
6308 if resolved.is_some() {
6309 "posting the owner's replacement title"
6310 } else {
6311 "posting the neutral text for the withheld fields"
6312 },
6313 );
6314 self.state.github_text = Some(g.clone());
6315 self.state.save()?;
6316 let neutral = |cat: &str| g.categories.iter().any(|c| c == cat);
6319 let title = match (g.title, resolved) {
6320 (true, Some(t)) => t,
6321 (true, None) if neutral("title-language") => gt::NEUTRAL_TITLE.to_owned(),
6322 _ => pr.title.clone(),
6323 };
6324 let body = if g.body && neutral("body-language") {
6325 format!("{}\n\nmagi:run/{}", gt::NEUTRAL_BODY, self.state.id)
6326 } else {
6327 pr.body.clone()
6328 };
6329 let english = gt::LanguageDecision {
6331 title_english: true,
6332 body_english: true,
6333 body_complete: true,
6334 };
6335 let (title, body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6336 Ok(Some(PrMessage { title, body }))
6337 }
6338
6339 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6340 self.state.github_text = Some(g.clone());
6341 self.state.parked = true;
6342 self.state.event(
6343 "github-text",
6344 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6345 );
6346 self.state.save()
6347 }
6348
6349 async fn judge_pr_language(
6352 &mut self,
6353 cwd: &Path,
6354 pr: &PrMessage,
6355 ) -> Option<crate::github_text::LanguageDecision> {
6356 self.state.config.roles.language_judge.as_ref()?;
6357 let decision =
6358 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6359 self.state.event(
6360 "github-text",
6361 if decision.is_some() {
6362 "language decided by the language judge"
6363 } else {
6364 "language judge unavailable; using built-in heuristics"
6365 },
6366 );
6367 decision
6368 }
6369
6370 async fn merge(&mut self) -> Result<()> {
6371 if self
6386 .state
6387 .base_sync
6388 .as_ref()
6389 .is_some_and(|s| s.conflict.is_some())
6390 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6391 != Some(RunStatus::Gating)
6392 || !self.state.gate_status().ok()
6401 {
6402 return Ok(());
6403 }
6404 if self.state.merge.is_some() {
6413 return Ok(());
6414 }
6415 let Some(winner) = self.state.winner().cloned() else {
6416 return Ok(());
6417 };
6418 let repo = self.state.repo.clone();
6419 let base = self.state.base_branch.clone();
6420 let mode = self.state.config.merge.mode;
6421 let style = self.state.config.merge.style;
6422 let facts = if is_review_run(&self.state) {
6423 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6424 let start = review_base(
6425 &repo,
6426 &self.state.config.merge.remote,
6427 &base,
6428 &self.state.base_commit,
6429 &winner.branch,
6430 )
6431 .await;
6432 branch_facts(&repo, &start, &winner.branch).await
6433 } else {
6434 None
6435 };
6436 let leaked = if is_review_run(&self.state) {
6439 leaked_subjects(&self.state, &winner.branch).await
6440 } else {
6441 Some(Vec::new())
6442 };
6443 let Some(pr) = self
6447 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6448 .await?
6449 else {
6450 return Ok(());
6451 };
6452 let message = pr.commit_message();
6453
6454 let outcome = match mode {
6455 MergeMode::None => MergeOutcome {
6456 mode,
6457 ok: true,
6458 detail: manual_merge_command(style, &repo, &winner.branch, &message),
6459 empty: false,
6460 },
6461 MergeMode::Pr | MergeMode::Local
6462 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6463 {
6464 MergeOutcome {
6465 mode,
6466 ok: false,
6467 detail: empty_candidate_detail(&self.state, &base),
6468 empty: true,
6469 }
6470 }
6471 MergeMode::Local => {
6472 let on = git::current_branch(&repo).await?;
6473 if on.as_deref() != Some(base.as_str()) {
6474 MergeOutcome {
6475 mode,
6476 ok: false,
6477 detail: format!(
6478 "{} has {} checked out, not the base branch {base}",
6479 repo.display(),
6480 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6481 ),
6482 empty: false,
6483 }
6484 } else if !git::is_clean(&repo).await? {
6485 MergeOutcome {
6486 mode,
6487 ok: false,
6488 detail: format!("{} is dirty; refusing to merge", repo.display()),
6489 empty: false,
6490 }
6491 } else {
6492 let out = match style {
6493 MergeStyle::Merge => {
6494 git::merge_no_ff(&repo, &winner.branch, &message).await?
6495 }
6496 MergeStyle::Squash => {
6497 git::merge_squash(&repo, &winner.branch, &message).await?
6498 }
6499 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6500 };
6501 MergeOutcome {
6502 mode,
6503 ok: out.ok(),
6504 detail: if out.ok() { out.stdout } else { out.stderr },
6505 empty: false,
6506 }
6507 }
6508 }
6509 MergeMode::Pr => {
6510 let remote = self.state.config.merge.remote.clone();
6511 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6512 if !pushed.ok() {
6513 MergeOutcome {
6514 mode,
6515 ok: false,
6516 detail: pushed.stderr,
6517 empty: false,
6518 }
6519 } else {
6520 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6526 let out = match pr_merge_plan(found) {
6527 PrPlan::Create => {
6528 gh_pr_create(
6529 &winner.worktree,
6530 &base,
6531 &winner.branch,
6532 &pr.title,
6533 &pr.body,
6534 )
6535 .await
6536 }
6537 PrPlan::Adopt { url, title } => {
6538 self.state
6539 .event("merge", format!("Pr: adopted open pull request {url}"));
6540 if title != pr.title
6541 && (!is_review_run(&self.state)
6542 || leaked
6543 .as_deref()
6544 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6545 && let Err(e) = land::set_pr_title(
6546 &mut self.state,
6547 &winner.worktree,
6548 &url,
6549 &pr.title,
6550 )
6551 .await
6552 {
6553 tracing::warn!("could not refresh title of {url}: {e:#}");
6554 self.state
6555 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6556 }
6557 Ok(url)
6558 }
6559 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6560 };
6561 match out {
6562 Ok(url) => MergeOutcome {
6563 mode,
6564 ok: true,
6565 detail: url,
6566 empty: false,
6567 },
6568 Err(e) => MergeOutcome {
6569 mode,
6570 ok: false,
6571 detail: e.to_string(),
6572 empty: false,
6573 },
6574 }
6575 }
6576 }
6577 };
6578
6579 self.state.status = match (mode, outcome.ok) {
6580 (MergeMode::None, _) => RunStatus::Ready,
6581 (_, true) => RunStatus::Merged,
6582 (_, false) => RunStatus::Blocked,
6583 };
6584 self.state.event(
6585 "merge",
6586 format!(
6587 "{:?}: {}",
6588 mode,
6589 outcome.detail.lines().next().unwrap_or("")
6590 ),
6591 );
6592 self.state.merge = Some(outcome);
6593 self.state.save()?;
6594
6595 if self.state.config.graph.land
6601 && mode == MergeMode::Pr
6602 && self.state.status == RunStatus::Merged
6603 {
6604 self.run_land().await?;
6605 }
6606 self.settle_questions();
6611 Ok(())
6612 }
6613
6614 async fn run_land(&mut self) -> Result<()> {
6625 let url = self
6626 .state
6627 .merge
6628 .as_ref()
6629 .map(|m| m.detail.clone())
6630 .unwrap_or_default();
6631 let url = url.lines().next().unwrap_or("").trim().to_owned();
6632 if !url.starts_with("http") {
6633 return Ok(());
6634 }
6635 match land::land(&mut self.state, &url).await {
6638 Ok(pr) if self.state.parked => {
6639 let _ = pr;
6643 }
6644 Ok(pr) => {
6645 self.state.status = match pr.state {
6646 land::PrLifecycle::Merged => RunStatus::Merged,
6647 _ => RunStatus::Blocked,
6648 };
6649 if bump::should_release_bump(self.state.status)
6656 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6657 {
6658 self.state
6664 .event("bump", format!("release bump skipped: {e:#}"));
6665 }
6666 if self.state.status == RunStatus::Merged {
6669 crate::followup::after_merge(&mut self.state, &pr.url).await;
6670 }
6671 self.state.save()?;
6672 }
6673 Err(e) => {
6674 self.state.status = RunStatus::Blocked;
6675 self.state.event("land", format!("gave up: {e}"));
6676 self.state.save()?;
6677 }
6678 }
6679 Ok(())
6680 }
6681
6682 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6686 if let Some(existing) = self.state.seats.get(key)
6687 && existing.agent == agent
6688 {
6689 return existing.clone();
6690 }
6691 let fresh = if self.state.seats.contains_key(key) {
6694 handover_seat(key, agent, self.state.next_seat_seed())
6695 } else {
6696 SeatState::new(key, agent, self.state.seed)
6697 };
6698 self.state.seats.insert(key.to_owned(), fresh.clone());
6699 fresh
6700 }
6701
6702 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6708 match self.state.seats.get(key) {
6709 Some(s) if s.agent != spec.id => {
6710 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6711 }
6712 _ => spec,
6713 }
6714 }
6715
6716 fn view(&self, c: &Candidate) -> CandidateView {
6718 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6719 .unwrap_or_default();
6720 let (patch, _) = blind::sanitize_patch(
6721 &format!("candidate {} patch", c.label),
6722 &raw,
6723 &self.state.config.blind,
6724 );
6725 CandidateView {
6726 label: c.label,
6727 branch: c.branch.clone(),
6728 summary: c.summary.clone(),
6729 stat: c.stat.clone(),
6730 patch,
6731 }
6732 }
6733
6734 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6736 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6737 prompt::judge(
6738 "(see above)",
6739 &views,
6740 self.roles.judges.len(),
6741 base_short,
6742 "en",
6743 )
6744 }
6745
6746 fn vote_prompt_full(
6752 &self,
6753 j: usize,
6754 labels: &[char],
6755 language: &str,
6756 candidates: &[Candidate],
6757 base_short: &str,
6758 ) -> String {
6759 let mut text = format!(
6760 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6761 prompt::final_vote(labels, language),
6762 self.state.instruction,
6763 self.candidate_block(candidates, base_short)
6764 );
6765 if let Some(own) = self
6766 .state
6767 .judgements
6768 .get(j)
6769 .filter(|r| !r.ranking.is_empty())
6770 {
6771 let reasons = own
6772 .reasons
6773 .iter()
6774 .map(|(k, v)| format!("- {k}: {v}"))
6775 .collect::<Vec<_>>()
6776 .join("\n");
6777 text.push_str(&format!(
6778 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6779 own.ranking.iter().collect::<String>(),
6780 if reasons.is_empty() {
6781 ""
6782 } else {
6783 ", because:\n"
6784 }
6785 ));
6786 }
6787 if !self.state.deliberation.is_empty() {
6788 text.push_str("\n# What was argued before this vote\n");
6789 for t in self.transcript(&[], j) {
6790 text.push_str(&format!(
6791 "\n## {}{}\n\n{}\n",
6792 t.who,
6793 if t.is_self { " (you)" } else { "" },
6794 t.body.trim()
6795 ));
6796 }
6797 }
6798 text
6799 }
6800
6801 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6808 let mut turns = Vec::new();
6809 for j in &self.state.judgements {
6810 if j.ranking.is_empty() {
6811 continue;
6812 }
6813 let reasons = j
6814 .reasons
6815 .iter()
6816 .map(|(k, v)| format!("- {k}: {v}"))
6817 .collect::<Vec<_>>()
6818 .join("\n");
6819 turns.push(Turn {
6820 who: format!("Judge {} (opening ranking)", j.judge),
6821 is_self: j.judge == self_idx + 1,
6822 body: format!(
6823 "Ranked {}{}{reasons}",
6824 j.ranking.iter().collect::<String>(),
6825 if reasons.is_empty() {
6826 ""
6827 } else {
6828 ", because:\n"
6829 }
6830 ),
6831 });
6832 }
6833 for t in self
6834 .state
6835 .deliberation
6836 .iter()
6837 .flat_map(|r| r.turns.iter())
6838 .chain(current)
6839 {
6840 turns.push(Turn {
6841 who: format!("Judge {}", t.judge),
6842 is_self: t.judge == self_idx + 1,
6843 body: t.body.clone(),
6844 });
6845 }
6846 turns
6847 }
6848}
6849
6850fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6852 agent::has_session(spec.kind, seat, sessions)
6853}
6854
6855fn next_untried_in_roster<'a>(
6876 roster: &'a [AgentSpec],
6877 start: usize,
6878 tried: &BTreeSet<String>,
6879) -> Option<&'a AgentSpec> {
6880 roster
6881 .get(start + 1..)?
6882 .iter()
6883 .find(|s| !tried.contains(&s.id))
6884}
6885
6886fn pick_successor<'a>(
6907 roster: &'a [AgentSpec],
6908 start: usize,
6909 tried: &BTreeSet<String>,
6910 carried: Option<&BTreeSet<String>>,
6911 others: &BTreeSet<String>,
6912) -> Option<&'a AgentSpec> {
6913 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6914 roster
6915 .get(start + 1..)
6916 .and_then(|tail| {
6917 tail.iter()
6918 .filter(free)
6919 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6920 })
6921 .or_else(|| {
6922 carried
6923 .is_some()
6924 .then(|| roster.iter().find(free))
6925 .flatten()
6926 })
6927 .or_else(|| match carried {
6928 Some(c) => next_for_seat(roster, start, tried, c),
6929 None => next_untried_in_roster(roster, start, tried),
6930 })
6931}
6932
6933fn next_for_seat<'a>(
6943 roster: &'a [AgentSpec],
6944 start: usize,
6945 round_tried: &BTreeSet<String>,
6946 carried_failed: &BTreeSet<String>,
6947) -> Option<&'a AgentSpec> {
6948 roster
6949 .get(start + 1..)?
6950 .iter()
6951 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6952 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6953}
6954
6955fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6961 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6962 return spec;
6963 };
6964 let ok = |id: &str| !h.failed.contains(id);
6965 if let Some(last) = h.last_ok.as_deref()
6966 && ok(last)
6967 && let Some(s) = roster.iter().find(|s| s.id == last)
6968 {
6969 return s.clone();
6970 }
6971 if ok(&spec.id) {
6972 return spec;
6973 }
6974 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6975 roster
6976 .get(from + 1..)
6977 .into_iter()
6978 .flatten()
6979 .chain(roster.iter())
6980 .find(|s| ok(&s.id))
6981 .cloned()
6982 .unwrap_or(spec)
6983}
6984
6985pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6989 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6990}
6991
6992const TIMED_OUT: &str = "timed out";
6995
6996impl FailClass {
6997 fn of(out: &AgentOutcome) -> Option<Self> {
6999 match out {
7000 AgentOutcome::Ok(_) => None,
7001 AgentOutcome::Quota(_) => Some(Self::Quota),
7002 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7003 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7004 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7005 }
7006 }
7007
7008 fn stem_word(&self) -> &'static str {
7010 match self {
7011 Self::Quota => "quota",
7012 _ => "handover",
7013 }
7014 }
7015}
7016
7017fn failure_signature(msg: &str) -> String {
7021 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7022 let mut out = Vec::new();
7023 for word in line.split_whitespace() {
7024 if word.contains('/') || word.contains('\\') {
7025 out.push("<path>".to_owned());
7026 continue;
7027 }
7028 let mut w = String::new();
7029 let mut in_digits = false;
7030 for c in word.chars() {
7031 if c.is_ascii_digit() {
7032 if !in_digits {
7033 w.push('#');
7034 }
7035 in_digits = true;
7036 } else {
7037 in_digits = false;
7038 w.push(c);
7039 }
7040 }
7041 out.push(w);
7042 }
7043 out.join(" ").chars().take(120).collect()
7044}
7045
7046fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7053 match cur {
7054 FailClass::Quota | FailClass::Timeout => true,
7055 FailClass::Other(_) => prev != Some(cur),
7056 }
7057}
7058
7059fn fail_reason(out: &AgentOutcome) -> String {
7061 match out {
7062 AgentOutcome::Ok(_) => String::new(),
7063 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7064 AgentOutcome::Dropped(o) => format!(
7065 "the CLI dropped the stream ({})",
7066 o.dropped
7067 .as_ref()
7068 .map(|d| d.why.as_str())
7069 .unwrap_or("it ended without delivering its answer")
7070 ),
7071 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7072 }
7073}
7074
7075pub(crate) fn record_handover(
7078 state: &mut RunState,
7079 node: &str,
7080 seat: &str,
7081 from: &str,
7082 to: &str,
7083 class: &FailClass,
7084 reason: &str,
7085) {
7086 let message = if *class == FailClass::Quota {
7087 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7088 } else {
7089 format!("{seat}: handed over {from} -> {to} ({reason})")
7090 };
7091 state.event(node, message);
7092 state.handovers.push(Handover {
7093 at: Timestamp::now(),
7094 node: node.to_owned(),
7095 seat: seat.to_owned(),
7096 from: from.to_owned(),
7097 to: to.to_owned(),
7098 reason: reason.to_owned(),
7099 });
7100}
7101
7102fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7116 commands.iter().any(|c| c.exit_code.is_none())
7117}
7118
7119fn verified_noop_claim(
7132 usable: bool,
7133 commands: &[agent::CommandEvidence],
7134 text: &str,
7135) -> Option<String> {
7136 (usable && !has_unconfirmed_command(commands))
7137 .then(|| verdict::verified_noop(text))
7138 .flatten()
7139}
7140
7141fn short(commit: &str) -> String {
7142 commit.chars().take(7).collect()
7143}
7144
7145fn make_executable(path: &Path) -> Result<()> {
7146 #[cfg(unix)]
7147 {
7148 use std::os::unix::fs::PermissionsExt as _;
7149 let mut perms = std::fs::metadata(path)?.permissions();
7150 perms.set_mode(0o755);
7151 std::fs::set_permissions(path, perms)?;
7152 }
7153 #[cfg(not(unix))]
7154 {
7155 let _ = path;
7156 }
7157 Ok(())
7158}
7159
7160struct WaveCtx<'a> {
7167 run: &'a str,
7170 node: &'a str,
7172 prompts: &'a Prompts,
7173 cache: Option<&'a Path>,
7175 round: Option<usize>,
7178 carry_seats: bool,
7182}
7183
7184async fn run_one(
7186 job: SeatJob,
7187 sem: Arc<Semaphore>,
7188 ctx: &WaveCtx<'_>,
7189 state: &mut RunState,
7190 attempt: usize,
7191) -> (SeatState, AgentOutcome) {
7192 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7193 .await
7194 .pop()
7195 .expect("one job in, one result out");
7196 (seat, out)
7197}
7198
7199async fn wave(
7205 jobs: Vec<SeatJob>,
7206 sem: Arc<Semaphore>,
7207 ctx: &WaveCtx<'_>,
7208 state: &mut RunState,
7209 attempt: usize,
7210) -> Vec<(usize, SeatState, AgentOutcome)> {
7211 let WaveCtx {
7212 run,
7213 node,
7214 prompts,
7215 cache,
7216 round,
7217 carry_seats: _,
7218 } = *ctx;
7219 for job in &jobs {
7220 state.seat_started(node, &job.seat.key, job.timeout, attempt);
7221 }
7222 if let Err(e) = state.save() {
7223 tracing::warn!("could not persist in-progress seats: {e:#}");
7228 }
7229 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7245 let wait_started = Instant::now();
7246 let cache_guard = if let Some(cache_dir) = cache {
7247 if jobs_had_a_writer {
7248 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7249 let budget = jobs
7250 .iter()
7251 .map(|j| j.timeout)
7252 .max()
7253 .unwrap_or(Duration::from_secs(60));
7254 acquire_cache_lease(state, cache_dir, &owner, budget, node)
7255 .await
7256 .ok()
7257 } else {
7258 None
7259 }
7260 } else {
7261 None
7262 };
7263 let waited_for_lease = wait_started.elapsed();
7270 let mut set = tokio::task::JoinSet::new();
7271 let overlay = prompts.overlay(node);
7272 for (i, mut job) in jobs.into_iter().enumerate() {
7273 job.timeout = job.timeout.saturating_sub(waited_for_lease);
7274 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7275 if cache.is_some() {
7276 job.prompt.push('\n');
7277 job.prompt
7278 .push_str(&prompt::build_cache_note(node, job.allow_write));
7279 }
7280 let sem = Arc::clone(&sem);
7281 let run = run.to_owned();
7282 let node = node.to_owned();
7283 let attachments = if node == "implement" {
7286 state.attachments.clone()
7287 } else {
7288 Vec::new()
7289 };
7290 let cache = cache
7301 .filter(|_| job.allow_write && cache_guard.is_some())
7302 .map(Path::to_path_buf);
7303 set.spawn(async move {
7304 let _permit = sem.acquire().await;
7305 let mut seat = job.seat;
7306 let out = agent::invoke(
7307 &job.spec,
7308 &mut seat,
7309 &Invocation {
7310 cwd: &job.cwd,
7311 prompt: &job.prompt,
7312 timeout: job.timeout,
7313 allow_write: job.allow_write,
7314 unsandboxed: false,
7315 sessions: job.sessions,
7316 artifacts: &job.artifacts,
7317 stem: &job.stem,
7318 run: &run,
7319 node: &node,
7320 cache_dir: cache.as_deref(),
7321 attachments: &attachments,
7322 writable: &[],
7323 },
7324 )
7325 .await;
7326 let out = match out {
7327 Ok(o) if o.usable() => AgentOutcome::Ok(o),
7328 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7329 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7337 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7338 Ok(o) => AgentOutcome::Failed(format!(
7339 "exited with {:?} and no usable output",
7340 o.exit_code
7341 )),
7342 Err(e) => AgentOutcome::Failed(e.to_string()),
7343 };
7344 (i, seat, out)
7345 });
7346 }
7347 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7348 while let Some(joined) = set.join_next().await {
7349 let (i, seat, out) = match joined {
7350 Ok(v) => v,
7351 Err(e) => {
7355 tracing::error!("agent task panicked: {e}");
7356 continue;
7357 }
7358 };
7359 state.seat_finished(&seat.key);
7360 record_jobs(state, node, round, &seat.key, &out);
7361 if let Err(e) = state.save() {
7362 tracing::warn!("could not persist a seat's completion: {e:#}");
7363 }
7364 if collected.len() <= i {
7365 collected.resize_with(i + 1, || None);
7366 }
7367 collected[i] = Some((i, seat, out));
7368 }
7369 if state
7375 .active
7376 .values()
7377 .any(|a| a.node == node && a.attempt == attempt)
7378 {
7379 state
7380 .active
7381 .retain(|_, a| !(a.node == node && a.attempt == attempt));
7382 if let Err(e) = state.save() {
7383 tracing::warn!("could not persist the end of a wave: {e:#}");
7384 }
7385 }
7386 if let Some(cache_dir) = cache
7393 && jobs_had_a_writer
7394 {
7395 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7396 }
7397 if let Some(guard) = cache_guard {
7398 guard.release();
7399 }
7400 collected.into_iter().flatten().collect()
7401}
7402
7403fn record_jobs(
7414 state: &mut RunState,
7415 node: &str,
7416 round: Option<usize>,
7417 seat: &str,
7418 out: &AgentOutcome,
7419) {
7420 let commands: &[agent::CommandEvidence] = match out {
7421 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7422 AgentOutcome::Failed(_) => &[],
7423 };
7424 let checked_at = Timestamp::now();
7425 for c in commands {
7426 state.jobs.push(JobRecord {
7427 node: node.to_owned(),
7428 round,
7429 seat: seat.to_owned(),
7430 id: c.id.clone(),
7431 description: c.description.clone(),
7432 checked_at,
7433 status: match c.exit_code {
7434 Some(0) => JobStatus::Completed,
7435 Some(_) => JobStatus::Failed,
7436 None => JobStatus::Unknown,
7437 },
7438 exit_code: c.exit_code,
7439 result_summary: c.result_summary.clone(),
7440 source: c.source.clone(),
7441 });
7442 }
7443}
7444
7445fn round_is_clean(
7466 blocking: usize,
7467 e2e_ok: bool,
7468 answered: usize,
7469 expected: usize,
7470 quota_missing: usize,
7471 policy: IncompleteReviewPolicy,
7472) -> bool {
7473 if blocking != 0 || !e2e_ok {
7474 return false;
7475 }
7476 if answered == expected || policy == IncompleteReviewPolicy::Warn {
7477 return true;
7478 }
7479 answered > 0 && expected - answered <= quota_missing
7480}
7481
7482fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7506 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7507 return Some(RunStatus::Gating);
7508 }
7509 let last = reviews.last()?;
7510 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7511 if reviews.len() < max_rounds && !stagnant {
7512 return None;
7513 }
7514 if last.incomplete() && last.blocking == 0 {
7515 return Some(RunStatus::Blocked);
7516 }
7517 if last.e2e_status() == E2eStatus::ResourceBlocked {
7518 return None;
7519 }
7520 Some(if last.e2e.iter().all(CommandOutcome::ok) {
7521 RunStatus::Gating
7522 } else {
7523 RunStatus::Blocked
7524 })
7525}
7526
7527pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7542 if nudged {
7543 (full / 4).max(Duration::from_secs(120)).min(full)
7544 } else {
7545 full
7546 }
7547}
7548
7549#[allow(clippy::too_many_arguments)]
7578async fn ask_json_wave<T>(
7579 jobs: Vec<SeatJob>,
7580 sem: Arc<Semaphore>,
7581 retries: usize,
7582 roster: &[AgentSpec],
7583 ctx: &WaveCtx<'_>,
7584 losses: &mut Vec<QuotaLoss>,
7585 state: &mut RunState,
7586 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7587) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7588where
7589 T: serde::de::DeserializeOwned + Send + 'static,
7590{
7591 ask_wave_with(
7592 jobs,
7593 sem,
7594 retries,
7595 roster,
7596 ctx,
7597 losses,
7598 state,
7599 &|text: &str| {
7600 let v = verdict::extract_json::<T>(text)?;
7601 validate(&v)?;
7602 Ok(v)
7603 },
7604 )
7605 .await
7606}
7607
7608#[allow(clippy::too_many_arguments)]
7617async fn ask_wave_with<T>(
7618 jobs: Vec<SeatJob>,
7619 sem: Arc<Semaphore>,
7620 retries: usize,
7621 roster: &[AgentSpec],
7622 ctx: &WaveCtx<'_>,
7623 losses: &mut Vec<QuotaLoss>,
7624 state: &mut RunState,
7625 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7626) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7627where
7628 T: Send + 'static,
7629{
7630 let n = jobs.len();
7631 let originals: Vec<SeatJob> = jobs;
7632 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7633 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7634 let mut nudges: Vec<usize> = vec![0; n];
7641 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7645 let mut tried: Vec<BTreeSet<String>> = specs
7646 .iter()
7647 .map(|s| BTreeSet::from([s.id.clone()]))
7648 .collect();
7649 let starts: Vec<usize> = specs
7650 .iter()
7651 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7652 .collect();
7653 let carry = ctx.carry_seats && !roster.is_empty();
7654 let carried: Vec<BTreeSet<String>> = originals
7657 .iter()
7658 .map(|j| {
7659 state
7660 .seat_history
7661 .get(&j.seat.key)
7662 .filter(|_| carry)
7663 .map(|h| h.failed.clone())
7664 .unwrap_or_default()
7665 })
7666 .collect();
7667 let mut prev: Vec<Option<FailClass>> = originals
7668 .iter()
7669 .map(|j| {
7670 state
7671 .seat_history
7672 .get(&j.seat.key)
7673 .filter(|_| carry)
7674 .and_then(|h| h.last_fail.clone())
7675 })
7676 .collect();
7677 let next_agent =
7678 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7679 let others: BTreeSet<String> = specs
7680 .iter()
7681 .enumerate()
7682 .filter(|(j, _)| *j != i)
7683 .map(|(_, s)| s.id.clone())
7684 .collect();
7685 pick_successor(
7686 roster,
7687 starts[i],
7688 tried,
7689 carry.then(|| &carried[i]),
7690 &others,
7691 )
7692 .cloned()
7693 };
7694 let mut fresh: Vec<Option<String>> = vec![None; n];
7695 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7696 let mut pending: Vec<usize> = (0..n).collect();
7697
7698 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7703 for round in 0..max_rounds {
7704 if pending.is_empty() {
7705 break;
7706 }
7707 let mut batch = Vec::with_capacity(pending.len());
7708 let mut renudged: Vec<&str> = Vec::new();
7709 for &i in &pending {
7710 let src = &originals[i];
7711 let full: &str = match &src.handover {
7716 Some(h) if specs[i].id != src.spec.id => h,
7717 _ => &src.prompt,
7718 };
7719 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7722 (
7723 full.to_owned(),
7724 src.timeout,
7725 format!("{}-{word}-{}", src.stem, specs[i].id),
7726 )
7727 } else if nudges[i] == 0 {
7728 (full.to_owned(), src.timeout, src.stem.clone())
7729 } else {
7730 renudged.push(src.seat.key.as_str());
7731 let why = done[i]
7732 .as_ref()
7733 .and_then(|r| r.as_ref().err().map(ToString::to_string))
7734 .unwrap_or_else(|| "no parsable answer".to_owned());
7735 let nudge = prompt::nudge(&why);
7736 let nudged = has_context(&specs[i], &seats[i], src.sessions);
7737 let prompt = if nudged {
7738 nudge
7739 } else {
7740 format!("{full}\n\n---\n\n{nudge}")
7741 };
7742 (
7743 prompt,
7744 retry_budget(src.timeout, nudged),
7745 format!("{}-retry{}", src.stem, nudges[i]),
7746 )
7747 };
7748 batch.push(SeatJob {
7749 spec: specs[i].clone(),
7750 seat: seats[i].clone(),
7751 cwd: src.cwd.clone(),
7752 prompt,
7753 timeout,
7754 allow_write: src.allow_write,
7755 sessions: src.sessions,
7756 artifacts: src.artifacts.clone(),
7757 stem,
7758 handover: None,
7759 });
7760 }
7761
7762 if !renudged.is_empty() {
7763 state.event(
7764 ctx.node,
7765 format!("retry {round}: re-asking {}", renudged.join(", ")),
7766 );
7767 }
7768 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7769 let mut still = Vec::new();
7770 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7771 seats[i] = seat;
7772 let class = FailClass::of(&out);
7773 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7776 && nudges[i] < retries
7777 && !roster.is_empty();
7778 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7779 let next = should_hand_over(prev[i].as_ref(), &cur)
7780 .then(|| next_agent(i, &tried[i], &specs))
7781 .flatten();
7782 if carry {
7783 let h = state
7784 .seat_history
7785 .entry(originals[i].seat.key.clone())
7786 .or_default();
7787 h.failed.insert(specs[i].id.clone());
7788 h.last_fail = Some(cur.clone());
7789 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7790 h.last_ok = None;
7791 }
7792 if let Err(e) = state.save() {
7795 tracing::warn!("could not persist a seat's failure history: {e:#}");
7796 }
7797 }
7798 if let Some(next) = next {
7799 record_handover(
7800 state,
7801 ctx.node,
7802 &originals[i].seat.key,
7803 &specs[i].id,
7804 &next.id,
7805 &cur,
7806 &fail_reason(&out),
7807 );
7808 tried[i].insert(next.id.clone());
7809 prev[i] = Some(cur.clone());
7810 seats[i] =
7811 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7812 specs[i] = next;
7813 fresh[i] = Some(cur.stem_word().to_owned());
7814 nudges[i] = 0;
7815 done[i] = Some(Err(anyhow::anyhow!(
7816 "handed over after: {}",
7817 fail_reason(&out)
7818 )));
7819 still.push(i);
7820 continue;
7821 }
7822 }
7823 if carry
7824 && !nudge_first
7825 && let Some(cur) = class.clone()
7826 {
7827 let h = state
7829 .seat_history
7830 .entry(originals[i].seat.key.clone())
7831 .or_default();
7832 h.failed.insert(specs[i].id.clone());
7833 h.last_fail = Some(cur);
7834 }
7835 let parsed = match out {
7836 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7837 AgentOutcome::Quota(o) => {
7838 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7839 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7840 }
7841 AgentOutcome::Dropped(o) => {
7845 let why = o
7846 .dropped
7847 .as_ref()
7848 .map(|d| d.why.as_str())
7849 .unwrap_or("the CLI ended the stream without delivering its answer");
7850 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7851 }
7852 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7853 };
7854 let quota = class == Some(FailClass::Quota);
7855 let failed = parsed.is_err();
7856 done[i] = Some(parsed);
7857 if carry && !failed {
7858 let h = state
7859 .seat_history
7860 .entry(originals[i].seat.key.clone())
7861 .or_default();
7862 h.failed.remove(&specs[i].id);
7863 h.last_ok = Some(specs[i].id.clone());
7864 h.last_fail = None;
7865 }
7866 let agent_failure = class.is_some()
7874 && !nudge_first
7875 && !roster.is_empty()
7876 && next_agent(i, &tried[i], &specs).is_some();
7877 if failed && !quota && !agent_failure && nudges[i] < retries {
7878 nudges[i] += 1;
7879 still.push(i);
7880 }
7881 }
7882 pending = still;
7883 }
7884
7885 for (i, q) in last_quota.into_iter().enumerate() {
7888 if let Some(reset) = q {
7889 losses.push(QuotaLoss {
7890 seat: originals[i].seat.key.clone(),
7891 node: ctx.node.to_owned(),
7892 at: Timestamp::now(),
7893 reset,
7894 });
7895 }
7896 }
7897
7898 seats
7899 .into_iter()
7900 .zip(done)
7901 .zip(nudges)
7902 .map(|((seat, res), attempts)| {
7903 (
7904 seat,
7905 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7906 attempts,
7907 )
7908 })
7909 .collect()
7910}
7911
7912async fn acquire_cache_lease(
7925 state: &mut RunState,
7926 cache_dir: &Path,
7927 owner: &crate::cache::Owner,
7928 budget: Duration,
7929 context: &str,
7930) -> Result<crate::cache::Guard> {
7931 let home = crate::run::home();
7932 let started = Instant::now();
7933 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7934 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7935 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7936 Err(e) => {
7937 state.event(
7938 "verify",
7939 format!("{context}: could not check the shared build cache: {e:#}"),
7940 );
7941 if let Err(e2) = state.save() {
7942 tracing::warn!("could not persist a cache-check failure: {e2:#}");
7943 }
7944 return Err(e);
7945 }
7946 };
7947 state.event(
7948 "verify",
7949 format!(
7950 "{context}: waiting for the shared build cache at {} ({})",
7951 cache_dir.display(),
7952 busy.describe()
7953 ),
7954 );
7955 if let Err(e) = state.save() {
7956 tracing::warn!("could not persist a cache wait: {e:#}");
7957 }
7958 let remaining = budget.saturating_sub(started.elapsed());
7959 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7960 Ok(g) => Ok(g),
7961 Err(e) => {
7962 state.event("verify", format!("{context}: {e:#}"));
7963 if let Err(e2) = state.save() {
7964 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7965 }
7966 Err(e)
7967 }
7968 }
7969}
7970
7971#[allow(clippy::too_many_arguments)]
7992async fn with_cache_lease<'s, F, Fut>(
7993 state: &'s mut RunState,
7994 cache_dir: Option<&Path>,
7995 node: &str,
7996 seat: &str,
7997 worktree: &Path,
7998 head: &str,
7999 budget: Duration,
8000 context: &str,
8001 body: F,
8002) -> (Vec<CommandOutcome>, bool)
8003where
8004 F: FnOnce(&'s mut RunState, Duration) -> Fut,
8005 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8006{
8007 let Some(cache_dir) = cache_dir else {
8008 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8009 return (outcomes, retried);
8010 };
8011 let home = crate::run::home();
8012 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8013 let started = Instant::now();
8014 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8015 Ok(g) => g,
8016 Err(e) => {
8017 return (
8018 vec![CommandOutcome {
8019 command: "(waiting for the shared build cache)".to_owned(),
8020 code: None,
8021 output_tail: e.to_string(),
8022 duration_ms: started.elapsed().as_millis() as u64,
8023 resource_blocked: true,
8024 }],
8025 false,
8026 );
8027 }
8028 };
8029 let identity = crate::cache::Identity::new(worktree, head);
8030 if let Err(e) = crate::cache::ensure_fresh(
8031 &home,
8032 cache_dir,
8033 &identity,
8034 &state.config.verify.cargo_manifests(),
8035 ) {
8036 state.event(
8044 "verify",
8045 format!(
8046 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8047 worktree.display(),
8048 short(head)
8049 ),
8050 );
8051 guard.release();
8052 return (
8053 vec![CommandOutcome {
8054 command: "(confirming the shared build cache is fresh)".to_owned(),
8055 code: None,
8056 output_tail: e.to_string(),
8057 duration_ms: started.elapsed().as_millis() as u64,
8058 resource_blocked: true,
8059 }],
8060 false,
8061 );
8062 }
8063 let remaining = budget.saturating_sub(started.elapsed());
8064 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8065 if !timed_out_pids.is_empty() {
8070 wait_for_timed_out_children_to_die(&timed_out_pids).await;
8071 }
8072 guard.release();
8073 (outcomes, retried)
8074}
8075
8076async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8088 wait_for_pids_with(
8089 pids,
8090 crate::proc::pid_alive,
8091 LEASE_RELEASE_POLL,
8092 LEASE_RELEASE_MAX_WAIT,
8093 )
8094 .await;
8095}
8096
8097async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8103 pids: &[u32],
8104 alive: F,
8105 poll: Duration,
8106 max_wait: Duration,
8107) {
8108 let deadline = Instant::now() + max_wait;
8109 loop {
8110 if pids.iter().all(|&pid| !alive(pid)) {
8111 return;
8112 }
8113 if Instant::now() >= deadline {
8114 return;
8115 }
8116 tokio::time::sleep(poll).await;
8117 }
8118}
8119
8120fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8127 outcomes.iter().any(|o| o.resource_blocked)
8128}
8129
8130enum GateFix {
8132 Retry,
8134 Stop,
8137 Defer,
8140}
8141
8142fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8150 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8151 red.peek().is_some()
8152 && red.all(|o| {
8153 !o.resource_blocked
8154 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8155 && !o.output_tail.trim().is_empty()
8156 })
8157}
8158
8159fn e2e_outcome_label(o: &CommandOutcome) -> String {
8163 if o.ok() {
8164 return "pass".to_owned();
8165 }
8166 let reason = if o.build_failed() {
8167 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8168 } else {
8169 format!("FAIL ({:?})", o.code)
8170 };
8171 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8172}
8173
8174async fn run_e2e_with_retry(
8182 state: &mut RunState,
8183 shell: &[String],
8184 commands: &[String],
8185 worktree: &Path,
8186 timeout: Duration,
8187 context: &str,
8188) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8189 let (mut e2e, mut timed_out_pids) = run_commands(
8190 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8191 )
8192 .await;
8193 for o in &e2e {
8194 state.event(
8195 "verify",
8196 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8197 );
8198 }
8199 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8203 if verify_retried {
8204 state.event(
8205 "verify",
8206 format!(
8207 "{context}: verify could not build/link, not a test result — retrying once \
8208 before concluding"
8209 ),
8210 );
8211 let retried = run_commands(
8212 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8213 )
8214 .await;
8215 e2e = retried.0;
8216 timed_out_pids.extend(retried.1);
8219 for o in &e2e {
8220 state.event(
8221 "verify",
8222 format!(
8223 "{context}: retry `{}` -> {}",
8224 o.command,
8225 e2e_outcome_label(o)
8226 ),
8227 );
8228 }
8229 }
8230 (e2e, verify_retried, timed_out_pids)
8231}
8232
8233#[allow(clippy::too_many_arguments)]
8249async fn run_commands(
8250 state: &mut RunState,
8251 node: &str,
8252 task: &str,
8253 attempt: usize,
8254 shell: &[String],
8255 commands: &[String],
8256 cwd: &Path,
8257 timeout: Duration,
8258) -> (Vec<CommandOutcome>, Vec<u32>) {
8259 if commands.is_empty() {
8260 return (Vec::new(), Vec::new());
8265 }
8266 let mut out = Vec::new();
8267 let mut timed_out_pids = Vec::new();
8268 let total = commands.len();
8269 for (idx, command) in commands.iter().enumerate() {
8270 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8271 if let Err(e) = state.save() {
8272 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8273 }
8274 let started = Instant::now();
8275 let mut cmd = tokio::process::Command::new(&shell[0]);
8276 cmd.quiet();
8277 cmd.args(&shell[1..])
8278 .arg(command)
8279 .current_dir(cwd)
8280 .stdin(std::process::Stdio::null())
8281 .stdout(std::process::Stdio::piped())
8282 .stderr(std::process::Stdio::piped())
8283 .kill_on_drop(true);
8284 let spawned = cmd.spawn();
8285 let (code, body) = match spawned {
8286 Ok(child) => {
8287 let pid = child.id();
8292 match tokio::time::timeout(timeout, child.wait_with_output()).await {
8293 Ok(Ok(o)) => {
8294 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8295 body.push_str(&String::from_utf8_lossy(&o.stderr));
8296 (o.status.code(), body)
8297 }
8298 Ok(Err(e)) => (None, format!("failed to run: {e}")),
8299 Err(_) => {
8300 if let Some(pid) = pid {
8301 timed_out_pids.push(pid);
8302 }
8303 (None, format!("timed out after {}s", timeout.as_secs()))
8304 }
8305 }
8306 }
8307 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8308 };
8309 out.push(CommandOutcome {
8310 command: command.clone(),
8311 code,
8312 output_tail: tail(&body, OUTPUT_TAIL),
8313 duration_ms: started.elapsed().as_millis() as u64,
8314 resource_blocked: false,
8315 });
8316 }
8317 state.task_finished(task);
8318 if let Err(e) = state.save() {
8319 tracing::warn!("could not persist the end of {task}: {e:#}");
8320 }
8321 (out, timed_out_pids)
8322}
8323
8324fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8336 let repo = repo.display();
8337 match style {
8338 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8339 MergeStyle::Squash => {
8340 let subject = message
8343 .lines()
8344 .next()
8345 .unwrap_or(branch)
8346 .replace(['\\', '"', '$', '`'], "");
8347 format!(
8348 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8349 )
8350 }
8351 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8352 }
8353}
8354
8355const PR_TITLE_MAX: usize = 240;
8366
8367fn english_title(text: &str, max: usize) -> Option<String> {
8378 let line = queue::first_line(text)?;
8379 let chars: Vec<char> = line.chars().collect();
8380 let (kept, mark) = if chars.len() <= max {
8381 (line.to_owned(), "")
8382 } else if let Some(end) = sentence_end(&chars, max) {
8383 (chars[..end].iter().collect::<String>(), "")
8384 } else {
8385 let room = max.saturating_sub(3);
8386 let cut = if chars[room].is_whitespace() {
8389 room
8390 } else {
8391 chars[..room]
8392 .iter()
8393 .rposition(|c| c.is_whitespace())
8394 .unwrap_or(room)
8395 };
8396 let head: String = chars[..cut].iter().collect();
8397 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8398 (head.to_owned(), "...")
8399 };
8400 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8401 return None;
8402 }
8403 Some(format!("{kept}{mark}"))
8404}
8405
8406fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8410 const MIN: usize = 20;
8411 for i in MIN..max.min(chars.len()) {
8412 if !matches!(chars[i], '.' | '!' | '?') {
8413 continue;
8414 }
8415 let Some(&next) = chars.get(i + 1) else {
8416 continue;
8417 };
8418 if !next.is_whitespace() {
8419 continue;
8420 }
8421 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8422 if after.is_some_and(|c| c.is_ascii_lowercase()) {
8423 continue;
8424 }
8425 let word: String = chars[..i]
8426 .iter()
8427 .rev()
8428 .take_while(|c| !c.is_whitespace())
8429 .collect::<Vec<_>>()
8430 .into_iter()
8431 .rev()
8432 .collect();
8433 let word = word.to_ascii_lowercase();
8434 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8435 continue;
8436 }
8437 let end = chars[..i]
8438 .iter()
8439 .rposition(|c| !c.is_whitespace())
8440 .map_or(i, |p| p + 1);
8441 return Some(end);
8442 }
8443 None
8444}
8445
8446struct PrMessage {
8451 title: String,
8452 body: String,
8453}
8454
8455impl PrMessage {
8456 fn commit_message(&self) -> String {
8460 format!("{}\n\n{}", self.title, self.body)
8461 }
8462}
8463
8464fn title_marker(line: &str) -> Option<&str> {
8466 let line = line.trim();
8467 let head = line.get(..6)?;
8468 head.eq_ignore_ascii_case("title:")
8469 .then(|| line[6..].trim())
8470}
8471
8472fn summary_title(summary: &str) -> Option<String> {
8477 let first = summary.lines().find(|l| !l.trim().is_empty())?;
8478 let raw = title_marker(first)?;
8479 if raw.is_empty() {
8480 return None;
8481 }
8482 let title = queue::title_from(raw, PR_TITLE_MAX);
8483 let lower = title.to_ascii_lowercase();
8484 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8485 return None;
8486 }
8487 Some(title)
8488}
8489
8490const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8492
8493const EXISTING_BRANCH: &str = "(existing branch)";
8495
8496fn is_review_run(state: &RunState) -> bool {
8500 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8501}
8502
8503fn review_title(state: &RunState) -> Option<String> {
8508 english_subject(state.reviewed_commits.as_ref()?.first()?)
8509}
8510
8511fn english_subject(raw: &str) -> Option<String> {
8514 let raw = raw.trim();
8515 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8516 return None;
8517 }
8518 let title = queue::title_from(raw, PR_TITLE_MAX);
8519 let lower = title.to_ascii_lowercase();
8520 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8521 return None;
8522 }
8523 Some(title)
8524}
8525
8526#[derive(Debug, Clone, PartialEq, Eq)]
8529struct BranchFacts {
8530 commits: Vec<(String, String)>,
8532 stat: String,
8534}
8535
8536const STAT_FILE_LINES: usize = 25;
8538const COMMITS_MAX_CHARS: usize = 20_000;
8540
8541async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8544 let commits = git::commit_log(repo, base, branch).await.ok()?;
8545 if commits.is_empty() {
8546 return None;
8547 }
8548 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8549 let lines: Vec<&str> = stat.lines().collect();
8550 let stat = if lines.len() > STAT_FILE_LINES + 1 {
8551 let omitted = lines.len() - 1 - STAT_FILE_LINES;
8552 let more = format!(" ... {omitted} more file(s)");
8553 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8554 kept.push(&more);
8555 kept.push(lines[lines.len() - 1]);
8556 kept.join("\n")
8557 } else {
8558 lines.join("\n")
8559 };
8560 Some(BranchFacts { commits, stat })
8561}
8562
8563fn markdown_safe(text: &str) -> String {
8566 text.replace("</details>", "</details>")
8567 .replace("\x60\x60\x60", "~~~")
8568}
8569
8570fn neutral_title(state: &RunState, winner: char) -> String {
8571 format!(
8572 "chore: land candidate {} of run {}",
8573 winner.to_ascii_uppercase(),
8574 state.id
8575 )
8576}
8577
8578pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8584 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8585 ""
8586 } else {
8587 pr_title
8588 }
8589}
8590
8591pub fn landing_subject_source(state: &RunState) -> String {
8595 if is_review_run(state) {
8596 let winner = state.candidates.first().map_or('A', |c| c.label);
8597 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8598 }
8599 state.instruction.clone()
8600}
8601
8602fn summary_without_title(summary: &str) -> String {
8605 let mut lines = summary.trim().lines().peekable();
8606 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8607 lines.next();
8608 }
8609 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8610}
8611
8612#[cfg(test)]
8626fn pr_message(state: &RunState, winner: char) -> PrMessage {
8627 pr_message_with(state, winner, None)
8628}
8629
8630#[cfg(test)]
8633fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8634 let raw = pr_message_raw(state, winner, facts);
8635 let id = crate::scrub::Identity::current();
8636 PrMessage {
8637 title: crate::scrub::scrub(&raw.title, &id),
8638 body: crate::scrub::scrub(&raw.body, &id),
8639 }
8640}
8641
8642fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8643 let summary = state
8644 .candidates
8645 .iter()
8646 .find(|c| c.label == winner)
8647 .map(|c| c.summary.as_str())
8648 .unwrap_or_default();
8649 let review = is_review_run(state);
8652 let title = if review {
8653 facts
8654 .and_then(|f| english_subject(&f.commits.first()?.0))
8655 .or_else(|| review_title(state))
8656 .or_else(|| {
8657 state
8658 .candidates
8659 .iter()
8660 .find(|c| c.label == winner)
8661 .filter(|c| !c.branch.starts_with("magi/"))
8662 .and_then(|c| english_subject(&c.branch))
8663 })
8664 .unwrap_or_else(|| neutral_title(state, winner))
8665 } else {
8666 summary_title(summary).unwrap_or_else(|| {
8667 english_title(&state.instruction, PR_TITLE_MAX)
8668 .unwrap_or_else(|| neutral_title(state, winner))
8669 })
8670 };
8671
8672 let mut body = String::new();
8673 let what = summary_without_title(summary);
8674 if !what.is_empty() {
8675 body.push_str("## Summary\n\n");
8676 body.push_str(&what);
8677 body.push_str("\n\n");
8678 }
8679
8680 let notes: Vec<(usize, &str)> = state
8683 .reviews
8684 .iter()
8685 .filter_map(|r| {
8686 let n = r.fix.as_ref()?.notes.trim();
8687 (!n.is_empty()).then_some((r.round, n))
8688 })
8689 .collect();
8690 if !notes.is_empty() {
8691 body.push_str("## Review fixes\n\n");
8692 if let [(_, only)] = notes.as_slice() {
8693 body.push_str(only);
8694 body.push_str("\n\n");
8695 } else {
8696 for (round, n) in ¬es {
8697 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8698 }
8699 }
8700 }
8701 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8702
8703 let open = state.open_findings();
8704 if !open.is_empty() {
8705 body.push_str("## Open review findings\n\n");
8706 for f in &open {
8707 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8708 }
8709 body.push('\n');
8710 }
8711
8712 if let Some(fix) = fix
8713 && !fix.rejected.is_empty()
8714 {
8715 body.push_str("## Declined by the fixer\n\n");
8716 for r in &fix.rejected {
8717 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8718 }
8719 body.push('\n');
8720 }
8721
8722 if review {
8723 body.push_str("## Commits under review\n\n");
8725 if let Some(facts) = facts {
8726 let mut left = COMMITS_MAX_CHARS;
8727 for (i, (subject, text)) in facts.commits.iter().enumerate() {
8728 let mut entry = format!("- {}\n", markdown_safe(subject));
8729 for l in markdown_safe(text).lines() {
8730 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
8731 }
8732 if left == 0 {
8733 body.push_str(&format!(
8734 "- ... {} more commit(s)\n",
8735 facts.commits.len() - i
8736 ));
8737 break;
8738 }
8739 if entry.len() > left {
8740 let mut end = left;
8743 while !entry.is_char_boundary(end) {
8744 end -= 1;
8745 }
8746 entry.truncate(end);
8747 entry.push_str("\n ... (truncated)\n");
8748 left = 0;
8749 } else {
8750 left -= entry.len();
8751 }
8752 body.push_str(&entry);
8753 }
8754 if !facts.stat.trim().is_empty() {
8755 body.push_str(&format!(
8756 "\n## Diff stat\n\n```\n{}\n```\n",
8757 markdown_safe(facts.stat.trim())
8758 ));
8759 }
8760 } else {
8761 match &state.reviewed_commits {
8762 Some(subjects) => {
8763 for s in subjects {
8764 body.push_str(&format!("- {}\n", s.trim()));
8765 }
8766 }
8767 None => {
8768 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8771 body.push_str(rest.trim());
8772 body.push('\n');
8773 }
8774 }
8775 }
8776 } else {
8777 let task = state.instruction.trim();
8778 let task = if task.is_empty() {
8779 "(empty task)"
8780 } else {
8781 task
8782 };
8783 body.push_str(&format!(
8784 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8785 task.replace("</details>", "</details>")
8786 ));
8787 }
8788
8789 body.push_str(&format!(
8790 "\n---\nmagi:run/{} magi:candidate-{}\n",
8791 state.id,
8792 winner.to_ascii_lowercase()
8793 ));
8794
8795 PrMessage { title, body }
8796}
8797
8798fn seeded_instruction(state: &RunState) -> String {
8802 match refs::describe(&state.seeds) {
8803 Some(facts) => format!(
8804 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8805 Candidates start from the unmerged branch named above, when there \
8806 is one, and carry any unmerged commit named by sha as a \
8807 cherry-pick. Check that this is what the task meant before \
8808 building on it.",
8809 state.instruction
8810 ),
8811 None => state.instruction.clone(),
8812 }
8813}
8814
8815async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8819 let base = &state.base_branch;
8820 let mut against = base.clone();
8821 if mode == MergeMode::Pr {
8822 let remote = &state.config.merge.remote;
8826 let tracking = format!("{remote}/{base}");
8827 let fetched = git::fetch(repo, remote, base).await;
8828 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8829 against = tracking;
8830 } else {
8831 return false;
8832 }
8833 }
8834 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8835}
8836
8837fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8840 let mut detail = format!(
8841 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8842 nothing to open a pull request for"
8843 );
8844 match refs::describe(&state.seeds) {
8845 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8846 None => detail.push_str(
8847 "\nThe task names no existing branch or commit; if it means to land work \
8848 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8849 ),
8850 }
8851 detail
8852}
8853
8854#[derive(Debug, PartialEq, Eq)]
8857enum PrPlan {
8858 Create,
8859 Adopt { url: String, title: String },
8860 Stop(String),
8861}
8862
8863fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8866 match found {
8867 Ok(land::OpenPr::None) => PrPlan::Create,
8868 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8869 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8870 "several open pull requests exist for this branch, not picking one: {}",
8871 urls.join(" ")
8872 )),
8873 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8874 }
8875}
8876
8877async fn gh_pr_create(
8879 cwd: &Path,
8880 base: &str,
8881 head: &str,
8882 title: &str,
8883 body: &str,
8884) -> Result<String> {
8885 let out = tokio::process::Command::new("gh")
8886 .args([
8887 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8888 ])
8889 .current_dir(cwd)
8890 .quiet()
8891 .stdin(std::process::Stdio::null())
8892 .output()
8893 .await
8894 .context("spawn gh")?;
8895 if out.status.success() {
8896 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8897 } else {
8898 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8899 }
8900}
8901
8902pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8911 let repo = state.repo.clone();
8912 let root = state.worktree_root();
8913 let winner = state.tally.as_ref().map(|t| t.winner);
8914 let mut removed = Vec::new();
8915
8916 for i in 0..state.candidates.len() {
8917 let c = state.candidates[i].clone();
8918 let is_winner = Some(c.label) == winner;
8919 if is_winner && !drop_winner {
8920 continue;
8921 }
8922 if c.worktree.exists() {
8923 git::worktree_remove(&repo, &c.worktree).await.ok();
8924 removed.push(c.worktree.to_string_lossy().into_owned());
8925 }
8926 let handed_over = state.released_branches.contains(&c.branch);
8929 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8930 git::branch_delete(&repo, &c.branch).await.ok();
8931 removed.push(c.branch.clone());
8932 }
8933 state.candidates[i].folded = true;
8934 }
8935
8936 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8937 let path = name.path();
8938 let keep = !drop_winner
8939 && winner.is_some_and(|w| {
8940 path.file_name()
8941 .is_some_and(|n| n == format!("cand-{w}").as_str())
8942 });
8943 if keep {
8944 continue;
8945 }
8946 git::worktree_remove(&repo, &path).await.ok();
8947 removed.push(path.to_string_lossy().into_owned());
8948 }
8949
8950 remove_if_empty(&root);
8959
8960 if state.enabled_worktree_config && drop_winner {
8961 git::release_worktree_config(&repo).await.ok();
8965 state.enabled_worktree_config = false;
8966 }
8967 state.save_under(home)?;
8968 Ok(removed)
8969}
8970
8971fn remove_if_empty(dir: &Path) {
8982 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8983 std::fs::remove_dir(dir).ok();
8984 }
8985}
8986
8987pub fn worst_open(state: &RunState) -> Option<Severity> {
8989 state
8990 .reviews
8991 .last()?
8992 .reviews
8993 .iter()
8994 .flat_map(|r| r.findings.iter())
8995 .map(|f| f.severity)
8996 .max()
8997}
8998
8999async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9003 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9004 git::acquire_worktree_config(repo).await?;
9005 state.enabled_worktree_config = true;
9006 }
9007 Ok(())
9008}
9009
9010#[cfg(test)]
9011mod tests {
9012 #[test]
9013 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9014 let leaked = vec!["chore(deps): update a crate".to_owned()];
9015 let own = "fix(daemon): apply a chosen action";
9016 assert!(should_retitle("", own, &leaked));
9017 assert!(should_retitle(
9018 &format!("{REVIEW_PROMPT_OPENING} `x`"),
9019 own,
9020 &leaked
9021 ));
9022 assert!(should_retitle(
9023 "chore: land candidate A of run 1",
9024 own,
9025 &leaked
9026 ));
9027 assert!(should_retitle(
9028 "magi: candidate A (uncommitted work)",
9029 own,
9030 &leaked
9031 ));
9032 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9033 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9034 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9035 }
9036
9037 #[test]
9038 fn pr_merge_plan_creates_adopts_or_stops() {
9039 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9040 assert_eq!(
9041 pr_merge_plan(Ok(land::OpenPr::One {
9042 url: "u".into(),
9043 title: "t".into()
9044 })),
9045 PrPlan::Adopt {
9046 url: "u".into(),
9047 title: "t".into()
9048 }
9049 );
9050 let PrPlan::Stop(many) =
9051 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9052 else {
9053 panic!("many must stop");
9054 };
9055 assert!(many.contains('a') && many.contains('b'));
9056 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9057 panic!("a failed lookup must stop");
9058 };
9059 assert!(err.contains("bad token"));
9060 }
9061
9062 use super::*;
9063 use crate::run::GateStatus;
9064 use std::collections::BTreeMap;
9065 use std::time::Duration;
9066
9067 fn conductor() -> AgentSpec {
9068 AgentSpec {
9069 id: "conductor".to_owned(),
9070 kind: crate::config::AgentKind::Command,
9071 model: None,
9072 command: vec!["true".to_owned()],
9073 extra_args: Vec::new(),
9074 env: BTreeMap::new(),
9075 prompt_delivery: None,
9076 }
9077 }
9078
9079 fn spec(id: &str) -> AgentSpec {
9080 AgentSpec {
9081 id: id.to_owned(),
9082 kind: crate::config::AgentKind::Command,
9083 model: None,
9084 command: vec!["true".to_owned()],
9085 extra_args: Vec::new(),
9086 env: BTreeMap::new(),
9087 prompt_delivery: None,
9088 }
9089 }
9090
9091 fn ids(xs: &[&str]) -> BTreeSet<String> {
9092 xs.iter().map(|s| (*s).to_owned()).collect()
9093 }
9094
9095 #[test]
9096 fn pick_successor_skips_an_agent_another_seat_holds() {
9097 let roster = [spec("a"), spec("b"), spec("c")];
9099 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9100 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9101 }
9102
9103 #[test]
9104 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9105 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9107 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9108 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9109 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9110 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9111 }
9112
9113 #[test]
9114 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9115 let roster = [spec("a"), spec("b")];
9116 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9117 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9118 let carried = ids(&["b"]);
9119 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9120 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9121 }
9122
9123 #[test]
9124 fn pick_successor_returns_none_without_any_untried_successor() {
9125 let roster = [spec("a"), spec("b")];
9126 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9127 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9128 let carried = ids(&["a"]);
9129 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9130 }
9131
9132 #[test]
9133 fn pick_successor_rescue_avoids_another_seats_occupant() {
9134 let roster = [spec("a"), spec("b"), spec("c")];
9136 let carried = ids(&["b", "c"]);
9137 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9138 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9139 }
9140
9141 #[test]
9142 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9143 let roster = [spec("a"), spec("b"), spec("c")];
9144 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9145 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9146 }
9147
9148 #[test]
9149 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9150 let roster = [spec("a"), spec("b"), spec("c")];
9151 let failed = ids(&["a", "b", "c"]);
9152 let mut tried = ids(&["b"]);
9154 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9155 assert_eq!(first.id, "a");
9156 tried.insert(first.id.clone());
9157 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9158 assert_eq!(second.id, "c");
9159 tried.insert(second.id.clone());
9160 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9161 }
9162
9163 #[test]
9164 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9165 let roster = [spec("a"), spec("b")];
9166 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9167 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9168 }
9169
9170 #[test]
9171 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9172 let roster = [spec("a"), spec("b"), spec("c")];
9173 let h = SeatHistory {
9174 failed: ids(&["a"]),
9175 last_ok: Some("b".to_owned()),
9176 last_fail: None,
9177 };
9178 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9179 let gone = SeatHistory {
9181 last_ok: Some("zzz".to_owned()),
9182 ..h.clone()
9183 };
9184 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9185 let failed = SeatHistory {
9186 failed: ids(&["a", "b"]),
9187 last_ok: Some("b".to_owned()),
9188 last_fail: None,
9189 };
9190 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9191 }
9192
9193 #[test]
9194 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9195 let first = SeatState::new("review-1", "alpha", 7);
9196 let next = handover_seat("review-1", "gamma", 7);
9197 assert_ne!(first.claude_session, next.claude_session);
9198 }
9199
9200 #[test]
9201 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9202 let mut state = state_with_summary("x", "y");
9203 let a = handover_seat("review-1", "beta", state.next_seat_seed());
9204 let b = handover_seat("review-1", "beta", state.next_seat_seed());
9205 assert_ne!(a.claude_session, b.claude_session);
9206 }
9207
9208 #[test]
9209 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9210 let roster = [spec("a"), spec("b")];
9211 let h = SeatHistory {
9212 failed: ids(&["a", "b"]),
9213 ..SeatHistory::default()
9214 };
9215 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9216 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9217 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9218 }
9219
9220 #[test]
9227 fn failure_signature_ignores_numbers_and_paths() {
9228 assert_eq!(
9229 failure_signature("exited with Some(2) and no usable output"),
9230 failure_signature("exited with Some(137) and no usable output")
9231 );
9232 assert_eq!(
9233 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9234 failure_signature("cannot open /var/x.txt: denied")
9235 );
9236 assert_ne!(failure_signature("boom"), failure_signature("bang"));
9237 }
9238
9239 #[test]
9240 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9241 let other = FailClass::Other("x".into());
9242 assert!(should_hand_over(None, &FailClass::Quota));
9243 assert!(should_hand_over(Some(&other), &FailClass::Quota));
9244 assert!(should_hand_over(
9245 Some(&FailClass::Timeout),
9246 &FailClass::Timeout
9247 ));
9248 assert!(should_hand_over(None, &other));
9249 assert!(!should_hand_over(Some(&other), &other));
9250 assert!(should_hand_over(
9251 Some(&other),
9252 &FailClass::Other("y".into())
9253 ));
9254 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9256 assert!(should_hand_over(Some(&FailClass::Quota), &other));
9257 }
9258
9259 #[test]
9260 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9261 let a = SeatState::new("judge-1", "alpha", 7);
9262 let b = handover_seat("judge-1", "beta", 7);
9263 assert_ne!(a.claude_session, b.claude_session);
9264 assert_eq!(b.turns, 0);
9265 }
9266
9267 #[test]
9268 fn a_timeout_is_classified_apart_from_other_failures() {
9269 assert_eq!(
9270 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9271 Some(FailClass::Timeout)
9272 );
9273 assert!(matches!(
9274 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9275 Some(FailClass::Other(_))
9276 ));
9277 }
9278
9279 #[test]
9280 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9281 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9282 let tried = BTreeSet::from(["beta".to_owned()]);
9283 let next = next_untried_in_roster(&roster, 1, &tried);
9286 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9287 }
9288
9289 #[test]
9290 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9291 let roster = vec![spec("alpha"), spec("beta")];
9292 let tried = BTreeSet::from(["beta".to_owned()]);
9293 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9297 }
9298
9299 #[test]
9300 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9301 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9302 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9303 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9307 }
9308
9309 #[test]
9310 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9311 let roster = vec![spec("a"), spec("a"), spec("b")];
9312 let tried = BTreeSet::from(["a".to_owned()]);
9313 let next = next_untried_in_roster(&roster, 0, &tried);
9314 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9315 }
9316
9317 #[test]
9318 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9319 let roster = vec![spec("a"), spec("b")];
9320 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9321 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9322 }
9323
9324 #[test]
9325 fn remove_if_empty_only_ever_takes_a_bare_directory() {
9326 let dir = tempfile::tempdir().unwrap();
9327 let bay = dir.path().join("ffff");
9328
9329 remove_if_empty(&bay);
9331 assert!(!bay.exists());
9332
9333 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9336 remove_if_empty(&bay);
9337 assert!(bay.exists(), "non-empty directory must survive");
9338
9339 std::fs::remove_dir(bay.join("cand-A")).unwrap();
9341 remove_if_empty(&bay);
9342 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9343 }
9344
9345 #[test]
9354 fn a_full_panel_that_found_nothing_is_clean() {
9355 assert!(round_is_clean(
9356 0,
9357 true,
9358 2,
9359 2,
9360 0,
9361 IncompleteReviewPolicy::Block
9362 ));
9363 }
9364
9365 #[test]
9366 fn a_missing_seat_is_never_clean_under_the_default_policy() {
9367 assert!(!round_is_clean(
9368 0,
9369 true,
9370 1,
9371 2,
9372 0,
9373 IncompleteReviewPolicy::Block
9374 ));
9375 }
9376
9377 #[test]
9378 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9379 assert!(!round_is_clean(
9380 1,
9381 true,
9382 1,
9383 2,
9384 0,
9385 IncompleteReviewPolicy::Warn
9386 ));
9387 }
9388
9389 #[test]
9390 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9391 assert!(round_is_clean(
9392 0,
9393 true,
9394 1,
9395 2,
9396 0,
9397 IncompleteReviewPolicy::Warn
9398 ));
9399 }
9400
9401 #[test]
9402 fn a_full_panel_with_an_open_finding_is_not_clean() {
9403 assert!(!round_is_clean(
9404 1,
9405 true,
9406 2,
9407 2,
9408 0,
9409 IncompleteReviewPolicy::Block
9410 ));
9411 }
9412
9413 #[test]
9414 fn a_full_panel_with_a_red_e2e_is_not_clean() {
9415 assert!(!round_is_clean(
9416 0,
9417 false,
9418 2,
9419 2,
9420 0,
9421 IncompleteReviewPolicy::Block
9422 ));
9423 }
9424
9425 #[test]
9432 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9433 assert!(round_is_clean(
9436 0,
9437 true,
9438 1,
9439 2,
9440 1,
9441 IncompleteReviewPolicy::Block
9442 ));
9443 }
9444
9445 #[test]
9446 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9447 assert!(!round_is_clean(
9450 0,
9451 true,
9452 1,
9453 2,
9454 0,
9455 IncompleteReviewPolicy::Block
9456 ));
9457 }
9458
9459 #[test]
9460 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9461 assert!(!round_is_clean(
9462 1,
9463 true,
9464 1,
9465 2,
9466 1,
9467 IncompleteReviewPolicy::Block
9468 ));
9469 assert!(!round_is_clean(
9470 0,
9471 false,
9472 1,
9473 2,
9474 1,
9475 IncompleteReviewPolicy::Block
9476 ));
9477 }
9478
9479 #[test]
9480 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9481 assert!(!round_is_clean(
9485 0,
9486 true,
9487 0,
9488 2,
9489 2,
9490 IncompleteReviewPolicy::Block
9491 ));
9492 }
9493
9494 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9495 CommandOutcome {
9496 command: "test".to_owned(),
9497 code,
9498 output_tail: String::new(),
9499 duration_ms: 0,
9500 resource_blocked,
9501 }
9502 }
9503
9504 #[test]
9505 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9506 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9507 assert!(
9508 !verify_inconclusive(&[outcome(Some(1), false)]),
9509 "an ordinary failure is still evidence about the patch"
9510 );
9511 assert!(verify_inconclusive(&[outcome(None, true)]));
9512 assert!(
9513 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9514 "one inconclusive outcome taints the whole batch"
9515 );
9516 assert!(!verify_inconclusive(&[]));
9517 }
9518
9519 #[tokio::test]
9520 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9521 let calls = std::sync::atomic::AtomicUsize::new(0);
9525 let started = Instant::now();
9526 wait_for_pids_with(
9527 &[123],
9528 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9529 Duration::from_millis(5),
9530 Duration::from_secs(5),
9531 )
9532 .await;
9533 assert!(
9534 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9535 "must keep checking rather than deciding on the first answer"
9536 );
9537 assert!(
9538 started.elapsed() < Duration::from_secs(1),
9539 "must return the moment it is confirmed dead, not wait out the ceiling"
9540 );
9541 }
9542
9543 #[tokio::test]
9544 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9545 let started = Instant::now();
9546 wait_for_pids_with(
9547 &[123],
9548 |_| true, Duration::from_millis(5),
9550 Duration::from_millis(30),
9551 )
9552 .await;
9553 let elapsed = started.elapsed();
9554 assert!(
9555 elapsed >= Duration::from_millis(30),
9556 "must not give up before its own ceiling: {elapsed:?}"
9557 );
9558 assert!(
9559 elapsed < Duration::from_secs(1),
9560 "must not wait past its own ceiling either: {elapsed:?}"
9561 );
9562 }
9563
9564 #[tokio::test]
9565 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9566 let started = Instant::now();
9567 wait_for_pids_with(
9568 &[],
9569 |_| true,
9570 Duration::from_secs(5),
9571 Duration::from_secs(5),
9572 )
9573 .await;
9574 assert!(
9575 started.elapsed() < Duration::from_millis(200),
9576 "an empty pid list has nothing to confirm"
9577 );
9578 }
9579
9580 fn review_round(
9586 clean: bool,
9587 blocking: usize,
9588 answered: usize,
9589 expected: usize,
9590 progressed: bool,
9591 e2e_ok: bool,
9592 ) -> ReviewRound {
9593 ReviewRound {
9594 round: 1,
9595 head: "h".to_owned(),
9596 verified_head: None,
9597 verified_at: None,
9598 reviews: Vec::new(),
9599 e2e: vec![CommandOutcome {
9600 command: "test".to_owned(),
9601 code: Some(if e2e_ok { 0 } else { 1 }),
9602 output_tail: String::new(),
9603 duration_ms: 0,
9604 resource_blocked: false,
9605 }],
9606 verify_retried: false,
9607 e2e_deferred: false,
9608 e2e_defer_reason: None,
9609 fix: None,
9610 blocking,
9611 answered,
9612 expected,
9613 clean,
9614 progressed,
9615 vote_split: false,
9616 reconsideration: Vec::new(),
9617 verdict: None,
9618 }
9619 }
9620
9621 #[test]
9622 fn review_conclusion_is_none_when_nothing_has_run() {
9623 assert_eq!(review_conclusion(&[], 3), None);
9624 }
9625
9626 #[test]
9627 fn review_conclusion_is_none_while_rounds_remain() {
9628 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9629 assert_eq!(review_conclusion(&rounds, 3), None);
9630 }
9631
9632 #[test]
9633 fn review_conclusion_is_gating_once_a_round_is_clean() {
9634 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9635 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9636 }
9637
9638 #[test]
9639 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9640 let rounds = vec![
9641 review_round(false, 1, 2, 2, true, true),
9642 review_round(false, 1, 2, 2, true, true),
9643 ];
9644 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9645 }
9646
9647 #[test]
9648 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9649 let rounds = vec![
9650 review_round(false, 1, 2, 2, true, true),
9651 review_round(false, 1, 2, 2, true, false),
9652 ];
9653 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9654 }
9655
9656 #[test]
9657 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9658 let mut blocked = review_round(false, 1, 2, 2, true, false);
9665 blocked.e2e[0].resource_blocked = true;
9666 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9667 assert_eq!(review_conclusion(&rounds, 2), None);
9668 }
9669
9670 #[test]
9671 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9672 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9674 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9675 }
9676
9677 #[test]
9678 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9679 let rounds = vec![
9680 review_round(false, 1, 2, 2, false, true),
9681 review_round(false, 1, 2, 2, false, true),
9682 ];
9683 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9684 }
9685
9686 fn secs(n: u64) -> Duration {
9687 Duration::from_secs(n)
9688 }
9689
9690 fn init_repo(dir: &Path) {
9693 let run = |args: &[&str]| {
9694 let out = std::process::Command::new("git")
9695 .args(args)
9696 .current_dir(dir)
9697 .quiet()
9698 .output()
9699 .expect("spawn git");
9700 assert!(
9701 out.status.success(),
9702 "git {args:?} failed: {}",
9703 String::from_utf8_lossy(&out.stderr)
9704 );
9705 };
9706 run(&["init", "-b", "main"]);
9707 run(&["config", "user.name", "magi test"]);
9708 run(&["config", "user.email", "magi@example.com"]);
9709 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9710 run(&["add", "-A"]);
9711 run(&["commit", "-m", "init"]);
9712 }
9713
9714 fn ask_test_home() {
9722 crate::run::pin_test_home();
9723 }
9724
9725 fn runner_at(status: RunStatus) -> Runner {
9728 let mut state = RunState::new(
9729 PathBuf::from("/nonexistent/repo"),
9730 "main".to_owned(),
9731 "deadbeef".to_owned(),
9732 "task".to_owned(),
9733 Config::default(),
9734 );
9735 state.status = status;
9736 Runner {
9737 state,
9738 roles: ResolvedRoles {
9739 implementers: Vec::new(),
9740 judges: Vec::new(),
9741 reviewers: Vec::new(),
9742 fixer: None,
9743 conductor: conductor(),
9744 implementer_roster: Vec::new(),
9745 judge_roster: Vec::new(),
9746 reviewer_roster: Vec::new(),
9747 },
9748 sem: Arc::new(Semaphore::new(1)),
9749 pause: Pause::new(),
9750 interrupt: Pause::new(),
9751 }
9752 }
9753
9754 #[test]
9758 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9759 crate::run::pin_test_home();
9760 let mut runner = runner_at(RunStatus::Implementing);
9761 let interrupt = Pause::new();
9762 runner.watch_interrupt(interrupt.clone());
9763
9764 interrupt.park_because("task a1b2 asked to run first");
9765
9766 assert!(runner.park_here().expect("park_here"));
9767 assert!(runner.state.parked);
9768 let last = runner.state.events.last().expect("a park event");
9769 assert_eq!(last.node, "park");
9770 assert!(
9771 last.message.contains("task a1b2 asked to run first"),
9772 "expected the interrupt reason in {:?}",
9773 last.message
9774 );
9775 }
9776
9777 #[test]
9785 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9786 crate::run::pin_test_home();
9787 let mut runner = runner_at(RunStatus::Implementing);
9788 let shutdown = Pause::new();
9789 runner.on_pause(shutdown.clone());
9790 let interrupt = Pause::new();
9791 runner.watch_interrupt(interrupt.clone());
9792
9793 assert!(!runner.park_here().expect("park_here"));
9795 assert!(!runner.state.parked);
9796
9797 interrupt.park_because("test");
9799 assert!(!shutdown.parked());
9800 assert!(runner.park_here().expect("park_here"));
9801 }
9802
9803 #[tokio::test]
9817 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9818 crate::run::pin_test_home();
9819 let mut runner = runner_at(RunStatus::Implementing);
9820 let interrupt = Pause::new();
9821 runner.watch_interrupt(interrupt.clone());
9822
9823 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9824 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9825
9826 let node = async move {
9830 started_tx.send(()).expect("send started");
9831 finish_rx.await.expect("recv finish");
9832 "node finished"
9833 };
9834
9835 let interrupter = async move {
9836 started_rx.await.expect("recv started");
9837 interrupt.park_because("higher-priority task waiting");
9839 tokio::task::yield_now().await;
9843 finish_tx.send(()).expect("send finish");
9844 };
9845
9846 let (node_result, ()) = tokio::join!(node, interrupter);
9847 assert_eq!(
9848 node_result, "node finished",
9849 "the in-flight call ran to completion"
9850 );
9851
9852 assert!(runner.park_here().expect("park_here"));
9855 assert!(runner.state.parked);
9856 }
9857
9858 #[test]
9864 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9865 crate::run::pin_test_home();
9866 let mut runner = runner_at(RunStatus::Judging);
9867 runner.state.config.agents = vec![conductor()];
9871 runner.state.candidates = vec![Candidate {
9872 index: 0,
9873 label: 'A',
9874 agent: "alpha".to_owned(),
9875 branch: "magi/x/A".to_owned(),
9876 worktree: PathBuf::from("/nonexistent/worktree"),
9877 summary: "did the thing".to_owned(),
9878 stat: "1 file changed".to_owned(),
9879 files: 1,
9880 commits: 1,
9881 empty: false,
9882 failed: None,
9883 verified_noop: None,
9884 duration_ms: 1234,
9885 folded: false,
9886 }];
9887 let run_id = runner.state.id.clone();
9888
9889 let interrupt = Pause::new();
9890 runner.watch_interrupt(interrupt.clone());
9891 interrupt.park_because("task c3d4 asked to run first");
9892 assert!(runner.park_here().expect("park_here"));
9893
9894 let resumed = Runner::resume(&run_id).expect("resume");
9895 assert_eq!(resumed.state.candidates.len(), 1);
9896 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9897 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9898 assert_eq!(resumed.state.status, runner.state.status);
9899 assert!(
9900 resumed.state.parked,
9901 "still parked until `execute` actually walks the graph again"
9902 );
9903 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9904 }
9905
9906 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9908 let mut q = ask::Question::new(
9909 run.to_owned(),
9910 "implement".to_owned(),
9911 "impl-A".to_owned(),
9912 "Which storage backend should the cache use?".to_owned(),
9913 String::new(),
9914 vec!["SQLite".to_owned(), "Redis".to_owned()],
9915 );
9916 store.put(&mut q).unwrap();
9917 q
9918 }
9919
9920 #[test]
9921 fn a_failed_runs_open_question_is_abandoned() {
9922 ask_test_home();
9923 let store = ask::Questions::open();
9924 let mut runner = runner_at(RunStatus::Failed);
9925 let run = runner.state.id.clone();
9926 let q = ask_open_question(&store, &run);
9927
9928 runner.settle_questions();
9929
9930 let back = store.get(&q.id).unwrap();
9931 assert!(
9932 !back.status.open(),
9933 "the seat that asked died with the run; nobody is left to read an answer"
9934 );
9935 assert!(
9936 back.detail.contains(&run) && back.detail.contains("failed"),
9937 "the reason names what the run became, not just that it is gone: {}",
9938 back.detail
9939 );
9940 }
9941
9942 #[test]
9943 fn a_merged_runs_open_question_is_abandoned_too() {
9944 ask_test_home();
9945 let store = ask::Questions::open();
9946 for status in [RunStatus::Merged, RunStatus::Ready] {
9949 let mut runner = runner_at(status);
9950 let run = runner.state.id.clone();
9951 let q = ask_open_question(&store, &run);
9952
9953 runner.settle_questions();
9954
9955 let back = store.get(&q.id).unwrap();
9956 assert!(
9957 !back.status.open(),
9958 "{status:?} run's question must not outlive the run"
9959 );
9960 }
9961 }
9962
9963 #[test]
9964 fn a_still_resumable_runs_open_question_is_left_alone() {
9965 ask_test_home();
9966 let store = ask::Questions::open();
9967 for status in [RunStatus::Blocked, RunStatus::Stalled] {
9973 let mut runner = runner_at(status);
9974 let run = runner.state.id.clone();
9975 let q = ask_open_question(&store, &run);
9976
9977 runner.settle_questions();
9978
9979 let back = store.get(&q.id).unwrap();
9980 assert!(
9981 back.status.open(),
9982 "{status:?} is still alive; the question must still be waiting"
9983 );
9984 }
9985 }
9986
9987 #[test]
9988 fn settle_questions_never_touches_an_already_answered_question() {
9989 ask_test_home();
9990 let store = ask::Questions::open();
9991 let mut runner = runner_at(RunStatus::Failed);
9992 let run = runner.state.id.clone();
9993 let mut q = ask_open_question(&store, &run);
9994 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9995 .unwrap();
9996 store.put(&mut q).unwrap();
9997
9998 runner.settle_questions();
10003 runner.settle_questions();
10004
10005 let back = store.get(&q.id).unwrap();
10006 assert_eq!(
10007 back.status,
10008 ask::QuestionStatus::Answered,
10009 "a real answer is a decision on record, never overwritten by a sweep"
10010 );
10011 }
10012
10013 #[tokio::test]
10024 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10025 crate::run::pin_test_home();
10026 let tmp = tempfile::tempdir().expect("tempdir");
10027 let repo = tmp.path().join("repo");
10028 std::fs::create_dir_all(&repo).unwrap();
10029 init_repo(&repo);
10030
10031 let mut config = Config::default();
10032 config.graph.worktree_root = Some(tmp.path().join("wt"));
10033
10034 let mut state = RunState::new(
10035 repo.clone(),
10036 "main".to_owned(),
10037 "deadbeef".to_owned(),
10038 "task".to_owned(),
10039 config,
10040 );
10041 let root = state.worktree_root();
10042 let wt_a = root.join("cand-A");
10043 let wt_b = root.join("cand-B");
10044 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10045 .await
10046 .expect("worktree A");
10047 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10048 .await
10049 .expect("worktree B");
10050
10051 state.candidates = vec![
10052 Candidate {
10053 index: 0,
10054 label: 'A',
10055 agent: "alpha".to_owned(),
10056 branch: "magi/x/A".to_owned(),
10057 worktree: wt_a.clone(),
10058 summary: String::new(),
10059 stat: String::new(),
10060 files: 0,
10061 commits: 0,
10062 empty: false,
10063 failed: None,
10064 verified_noop: None,
10065 duration_ms: 0,
10066 folded: false,
10067 },
10068 Candidate {
10069 index: 1,
10070 label: 'B',
10071 agent: "beta".to_owned(),
10072 branch: "magi/x/B".to_owned(),
10073 worktree: wt_b.clone(),
10074 summary: String::new(),
10075 stat: String::new(),
10076 files: 0,
10077 commits: 0,
10078 empty: false,
10079 failed: None,
10080 verified_noop: None,
10081 duration_ms: 0,
10082 folded: false,
10083 },
10084 ];
10085 state.tally = Some(Tally {
10086 first_choice: BTreeMap::from([('A', 1)]),
10087 borda: BTreeMap::new(),
10088 winner: 'A',
10089 rankings: 1,
10090 unanimous_initial: true,
10091 deliberated: false,
10092 changed_votes: 0,
10093 unanimous_final: true,
10094 tie_break: None,
10095 judges: 1,
10096 present: 1,
10097 quorum: 1,
10098 met_quorum: true,
10099 uncontested: None,
10100 });
10101 state.status = RunStatus::Ready;
10102
10103 fold_run(&mut state, false, &crate::run::home())
10104 .await
10105 .expect("fold_run");
10106
10107 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10108 assert!(
10109 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10110 "the unmerged winner's branch survives"
10111 );
10112 assert!(
10113 !state.candidates[0].folded,
10114 "the winner is not marked folded"
10115 );
10116
10117 assert!(!wt_b.exists(), "the loser's worktree is removed");
10118 assert!(
10119 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10120 "the loser's branch is removed"
10121 );
10122 assert!(state.candidates[1].folded, "the loser is marked folded");
10123 }
10124
10125 #[tokio::test]
10128 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10129 let tmp = tempfile::tempdir().expect("tempdir");
10130 let repo = tmp.path().join("repo");
10131 std::fs::create_dir_all(&repo).unwrap();
10132 init_repo(&repo);
10133 let home = tmp.path().join("home");
10134
10135 let mut config = Config::default();
10136 config.graph.worktree_root = Some(tmp.path().join("wt"));
10137 let mut state = RunState::new(
10138 repo.clone(),
10139 "main".to_owned(),
10140 "deadbeef".to_owned(),
10141 "task".to_owned(),
10142 config,
10143 );
10144 git::git(&repo, &["branch", "magi/x/A", "main"])
10146 .await
10147 .expect("branch");
10148 state.candidates = vec![Candidate {
10149 index: 0,
10150 label: 'A',
10151 agent: "alpha".to_owned(),
10152 branch: "magi/x/A".to_owned(),
10153 worktree: state.worktree_root().join("cand-A"),
10154 summary: String::new(),
10155 stat: String::new(),
10156 files: 0,
10157 commits: 0,
10158 empty: false,
10159 failed: None,
10160 verified_noop: None,
10161 duration_ms: 0,
10162 folded: true,
10163 }];
10164 state.released_to = Some("20260901-000000-new1".to_owned());
10165 state.released_branches = vec!["magi/x/A".to_owned()];
10166
10167 fold_run(&mut state, true, &home).await.expect("fold_run");
10168
10169 assert!(
10170 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10171 "the handed-over branch survives a fold"
10172 );
10173 }
10174
10175 #[tokio::test]
10179 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10180 let tmp = tempfile::tempdir().expect("tempdir");
10181 let repo = tmp.path().join("repo");
10182 std::fs::create_dir_all(&repo).unwrap();
10183 init_repo(&repo);
10184 let run = |args: &[&str]| {
10185 let out = std::process::Command::new("git")
10186 .quiet()
10187 .args(args)
10188 .current_dir(&repo)
10189 .output()
10190 .expect("spawn git");
10191 assert!(out.status.success(), "git {args:?}");
10192 };
10193 run(&["branch", "magi/x/A"]);
10194 run(&["checkout", "-q", "-b", "magi/x/B"]);
10195 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10196 run(&["add", "-A"]);
10197 run(&["commit", "-q", "-m", "work"]);
10198 run(&["checkout", "-q", "main"]);
10199
10200 let probe = RunState::new(
10204 repo.clone(),
10205 "main".to_owned(),
10206 "deadbeef".to_owned(),
10207 "task".to_owned(),
10208 Config::default(),
10209 );
10210 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10211 let bare = tmp.path().join("origin.git");
10212 let out = std::process::Command::new("git")
10213 .quiet()
10214 .args(["init", "-q", "--bare"])
10215 .arg(&bare)
10216 .output()
10217 .expect("spawn git");
10218 assert!(out.status.success(), "git init --bare");
10219 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10220 run(&["push", "-q", "origin", "main"]);
10221
10222 let mut state = RunState::new(
10223 repo.clone(),
10224 "main".to_owned(),
10225 "deadbeef".to_owned(),
10226 "task".to_owned(),
10227 Config::default(),
10228 );
10229 state.seeds = vec![refs::Seed {
10230 token: "magi/27b2/A".to_owned(),
10231 kind: refs::SeedKind::Unresolved,
10232 sha: String::new(),
10233 branch: true,
10234 detail: "no branch or commit named magi/27b2/A".to_owned(),
10235 }];
10236
10237 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10238 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10239 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10240 let detail = empty_candidate_detail(&state, "main");
10241 assert!(detail.starts_with("empty candidate"), "{detail}");
10242 assert!(detail.contains("magi/27b2/A"), "{detail}");
10243 }
10244
10245 #[tokio::test]
10254 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10255 let tmp = tempfile::tempdir().expect("tempdir");
10256 let repo = tmp.path().join("repo");
10257 std::fs::create_dir_all(&repo).unwrap();
10258 init_repo(&repo);
10259
10260 let mut config = Config::default();
10261 config.merge.mode = MergeMode::Local;
10262
10263 let mut state = RunState::new(
10264 repo.clone(),
10265 "main".to_owned(),
10266 "deadbeef".to_owned(),
10267 "task".to_owned(),
10268 config,
10269 );
10270 state.candidates = vec![Candidate {
10271 index: 0,
10272 label: 'A',
10273 agent: "alpha".to_owned(),
10274 branch: "does-not-exist".to_owned(),
10275 worktree: repo.clone(),
10276 summary: String::new(),
10277 stat: String::new(),
10278 files: 0,
10279 commits: 0,
10280 empty: false,
10281 failed: None,
10282 verified_noop: None,
10283 duration_ms: 0,
10284 folded: false,
10285 }];
10286 state.tally = Some(Tally {
10287 first_choice: BTreeMap::from([('A', 1)]),
10288 borda: BTreeMap::new(),
10289 winner: 'A',
10290 rankings: 1,
10291 unanimous_initial: true,
10292 deliberated: false,
10293 changed_votes: 0,
10294 unanimous_final: true,
10295 tie_break: None,
10296 judges: 0,
10297 present: 0,
10298 quorum: 0,
10299 met_quorum: true,
10300 uncontested: Some("only candidate A produced a change".to_owned()),
10301 });
10302 state.reviews = vec![ReviewRound {
10303 round: 1,
10304 head: "deadbeef".to_owned(),
10305 verified_head: None,
10306 verified_at: None,
10307 reviews: Vec::new(),
10308 e2e: Vec::new(),
10309 fix: None,
10310 blocking: 0,
10311 answered: 0,
10312 expected: 0,
10313 clean: true,
10314 verify_retried: false,
10315 e2e_deferred: false,
10316 e2e_defer_reason: None,
10317 progressed: false,
10318 vote_split: false,
10319 reconsideration: Vec::new(),
10320 verdict: None,
10321 }];
10322 state.gate = vec![CommandOutcome {
10323 command: "test".to_owned(),
10324 code: Some(0),
10325 output_tail: String::new(),
10326 duration_ms: 0,
10327 resource_blocked: false,
10328 }];
10329 state.gate_ran = true;
10330 state.status = RunStatus::Ready;
10335 state.merge = Some(MergeOutcome {
10336 mode: MergeMode::Local,
10337 ok: false,
10338 detail: "already concluded".to_owned(),
10339 empty: false,
10340 });
10341
10342 let mut runner = Runner {
10343 state,
10344 roles: ResolvedRoles {
10345 implementers: Vec::new(),
10346 judges: Vec::new(),
10347 reviewers: Vec::new(),
10348 fixer: None,
10349 conductor: conductor(),
10350 implementer_roster: Vec::new(),
10351 judge_roster: Vec::new(),
10352 reviewer_roster: Vec::new(),
10353 },
10354 sem: Arc::new(Semaphore::new(1)),
10355 pause: Pause::new(),
10356 interrupt: Pause::new(),
10357 };
10358
10359 runner.merge().await.expect("merge");
10360
10361 assert_eq!(
10362 runner.state.status,
10363 RunStatus::Ready,
10364 "a concluded run's status must not change on reentry"
10365 );
10366 assert_eq!(
10367 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10368 Some("already concluded"),
10369 "merge must not run again once the node already recorded an outcome"
10370 );
10371 }
10372
10373 #[tokio::test]
10382 async fn merge_refuses_a_gate_that_has_not_actually_run() {
10383 let tmp = tempfile::tempdir().expect("tempdir");
10384 let repo = tmp.path().join("repo");
10385 std::fs::create_dir_all(&repo).unwrap();
10386 init_repo(&repo);
10387
10388 let mut config = Config::default();
10389 config.merge.mode = MergeMode::Local;
10390
10391 let mut state = RunState::new(
10392 repo.clone(),
10393 "main".to_owned(),
10394 "deadbeef".to_owned(),
10395 "task".to_owned(),
10396 config,
10397 );
10398 state.candidates = vec![Candidate {
10399 index: 0,
10400 label: 'A',
10401 agent: "alpha".to_owned(),
10402 branch: "does-not-exist".to_owned(),
10403 worktree: repo.clone(),
10404 summary: String::new(),
10405 stat: String::new(),
10406 files: 0,
10407 commits: 0,
10408 empty: false,
10409 failed: None,
10410 verified_noop: None,
10411 duration_ms: 0,
10412 folded: false,
10413 }];
10414 state.tally = Some(Tally {
10415 first_choice: BTreeMap::from([('A', 1)]),
10416 borda: BTreeMap::new(),
10417 winner: 'A',
10418 rankings: 1,
10419 unanimous_initial: true,
10420 deliberated: false,
10421 changed_votes: 0,
10422 unanimous_final: true,
10423 tie_break: None,
10424 judges: 0,
10425 present: 0,
10426 quorum: 0,
10427 met_quorum: true,
10428 uncontested: Some("only candidate A produced a change".to_owned()),
10429 });
10430 state.reviews = vec![ReviewRound {
10431 round: 1,
10432 head: "deadbeef".to_owned(),
10433 verified_head: None,
10434 verified_at: None,
10435 reviews: Vec::new(),
10436 e2e: Vec::new(),
10437 fix: None,
10438 blocking: 0,
10439 answered: 0,
10440 expected: 0,
10441 clean: true,
10442 verify_retried: false,
10443 e2e_deferred: false,
10444 e2e_defer_reason: None,
10445 progressed: false,
10446 vote_split: false,
10447 reconsideration: Vec::new(),
10448 verdict: None,
10449 }];
10450 state.gate = Vec::new();
10452 state.gate_ran = false;
10453 state.status = RunStatus::Gating;
10454
10455 let mut runner = Runner {
10456 state,
10457 roles: ResolvedRoles {
10458 implementers: Vec::new(),
10459 judges: Vec::new(),
10460 reviewers: Vec::new(),
10461 fixer: None,
10462 conductor: conductor(),
10463 implementer_roster: Vec::new(),
10464 judge_roster: Vec::new(),
10465 reviewer_roster: Vec::new(),
10466 },
10467 sem: Arc::new(Semaphore::new(1)),
10468 pause: Pause::new(),
10469 interrupt: Pause::new(),
10470 };
10471
10472 runner.merge().await.expect("merge");
10473
10474 assert!(
10475 runner.state.merge.is_none(),
10476 "an empty gate must never be read as a passing one: {:?}",
10477 runner.state.merge
10478 );
10479 }
10480
10481 #[tokio::test]
10488 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10489 let tmp = tempfile::tempdir().expect("tempdir");
10490 let repo = tmp.path().join("repo");
10491 std::fs::create_dir_all(&repo).unwrap();
10492 init_repo(&repo);
10493
10494 let config = Config::default();
10496
10497 let mut state = RunState::new(
10498 repo.clone(),
10499 "main".to_owned(),
10500 "deadbeef".to_owned(),
10501 "task".to_owned(),
10502 config,
10503 );
10504 state.candidates = vec![Candidate {
10505 index: 0,
10506 label: 'A',
10507 agent: "alpha".to_owned(),
10508 branch: "does-not-exist".to_owned(),
10509 worktree: repo.clone(),
10510 summary: String::new(),
10511 stat: String::new(),
10512 files: 0,
10513 commits: 0,
10514 empty: false,
10515 failed: None,
10516 verified_noop: None,
10517 duration_ms: 0,
10518 folded: false,
10519 }];
10520 state.tally = Some(Tally {
10521 first_choice: BTreeMap::from([('A', 1)]),
10522 borda: BTreeMap::new(),
10523 winner: 'A',
10524 rankings: 1,
10525 unanimous_initial: true,
10526 deliberated: false,
10527 changed_votes: 0,
10528 unanimous_final: true,
10529 tie_break: None,
10530 judges: 0,
10531 present: 0,
10532 quorum: 0,
10533 met_quorum: true,
10534 uncontested: Some("only candidate A produced a change".to_owned()),
10535 });
10536 state.reviews = vec![ReviewRound {
10537 round: 1,
10538 head: "deadbeef".to_owned(),
10539 verified_head: None,
10540 verified_at: None,
10541 reviews: Vec::new(),
10542 e2e: Vec::new(),
10543 fix: None,
10544 blocking: 0,
10545 answered: 0,
10546 expected: 0,
10547 clean: true,
10548 verify_retried: false,
10549 e2e_deferred: false,
10550 e2e_defer_reason: None,
10551 progressed: false,
10552 vote_split: false,
10553 reconsideration: Vec::new(),
10554 verdict: None,
10555 }];
10556
10557 let mut runner = Runner {
10558 state,
10559 roles: ResolvedRoles {
10560 implementers: Vec::new(),
10561 judges: Vec::new(),
10562 reviewers: Vec::new(),
10563 fixer: None,
10564 conductor: conductor(),
10565 implementer_roster: Vec::new(),
10566 judge_roster: Vec::new(),
10567 reviewer_roster: Vec::new(),
10568 },
10569 sem: Arc::new(Semaphore::new(1)),
10570 pause: Pause::new(),
10571 interrupt: Pause::new(),
10572 };
10573
10574 runner.gate().await.expect("gate");
10575 assert!(
10576 runner.state.gate_ran,
10577 "zero configured commands is still a real attempt, not an unrun gate"
10578 );
10579 assert!(runner.state.gate.is_empty());
10580 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10581 assert_ne!(
10582 runner.state.status,
10583 RunStatus::Blocked,
10584 "a gate with nothing to check must not read as failed"
10585 );
10586
10587 runner.merge().await.expect("merge");
10588 assert_eq!(
10589 runner.state.status,
10590 RunStatus::Ready,
10591 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10592 );
10593 }
10594
10595 #[tokio::test]
10606 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10607 crate::run::pin_test_home();
10608 let home = crate::run::home();
10609
10610 let tmp = tempfile::tempdir().expect("tempdir");
10611 let repo = tmp.path().join("repo");
10612 std::fs::create_dir_all(&repo).unwrap();
10613 init_repo(&repo);
10614 let cache_dir = tmp.path().join("target");
10617
10618 let mut config = Config::default();
10619 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10620 config.graph.timeout_verify = Some(2);
10623
10624 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10625 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10626 .expect("no io error acquiring directly")
10627 {
10628 crate::cache::AcquireOutcome::Acquired(g) => g,
10629 crate::cache::AcquireOutcome::Busy(b) => {
10630 panic!("expected the direct acquire to win the lease first: {b:?}")
10631 }
10632 };
10633
10634 let mut state = RunState::new(
10635 repo.clone(),
10636 "main".to_owned(),
10637 "deadbeef".to_owned(),
10638 "task".to_owned(),
10639 config,
10640 );
10641 state.candidates = vec![Candidate {
10642 index: 0,
10643 label: 'A',
10644 agent: "alpha".to_owned(),
10645 branch: "does-not-exist".to_owned(),
10646 worktree: repo.clone(),
10647 summary: String::new(),
10648 stat: String::new(),
10649 files: 0,
10650 commits: 0,
10651 empty: false,
10652 failed: None,
10653 verified_noop: None,
10654 duration_ms: 0,
10655 folded: false,
10656 }];
10657 state.tally = Some(Tally {
10658 first_choice: BTreeMap::from([('A', 1)]),
10659 borda: BTreeMap::new(),
10660 winner: 'A',
10661 rankings: 1,
10662 unanimous_initial: true,
10663 deliberated: false,
10664 changed_votes: 0,
10665 unanimous_final: true,
10666 tie_break: None,
10667 judges: 0,
10668 present: 0,
10669 quorum: 0,
10670 met_quorum: true,
10671 uncontested: Some("only candidate A produced a change".to_owned()),
10672 });
10673 state.reviews = vec![ReviewRound {
10674 round: 1,
10675 head: "deadbeef".to_owned(),
10676 verified_head: None,
10677 verified_at: None,
10678 reviews: Vec::new(),
10679 e2e: Vec::new(),
10680 fix: None,
10681 blocking: 0,
10682 answered: 0,
10683 expected: 0,
10684 clean: true,
10685 verify_retried: false,
10686 e2e_deferred: false,
10687 e2e_defer_reason: None,
10688 progressed: false,
10689 vote_split: false,
10690 reconsideration: Vec::new(),
10691 verdict: None,
10692 }];
10693
10694 let mut runner = Runner {
10695 state,
10696 roles: ResolvedRoles {
10697 implementers: Vec::new(),
10698 judges: Vec::new(),
10699 reviewers: Vec::new(),
10700 fixer: None,
10701 conductor: conductor(),
10702 implementer_roster: Vec::new(),
10703 judge_roster: Vec::new(),
10704 reviewer_roster: Vec::new(),
10705 },
10706 sem: Arc::new(Semaphore::new(1)),
10707 pause: Pause::new(),
10708 interrupt: Pause::new(),
10709 };
10710
10711 let started = std::time::Instant::now();
10712 runner.gate().await.expect("gate");
10713 assert!(
10714 started.elapsed() < Duration::from_secs(1),
10715 "a gate with nothing to run must never wait on a lease it never needed"
10716 );
10717 assert!(
10718 runner.state.gate_ran,
10719 "zero commands is still a real, immediate attempt"
10720 );
10721 assert!(runner.state.gate.is_empty());
10722 assert_ne!(
10723 runner.state.status,
10724 RunStatus::Blocked,
10725 "must not read as resource-blocked on a lease it never asked for"
10726 );
10727 }
10728
10729 #[tokio::test]
10739 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10740 crate::run::pin_test_home();
10741
10742 let tmp = tempfile::tempdir().expect("tempdir");
10743 let repo = tmp.path().join("repo");
10744 std::fs::create_dir_all(&repo).unwrap();
10745 init_repo(&repo);
10746
10747 let mut config = Config::default();
10748 config.verify.gate = vec![
10749 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10750 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10751 .to_owned(),
10752 ];
10753
10754 let mut state = RunState::new(
10755 repo.clone(),
10756 "main".to_owned(),
10757 "deadbeef".to_owned(),
10758 "task".to_owned(),
10759 config,
10760 );
10761 let run_id = state.id.clone();
10762 state.candidates = vec![Candidate {
10763 index: 0,
10764 label: 'A',
10765 agent: "alpha".to_owned(),
10766 branch: "does-not-exist".to_owned(),
10767 worktree: repo.clone(),
10768 summary: String::new(),
10769 stat: String::new(),
10770 files: 0,
10771 commits: 0,
10772 empty: false,
10773 failed: None,
10774 verified_noop: None,
10775 duration_ms: 0,
10776 folded: false,
10777 }];
10778 state.tally = Some(Tally {
10779 first_choice: BTreeMap::from([('A', 1)]),
10780 borda: BTreeMap::new(),
10781 winner: 'A',
10782 rankings: 1,
10783 unanimous_initial: true,
10784 deliberated: false,
10785 changed_votes: 0,
10786 unanimous_final: true,
10787 tie_break: None,
10788 judges: 0,
10789 present: 0,
10790 quorum: 0,
10791 met_quorum: true,
10792 uncontested: Some("only candidate A produced a change".to_owned()),
10793 });
10794 state.reviews = vec![ReviewRound {
10795 round: 1,
10796 head: "deadbeef".to_owned(),
10797 verified_head: None,
10798 verified_at: None,
10799 reviews: Vec::new(),
10800 e2e: Vec::new(),
10801 fix: None,
10802 blocking: 0,
10803 answered: 0,
10804 expected: 0,
10805 clean: true,
10806 verify_retried: false,
10807 e2e_deferred: false,
10808 e2e_defer_reason: None,
10809 progressed: false,
10810 vote_split: false,
10811 reconsideration: Vec::new(),
10812 verdict: None,
10813 }];
10814
10815 let mut runner = Runner {
10816 state,
10817 roles: ResolvedRoles {
10818 implementers: Vec::new(),
10819 judges: Vec::new(),
10820 reviewers: Vec::new(),
10821 fixer: None,
10822 conductor: conductor(),
10823 implementer_roster: Vec::new(),
10824 judge_roster: Vec::new(),
10825 reviewer_roster: Vec::new(),
10826 },
10827 sem: Arc::new(Semaphore::new(1)),
10828 pause: Pause::new(),
10829 interrupt: Pause::new(),
10830 };
10831
10832 let started_marker = repo.join("started.marker");
10833 let release_marker = repo.join("release.marker");
10834 let poller = tokio::spawn(async move {
10835 for _ in 0..100 {
10840 if started_marker.exists()
10841 && let Ok(s) = crate::run::RunState::load(&run_id)
10842 && let Some(a) = s.active.get("gate")
10843 {
10844 std::fs::write(&release_marker, b"go").expect("release marker");
10845 return Some(a.clone());
10846 }
10847 tokio::time::sleep(Duration::from_millis(50)).await;
10848 }
10849 None
10850 });
10851
10852 runner.gate().await.expect("gate");
10853 let captured = poller.await.expect("poller task");
10854 let captured = captured.expect(
10855 "the poller never saw a `gate` task entry in run.json while the command was \
10856 still blocked on its own release marker",
10857 );
10858
10859 assert_eq!(captured.task.as_deref(), Some("gate"));
10860 assert_eq!(captured.node, "gate");
10861 assert_eq!(captured.index, Some(1));
10862 assert_eq!(captured.total, Some(1));
10863 assert!(
10864 captured
10865 .command
10866 .as_deref()
10867 .is_some_and(|c| c.contains("started.marker")),
10868 "{captured:?}"
10869 );
10870
10871 assert!(
10872 runner.state.active.is_empty(),
10873 "the entry must be cleared once the command actually finished: {:?}",
10874 runner.state.active
10875 );
10876 assert!(runner.state.gate_ran);
10877 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10878 }
10879
10880 #[tokio::test]
10883 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10884 use crate::verdict::{Finding, ReviewVote, Severity};
10885 crate::run::pin_test_home();
10886 let tmp = tempfile::tempdir().expect("tempdir");
10887 let repo = tmp.path().join("repo");
10888 std::fs::create_dir_all(&repo).unwrap();
10889 init_repo(&repo);
10890
10891 for (severity, vote, expect) in [
10892 (Severity::Major, ReviewVote::Reject, true),
10893 (Severity::Minor, ReviewVote::Reject, false),
10894 (Severity::Major, ReviewVote::Approve, false),
10895 ] {
10896 let mut round = review_round(false, 1, 1, 1, false, true);
10897 round.reviews = vec![ReviewRecord {
10898 reviewer: 1,
10899 agent: "alpha".to_owned(),
10900 summary: String::new(),
10901 findings: vec![Finding {
10902 id: "R1-1-1".to_owned(),
10903 severity,
10904 file: None,
10905 line: None,
10906 title: "t".to_owned(),
10907 detail: String::new(),
10908 }],
10909 vote: Some(vote),
10910 failed: None,
10911 duration_ms: 0,
10912 attempts: 0,
10913 }];
10914 let mut state = RunState::new(
10915 repo.clone(),
10916 "main".to_owned(),
10917 "deadbeef".to_owned(),
10918 "task".to_owned(),
10919 Config::default(),
10920 );
10921 state.reviews = vec![round];
10922 let mut runner = Runner {
10923 state,
10924 roles: ResolvedRoles {
10925 implementers: Vec::new(),
10926 judges: Vec::new(),
10927 reviewers: Vec::new(),
10928 fixer: None,
10929 conductor: conductor(),
10930 implementer_roster: Vec::new(),
10931 judge_roster: Vec::new(),
10932 reviewer_roster: Vec::new(),
10933 },
10934 sem: Arc::new(Semaphore::new(1)),
10935 pause: Pause::new(),
10936 interrupt: Pause::new(),
10937 };
10938 let shell = runner.state.config.shell();
10939 runner
10940 .stop_reviewing("round budget spent", &shell, &repo)
10941 .await
10942 .expect("stop_reviewing");
10943 assert_eq!(runner.state.status, RunStatus::Gating);
10944 assert_eq!(
10945 runner.state.contested_handoff.is_some(),
10946 expect,
10947 "{severity:?} + {vote:?}"
10948 );
10949 }
10950 }
10951
10952 #[tokio::test]
10965 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10966 crate::run::pin_test_home();
10967 let home = crate::run::home();
10968
10969 let tmp = tempfile::tempdir().expect("tempdir");
10970 let repo = tmp.path().join("repo");
10971 std::fs::create_dir_all(&repo).unwrap();
10972 init_repo(&repo);
10973 let head = crate::git::rev_parse(&repo, "HEAD")
10974 .await
10975 .expect("rev-parse");
10976 let cache_dir = tmp.path().join("target");
10979
10980 let mut config = Config::default();
10981 config.verify.e2e = vec![format!(
10982 "CARGO_TARGET_DIR='{}' test -f README.md",
10983 cache_dir.display()
10984 )];
10985 config.graph.review_rounds = 1;
10986 config.graph.timeout_verify = Some(2);
10989
10990 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10991 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10992 .expect("no io error acquiring directly")
10993 {
10994 crate::cache::AcquireOutcome::Acquired(g) => g,
10995 crate::cache::AcquireOutcome::Busy(b) => {
10996 panic!("expected the direct acquire to win the lease first: {b:?}")
10997 }
10998 };
10999
11000 let mut state = RunState::new(
11001 repo.clone(),
11002 "main".to_owned(),
11003 head.clone(),
11004 "task".to_owned(),
11005 config,
11006 );
11007 state.candidates = vec![Candidate {
11008 index: 0,
11009 label: 'A',
11010 agent: "alpha".to_owned(),
11011 branch: "does-not-exist".to_owned(),
11012 worktree: repo.clone(),
11013 summary: String::new(),
11014 stat: String::new(),
11015 files: 0,
11016 commits: 0,
11017 empty: false,
11018 failed: None,
11019 verified_noop: None,
11020 duration_ms: 0,
11021 folded: false,
11022 }];
11023 state.tally = Some(Tally {
11024 first_choice: BTreeMap::from([('A', 1)]),
11025 borda: BTreeMap::new(),
11026 winner: 'A',
11027 rankings: 1,
11028 unanimous_initial: true,
11029 deliberated: false,
11030 changed_votes: 0,
11031 unanimous_final: true,
11032 tie_break: None,
11033 judges: 0,
11034 present: 0,
11035 quorum: 0,
11036 met_quorum: true,
11037 uncontested: Some("only candidate A produced a change".to_owned()),
11038 });
11039 state.reviews = vec![ReviewRound {
11043 round: 1,
11044 head: head.clone(),
11045 verified_head: None,
11046 verified_at: None,
11047 reviews: Vec::new(),
11048 e2e: Vec::new(),
11049 fix: None,
11050 blocking: 1,
11051 answered: 1,
11052 expected: 1,
11053 clean: false,
11054 verify_retried: false,
11055 e2e_deferred: true,
11056 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11057 progressed: false,
11058 vote_split: false,
11059 reconsideration: Vec::new(),
11060 verdict: None,
11061 }];
11062
11063 let mut runner = Runner {
11064 state,
11065 roles: ResolvedRoles {
11066 implementers: Vec::new(),
11067 judges: Vec::new(),
11068 reviewers: Vec::new(),
11069 fixer: None,
11070 conductor: conductor(),
11071 implementer_roster: Vec::new(),
11072 judge_roster: Vec::new(),
11073 reviewer_roster: Vec::new(),
11074 },
11075 sem: Arc::new(Semaphore::new(1)),
11076 pause: Pause::new(),
11077 interrupt: Pause::new(),
11078 };
11079
11080 let shell = runner.state.config.shell();
11081 runner
11082 .stop_reviewing("round budget spent", &shell, &repo)
11083 .await
11084 .expect("stop_reviewing");
11085
11086 let last = runner.state.reviews.last().expect("round record");
11087 assert_eq!(
11088 last.e2e_status(),
11089 E2eStatus::ResourceBlocked,
11090 "the shared cache is still held; the attempt must read as blocked, not deferred or \
11091 failed: {last:?}"
11092 );
11093 assert_eq!(
11094 last.verified_head.as_deref(),
11095 Some(head.as_str()),
11096 "which commit this attempt targeted is known even though nothing finished checking \
11097 it"
11098 );
11099 let first_attempt_at = last
11100 .verified_at
11101 .expect("when this attempt ran is known too");
11102 assert_ne!(
11103 runner.state.status,
11104 RunStatus::Blocked,
11105 "contention is evidence about the machine, not the patch — it must not settle the \
11106 run as blocked: {:?}",
11107 runner.state.status
11108 );
11109 assert!(
11110 !runner
11111 .state
11112 .events
11113 .iter()
11114 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11115 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11116 runner.state.events
11117 );
11118
11119 runner
11124 .stop_reviewing("round budget spent", &shell, &repo)
11125 .await
11126 .expect("stop_reviewing retry");
11127 assert_eq!(
11128 runner.state.reviews.len(),
11129 1,
11130 "no new round was started: {:?}",
11131 runner.state.reviews
11132 );
11133 let last = runner.state.reviews.last().expect("round record");
11134 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11135 assert!(
11136 last.verified_at.expect("still known") > first_attempt_at,
11137 "a second reentry must be a fresh attempt, not a stale copy of the first"
11138 );
11139 assert_ne!(runner.state.status, RunStatus::Blocked);
11140
11141 held.release();
11142 }
11143
11144 #[tokio::test]
11156 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11157 crate::run::pin_test_home();
11158 let home = crate::run::home();
11159
11160 let tmp = tempfile::tempdir().expect("tempdir");
11161 let repo = tmp.path().join("repo");
11162 std::fs::create_dir_all(&repo).unwrap();
11163 init_repo(&repo);
11164 let head = crate::git::rev_parse(&repo, "HEAD")
11165 .await
11166 .expect("rev-parse");
11167 let cache_dir = tmp.path().join("target");
11168
11169 let mut config = Config::default();
11170 config.verify.e2e = vec![format!(
11171 "CARGO_TARGET_DIR='{}' test -f README.md",
11172 cache_dir.display()
11173 )];
11174 config.graph.review_rounds = 1;
11175 config.graph.timeout_verify = Some(2);
11176
11177 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11178 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11179 .expect("no io error acquiring directly")
11180 {
11181 crate::cache::AcquireOutcome::Acquired(g) => g,
11182 crate::cache::AcquireOutcome::Busy(b) => {
11183 panic!("expected the direct acquire to win the lease first: {b:?}")
11184 }
11185 };
11186
11187 let mut state = RunState::new(
11188 repo.clone(),
11189 "main".to_owned(),
11190 head.clone(),
11191 "task".to_owned(),
11192 config,
11193 );
11194 state.candidates = vec![Candidate {
11195 index: 0,
11196 label: 'A',
11197 agent: "alpha".to_owned(),
11198 branch: "does-not-exist".to_owned(),
11199 worktree: repo.clone(),
11200 summary: String::new(),
11201 stat: String::new(),
11202 files: 0,
11203 commits: 0,
11204 empty: false,
11205 failed: None,
11206 verified_noop: None,
11207 duration_ms: 0,
11208 folded: false,
11209 }];
11210 state.tally = Some(Tally {
11211 first_choice: BTreeMap::from([('A', 1)]),
11212 borda: BTreeMap::new(),
11213 winner: 'A',
11214 rankings: 1,
11215 unanimous_initial: true,
11216 deliberated: false,
11217 changed_votes: 0,
11218 unanimous_final: true,
11219 tie_break: None,
11220 judges: 0,
11221 present: 0,
11222 quorum: 0,
11223 met_quorum: true,
11224 uncontested: Some("only candidate A produced a change".to_owned()),
11225 });
11226 state.reviews = vec![ReviewRound {
11230 round: 1,
11231 head: head.clone(),
11232 verified_head: Some(head.clone()),
11233 verified_at: Some(jiff::Timestamp::now()),
11234 reviews: Vec::new(),
11235 e2e: vec![CommandOutcome {
11236 command: format!(
11237 "CARGO_TARGET_DIR='{}' test -f README.md",
11238 cache_dir.display()
11239 ),
11240 code: None,
11241 output_tail: "waiting for the shared build cache".to_owned(),
11242 duration_ms: 0,
11243 resource_blocked: true,
11244 }],
11245 fix: None,
11246 blocking: 1,
11247 answered: 1,
11248 expected: 1,
11249 clean: false,
11250 verify_retried: false,
11251 e2e_deferred: false,
11252 e2e_defer_reason: None,
11253 progressed: false,
11254 vote_split: false,
11255 reconsideration: Vec::new(),
11256 verdict: None,
11257 }];
11258
11259 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11260 let mut runner = Runner {
11261 state,
11262 roles: ResolvedRoles {
11263 implementers: Vec::new(),
11264 judges: Vec::new(),
11265 reviewers: Vec::new(),
11266 fixer: None,
11267 conductor: conductor(),
11268 implementer_roster: Vec::new(),
11269 judge_roster: Vec::new(),
11270 reviewer_roster: Vec::new(),
11271 },
11272 sem: Arc::new(Semaphore::new(1)),
11273 pause: Pause::new(),
11274 interrupt: Pause::new(),
11275 };
11276
11277 runner.review_loop().await.expect("review_loop");
11282
11283 assert_eq!(
11284 runner.state.reviews.len(),
11285 1,
11286 "no new round was started on top of the unresolved one: {:?}",
11287 runner.state.reviews
11288 );
11289 let last = &runner.state.reviews[0];
11290 assert_eq!(
11291 last.e2e_status(),
11292 E2eStatus::ResourceBlocked,
11293 "still contended: {last:?}"
11294 );
11295 assert!(
11296 last.verified_at.expect("still known") > first_attempt_at,
11297 "review_loop must have actually retried the check, not left it exactly as found"
11298 );
11299 assert_ne!(
11300 runner.state.status,
11301 RunStatus::Blocked,
11302 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11303 runner.state.status
11304 );
11305
11306 held.release();
11307 }
11308
11309 #[tokio::test]
11310 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11311 crate::run::pin_test_home();
11312 let tmp = tempfile::tempdir().expect("tempdir");
11313 let repo = tmp.path().join("repo");
11314 std::fs::create_dir_all(&repo).unwrap();
11315 init_repo(&repo);
11316
11317 let mut config = Config::default();
11318 config.merge.mode = MergeMode::Pr;
11319 config.graph.land = true;
11320 config.graph.land_approval = false;
11321
11322 let mut state = RunState::new(
11323 repo.clone(),
11324 "main".to_owned(),
11325 "deadbeef".to_owned(),
11326 "task".to_owned(),
11327 config,
11328 );
11329 state.candidates = vec![Candidate {
11330 index: 0,
11331 label: 'A',
11332 agent: "alpha".to_owned(),
11333 branch: "does-not-exist".to_owned(),
11334 worktree: repo.clone(),
11335 summary: String::new(),
11336 stat: String::new(),
11337 files: 0,
11338 commits: 0,
11339 empty: false,
11340 failed: None,
11341 verified_noop: None,
11342 duration_ms: 0,
11343 folded: false,
11344 }];
11345 state.tally = Some(Tally {
11346 first_choice: BTreeMap::from([('A', 1)]),
11347 borda: BTreeMap::new(),
11348 winner: 'A',
11349 rankings: 1,
11350 unanimous_initial: true,
11351 deliberated: false,
11352 changed_votes: 0,
11353 unanimous_final: true,
11354 tie_break: None,
11355 judges: 0,
11356 present: 0,
11357 quorum: 0,
11358 met_quorum: true,
11359 uncontested: Some("only candidate A produced a change".to_owned()),
11360 });
11361 state.reviews = vec![ReviewRound {
11362 round: 1,
11363 head: "deadbeef".to_owned(),
11364 verified_head: None,
11365 verified_at: None,
11366 reviews: Vec::new(),
11367 e2e: Vec::new(),
11368 fix: None,
11369 blocking: 0,
11370 answered: 0,
11371 expected: 0,
11372 clean: true,
11373 verify_retried: false,
11374 e2e_deferred: false,
11375 e2e_defer_reason: None,
11376 progressed: false,
11377 vote_split: false,
11378 reconsideration: Vec::new(),
11379 verdict: None,
11380 }];
11381 state.gate = vec![CommandOutcome {
11382 command: "test".to_owned(),
11383 code: Some(0),
11384 output_tail: String::new(),
11385 duration_ms: 0,
11386 resource_blocked: false,
11387 }];
11388 state.gate_ran = true;
11389 state.status = RunStatus::Landing;
11393 state.merge = Some(MergeOutcome {
11394 mode: MergeMode::Pr,
11395 ok: true,
11396 detail: "https://example.invalid/x/y/pull/1".to_owned(),
11397 empty: false,
11398 });
11399
11400 ask_test_home();
11404 let store = ask::Questions::open();
11405 let q = ask_open_question(&store, &state.id);
11406
11407 let mut runner = Runner {
11408 state,
11409 roles: ResolvedRoles {
11410 implementers: Vec::new(),
11411 judges: Vec::new(),
11412 reviewers: Vec::new(),
11413 fixer: None,
11414 conductor: conductor(),
11415 implementer_roster: Vec::new(),
11416 judge_roster: Vec::new(),
11417 reviewer_roster: Vec::new(),
11418 },
11419 sem: Arc::new(Semaphore::new(1)),
11420 pause: Pause::new(),
11421 interrupt: Pause::new(),
11422 };
11423
11424 runner.execute().await.expect("execute");
11429
11430 assert_eq!(
11431 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11432 Some("https://example.invalid/x/y/pull/1"),
11433 "reentry must not push again or open a second pull request over the \
11434 one `land` is already watching"
11435 );
11436 assert_ne!(
11437 runner.state.status,
11438 RunStatus::Landing,
11439 "land could not actually reach the fake pull request, so it must \
11440 have given up rather than left the run silently parked forever"
11441 );
11442 assert_eq!(runner.state.status, RunStatus::Blocked);
11446 assert!(
11447 store.get(&q.id).unwrap().status.open(),
11448 "Blocked is still alive; settle_questions must have been a no-op here"
11449 );
11450 }
11451
11452 fn state_with_round(round: ReviewRound) -> RunState {
11453 let mut s = RunState::new(
11454 PathBuf::from("/repo"),
11455 "main".to_owned(),
11456 "abc1234".to_owned(),
11457 "add retries".to_owned(),
11458 Config::default(),
11459 );
11460 s.reviews = vec![round];
11461 s
11462 }
11463
11464 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11465 crate::verdict::Finding {
11466 id: id.to_owned(),
11467 severity,
11468 file: None,
11469 line: None,
11470 title: title.to_owned(),
11471 detail: String::new(),
11472 }
11473 }
11474
11475 #[test]
11476 fn pr_body_names_open_findings_and_declined_ones() {
11477 let round = ReviewRound {
11478 round: 2,
11479 head: "deadbee".to_owned(),
11480 verified_head: None,
11481 verified_at: None,
11482 reviews: vec![ReviewRecord {
11483 attempts: 0,
11484 reviewer: 1,
11485 agent: "alpha".to_owned(),
11486 summary: String::new(),
11487 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11488 vote: None,
11489 failed: None,
11490 duration_ms: 0,
11491 }],
11492 e2e: vec![CommandOutcome {
11493 command: "cargo test".to_owned(),
11494 code: Some(0),
11495 output_tail: String::new(),
11496 duration_ms: 0,
11497 resource_blocked: false,
11498 }],
11499 verify_retried: false,
11500 e2e_deferred: false,
11501 e2e_defer_reason: None,
11502 fix: Some(FixRecord {
11503 agent: "alpha".to_owned(),
11504 addressed: Vec::new(),
11505 rejected: vec![crate::verdict::Rejection {
11506 id: "R1-1-1".to_owned(),
11507 why: "not reachable from any caller".to_owned(),
11508 }],
11509 notes: String::new(),
11510 committed: true,
11511 failed: None,
11512 duration_ms: 0,
11513 continuation: None,
11514 }),
11515 blocking: 0,
11516 answered: 1,
11517 expected: 1,
11518 clean: false,
11519 progressed: true,
11520 vote_split: false,
11521 reconsideration: Vec::new(),
11522 verdict: None,
11523 };
11524 let state = state_with_round(round);
11525 let body = pr_message(&state, 'A').body;
11526
11527 assert!(body.contains("add retries"), "the task must still be there");
11528 assert!(body.contains("R2-1-1"), "{body}");
11529 assert!(body.contains("unused import"), "{body}");
11530 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11531 assert!(
11532 body.contains("not reachable from any caller"),
11533 "the reason it was declined: {body}"
11534 );
11535 }
11536
11537 #[test]
11538 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11539 let round = ReviewRound {
11540 round: 1,
11541 head: "deadbee".to_owned(),
11542 verified_head: None,
11543 verified_at: None,
11544 reviews: vec![ReviewRecord {
11545 attempts: 0,
11546 reviewer: 1,
11547 agent: "alpha".to_owned(),
11548 summary: String::new(),
11549 findings: Vec::new(),
11550 vote: None,
11551 failed: None,
11552 duration_ms: 0,
11553 }],
11554 e2e: Vec::new(),
11555 verify_retried: false,
11556 e2e_deferred: false,
11557 e2e_defer_reason: None,
11558 fix: None,
11559 blocking: 0,
11560 answered: 1,
11561 expected: 1,
11562 clean: true,
11563 progressed: false,
11564 vote_split: false,
11565 reconsideration: Vec::new(),
11566 verdict: None,
11567 };
11568 let state = state_with_round(round);
11569 let body = pr_message(&state, 'A').body;
11570 assert!(!body.contains("Open review findings"), "{body}");
11571 assert!(!body.contains("Declined"), "{body}");
11572 }
11573
11574 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11575 let mut state = RunState::new(
11576 PathBuf::from("/repo"),
11577 "main".to_owned(),
11578 "abc1234".to_owned(),
11579 instruction.to_owned(),
11580 Config::default(),
11581 );
11582 state.candidates.push(Candidate {
11583 index: 0,
11584 label: 'A',
11585 agent: "alpha".to_owned(),
11586 branch: "magi/x/A".to_owned(),
11587 worktree: PathBuf::from("/wt"),
11588 summary: summary.to_owned(),
11589 stat: String::new(),
11590 files: 1,
11591 commits: 1,
11592 empty: false,
11593 failed: None,
11594 verified_noop: None,
11595 folded: false,
11596 duration_ms: 0,
11597 });
11598 state
11599 }
11600
11601 fn review_state(subjects: &[&str]) -> RunState {
11602 let mut state = state_with_summary(
11603 "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",
11604 "",
11605 );
11606 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11607 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11608 state
11609 }
11610
11611 #[tokio::test]
11614 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11615 ask_test_home();
11616 let tmp = tempfile::tempdir().unwrap();
11617 let repo = tmp.path().join("repo");
11618 std::fs::create_dir_all(&repo).unwrap();
11619 init_repo(&repo);
11620 let origin = tmp.path().join("origin.git");
11621 let g = |dir: &Path, args: &[&str]| {
11622 let out = std::process::Command::new("git")
11623 .args(args)
11624 .current_dir(dir)
11625 .quiet()
11626 .output()
11627 .expect("spawn git");
11628 assert!(
11629 out.status.success(),
11630 "git {args:?}: {}",
11631 String::from_utf8_lossy(&out.stderr)
11632 );
11633 };
11634 g(
11635 tmp.path(),
11636 &[
11637 "clone",
11638 "--bare",
11639 "-q",
11640 repo.to_str().unwrap(),
11641 origin.to_str().unwrap(),
11642 ],
11643 );
11644 g(
11645 &repo,
11646 &["remote", "add", "origin", origin.to_str().unwrap()],
11647 );
11648 let c1 = git::rev_parse(&repo, "main").await.unwrap();
11649
11650 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11652 g(&repo, &["add", "-A"]);
11653 g(
11654 &repo,
11655 &["commit", "-q", "-m", "chore(deps): update a crate"],
11656 );
11657 g(&repo, &["push", "-q", "origin", "main"]);
11658 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11659 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11660 g(&repo, &["add", "-A"]);
11661 g(
11662 &repo,
11663 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11664 );
11665 g(&repo, &["checkout", "-q", "main"]);
11666
11667 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11668 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11669 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11671 assert_eq!(fallback, c1);
11672
11673 let mut state = review_state(&[
11674 "chore(deps): update a crate",
11675 "fix(daemon): apply a chosen action",
11676 ]);
11677 state.repo = repo.clone();
11678 state.base_branch = "main".to_owned();
11679 state.base_commit = c1;
11680 refresh_reviewed_commits(&mut state, "feat/own").await;
11681 assert_eq!(
11682 state.reviewed_commits,
11683 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11684 );
11685 assert_eq!(
11686 review_title(&state).as_deref(),
11687 Some("fix(daemon): apply a chosen action")
11688 );
11689 assert_eq!(
11690 leaked_subjects(&state, "feat/own").await,
11691 Some(vec!["chore(deps): update a crate".to_owned()])
11692 );
11693 state.config.merge.remote = "nowhere".to_owned();
11696 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11697 }
11698
11699 #[test]
11700 fn pr_message_review_single_commit_uses_its_subject() {
11701 let state = review_state(&["feat(nats): per-role user"]);
11702 let m = pr_message(&state, 'A');
11703 assert_eq!(m.title, "feat(nats): per-role user");
11704 assert!(!m.body.contains("Review the work already"), "{}", m.body);
11705 assert!(m.body.contains("## Commits under review"), "{}", m.body);
11706 }
11707
11708 #[test]
11709 fn pr_message_review_multi_commit_takes_the_oldest() {
11710 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11711 let m = pr_message(&state, 'A');
11712 assert_eq!(m.title, "feat: the change");
11713 for s in ["feat: the change", "fix: typo", "fix: again"] {
11714 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11715 }
11716 }
11717
11718 #[test]
11719 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11720 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11721 let mut state = review_state(&[first, "fix: later fixup"]);
11722 state.candidates[0].branch = "機能/ログイン".to_owned();
11723 let m = pr_message(&state, 'A');
11724 assert!(
11725 m.title.starts_with("chore: land candidate A of run"),
11726 "{}",
11727 m.title
11728 );
11729 }
11730 }
11731
11732 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11733 BranchFacts {
11734 commits: commits
11735 .iter()
11736 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11737 .collect(),
11738 stat: stat.to_owned(),
11739 }
11740 }
11741
11742 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11743 let mut r = review_round(true, 0, 1, 1, true, true);
11744 r.round = round;
11745 r.fix = notes.map(|n| FixRecord {
11746 agent: "fixer".to_owned(),
11747 addressed: Vec::new(),
11748 rejected: Vec::new(),
11749 notes: n.to_owned(),
11750 committed: true,
11751 failed: None,
11752 duration_ms: 0,
11753 continuation: None,
11754 });
11755 r
11756 }
11757
11758 #[test]
11759 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11760 let state = review_state(&["ignored"]);
11761 let f = facts(
11762 &[
11763 (
11764 "fix(login): resolve PATH on macOS",
11765 "Login shells skip rc files.",
11766 ),
11767 ("fix: address review", ""),
11768 ],
11769 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11770 );
11771 let m = pr_message_with(&state, 'A', Some(&f));
11772 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11773 assert!(
11774 m.body
11775 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
11776 "{}",
11777 m.body
11778 );
11779 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11780 assert!(m.body.contains("## Diff stat"), "{}", m.body);
11781 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11782 for banned in [
11783 "Review the work already",
11784 "no task statement",
11785 "Original task",
11786 ] {
11787 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11788 }
11789 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11790 }
11791
11792 #[test]
11793 fn pr_message_review_truncates_a_huge_first_commit_body() {
11794 let state = review_state(&["ignored"]);
11795 let f = facts(
11796 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11797 "s",
11798 );
11799 let m = pr_message_with(&state, 'A', Some(&f));
11800 assert!(m.body.len() < 30_000, "{}", m.body.len());
11801 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11802 assert!(
11803 m.body.contains("- ... 1 more commit(s)"),
11804 "{}",
11805 m.body.len()
11806 );
11807 assert!(m.body.ends_with("magi:candidate-a\n"));
11808 }
11809
11810 #[test]
11811 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11812 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11813 assert_eq!(m.title, "feat: x");
11814 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11815 assert!(!m.body.contains("Diff stat"), "{}", m.body);
11816 assert!(!m.body.contains("no task statement"), "{}", m.body);
11817 }
11818
11819 #[test]
11820 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11821 let mut state = review_state(&["日本語の件名"]);
11822 state.candidates[0].branch = "fix/macos-login-path".to_owned();
11823 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11824 state.candidates[0].branch = "機能/ログイン".to_owned();
11825 assert!(
11826 pr_message(&state, 'A')
11827 .title
11828 .starts_with("chore: land candidate A")
11829 );
11830 }
11831
11832 #[test]
11833 fn pr_message_review_fixes_survive_a_clean_final_round() {
11834 let mut state = review_state(&["feat: x"]);
11835 state.reviews = vec![
11836 round_with_notes(1, Some("handled the PATH case")),
11837 round_with_notes(2, None),
11838 ];
11839 let body = pr_message(&state, 'A').body;
11840 assert!(
11841 body.contains("## Review fixes\n\nhandled the PATH case\n"),
11842 "{body}"
11843 );
11844 assert!(!body.contains("### Round"), "{body}");
11845
11846 state.reviews = vec![
11847 round_with_notes(1, Some("first fix")),
11848 round_with_notes(2, Some("")),
11849 round_with_notes(3, Some("second fix")),
11850 round_with_notes(4, None),
11851 ];
11852 let body = pr_message(&state, 'A').body;
11853 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11854 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11855 assert!(!body.contains("### Round 2"), "{body}");
11856 }
11857
11858 #[test]
11859 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11860 let state = state_with_summary(
11861 "add retries to the client",
11862 "TITLE: feat: retries\n\nDid it.",
11863 );
11864 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11865 assert_eq!(m.title, "feat: retries");
11866 assert!(
11867 m.body.contains("<summary>Original task</summary>"),
11868 "{}",
11869 m.body
11870 );
11871 assert!(!m.body.contains("Commits under review"), "{}", m.body);
11872 assert!(
11873 m.body
11874 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11875 "{}",
11876 m.body
11877 );
11878 }
11879
11880 #[test]
11881 fn pr_message_review_bounds_a_long_english_subject() {
11882 let long = format!("feat: {}", "word ".repeat(100));
11883 let m = pr_message(&review_state(&[&long]), 'A');
11884 assert!(m.title.starts_with("feat: word"), "{}", m.title);
11885 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11886 }
11887
11888 #[test]
11889 fn pr_message_implementation_run_is_unchanged_by_review_support() {
11890 let state = state_with_summary("add retries\n\ndetails", "- did some things");
11891 let m = pr_message(&state, 'A');
11892 assert_eq!(m.title, "add retries");
11893 assert!(m.body.contains("<summary>Original task</summary>"));
11894 assert!(!m.body.contains("Commits under review"));
11895 assert_eq!(landing_subject_source(&state), state.instruction);
11896 }
11897
11898 #[test]
11899 fn review_run_squash_subject_is_the_change_not_the_prompt() {
11900 let state = review_state(&["feat: the change", "fix: typo"]);
11901 let source = landing_subject_source(&state);
11902 assert_eq!(land::merge_subject("", &source), "feat: the change");
11903 assert_eq!(
11904 land::merge_subject("magi: candidate A (uncommitted work)", &source),
11905 "feat: the change"
11906 );
11907 assert_eq!(
11909 land::merge_subject("feat: renamed by hand", &source),
11910 "feat: renamed by hand"
11911 );
11912 let blank = review_state(&["日本語"]);
11913 assert!(
11914 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11915 );
11916 }
11917
11918 #[test]
11919 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11920 let state = review_state(&["feat: the change"]);
11921 let source = landing_subject_source(&state);
11922 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11923 assert_eq!(
11924 land::merge_subject(landing_title(&state, old), &source),
11925 "feat: the change"
11926 );
11927 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11928 let task = state_with_summary("add retries", "");
11929 assert_eq!(landing_title(&task, old), old);
11930 }
11931
11932 #[test]
11933 fn pr_message_describes_the_change_not_the_task() {
11934 let state = state_with_summary(
11935 "今回やってほしいこと: results projector を直す",
11936 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11937 );
11938 let m = pr_message(&state, 'A');
11939 assert_eq!(m.title, "fix(web): batch the runs list reads");
11940 assert!(
11941 m.body.starts_with("## Summary\n\n- reads run.json once"),
11942 "{}",
11943 m.body
11944 );
11945 assert!(!m.body.contains("TITLE:"), "{}", m.body);
11946 let task_at = m.body.find("今回やってほしいこと").unwrap();
11947 let details_at = m.body.find("<details>").unwrap();
11948 assert!(
11949 details_at < task_at,
11950 "the task lives inside <details>: {}",
11951 m.body
11952 );
11953 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11954 assert!(m.body.contains("magi:candidate-a"));
11955 }
11956
11957 #[test]
11958 fn pr_message_falls_back_to_the_task_without_a_title_line() {
11959 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11960 let m = pr_message(&state, 'A');
11961 assert_eq!(m.title, "add retries");
11962 assert!(
11963 m.body.contains("## Summary\n\n- did some things"),
11964 "{}",
11965 m.body
11966 );
11967
11968 let none = RunState::new(
11969 PathBuf::from("/repo"),
11970 "main".to_owned(),
11971 "abc1234".to_owned(),
11972 "add retries".to_owned(),
11973 Config::default(),
11974 );
11975 let m = pr_message(&none, 'A');
11976 assert_eq!(m.title, "add retries");
11977 assert!(!m.body.contains("## Summary"), "{}", m.body);
11978 }
11979
11980 #[test]
11981 fn pr_message_refuses_the_candidate_commit_subject() {
11982 for bad in [
11983 "TITLE: magi: candidate A (uncommitted work)",
11984 "TITLE: chore: stuff (uncommitted work)",
11985 "TITLE: ",
11986 ] {
11987 let state = state_with_summary("add retries", bad);
11988 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11989 }
11990 }
11991
11992 #[test]
11993 fn pr_message_bounds_a_very_long_task_and_title() {
11994 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11995 let state = state_with_summary(&long, "- nothing");
11996 let m = pr_message(&state, 'A');
11997 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11998 assert!(!m.title.contains('\n'));
11999
12000 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12001 let m = pr_message(&state, 'A');
12002 assert!(m.title.starts_with("feat: "));
12003 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12004 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12005 }
12006
12007 fn long_title_of(instruction: &str) -> String {
12008 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12009 }
12010
12011 #[test]
12012 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12013 let first = "Make the landing path keep a readable title for long tasks";
12014 let line = format!(
12015 "{first}. {}",
12016 "And then keep going with more words ".repeat(20)
12017 );
12018 let t = long_title_of(&line);
12019 assert_eq!(t, first);
12020 assert!(!t.starts_with("chore: land"));
12021 }
12022
12023 #[test]
12024 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12025 let line = "word ".repeat(200);
12026 let t = long_title_of(&line);
12027 assert!(t.ends_with("word..."), "{t}");
12028 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12029 }
12030
12031 #[test]
12032 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12033 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12034 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12035 }
12036 }
12037
12038 #[test]
12039 fn pr_message_title_limit_is_exact() {
12040 let at = "a".repeat(PR_TITLE_MAX);
12041 assert_eq!(long_title_of(&at), at);
12042 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12043 assert!(over.ends_with("..."), "{over}");
12044 assert_eq!(over.chars().count(), PR_TITLE_MAX);
12045 }
12046
12047 #[test]
12048 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12049 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12050 let t = long_title_of(&line);
12051 assert!(t.ends_with("..."), "{t}");
12052 assert!(t.is_ascii(), "{t}");
12053 }
12054
12055 #[test]
12056 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12057 let line = format!(
12058 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12059 "plus more filler words ".repeat(20)
12060 );
12061 let t = long_title_of(&line);
12062 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12063 }
12064
12065 #[test]
12066 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12067 let line = format!("\n\n# {}", "title words ".repeat(40));
12068 let state = state_with_summary(&line, "- nothing");
12069 let m = pr_message(&state, 'A');
12070 assert!(m.title.starts_with("title words"), "{}", m.title);
12071 assert_eq!(
12072 land::merge_subject(&m.title, &landing_subject_source(&state)),
12073 m.title
12074 );
12075 assert_eq!(
12077 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12078 "feat: renamed by hand"
12079 );
12080 }
12081
12082 #[tokio::test]
12083 async fn github_text_rewrite_is_bounded_and_falls_back() {
12084 crate::run::pin_test_home();
12085 for (reply, accepted) in [
12086 (
12087 "TITLE: fix: retries\nAdd retries for failed requests.",
12088 true,
12089 ),
12090 (
12091 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12092 false,
12093 ),
12094 ("TITLE: fix: retries\nUse token=secret", false),
12095 ] {
12096 let dir = tempfile::tempdir().unwrap();
12097 let mut runner = runner_at(RunStatus::Gating);
12098 runner.state = state_with_summary(
12099 "add retries",
12100 "TITLE: fix: retries\n日本語の説明を書きます。",
12101 );
12102 runner.state.repo = dir.path().to_owned();
12103 runner.state.candidates[0].worktree = dir.path().to_owned();
12104 let mut author = spec("alpha");
12105 author.command = vec![
12106 "sh".into(),
12107 "-c".into(),
12108 "printf '%s' \"$REWRITE_REPLY\"".into(),
12109 ];
12110 author.env.insert("REWRITE_REPLY".into(), reply.into());
12111 runner.state.config.agents = vec![author];
12112 let winner = runner.state.candidates[0].clone();
12113 let first = runner
12114 .guarded_pr_message(&winner, None, true)
12115 .await
12116 .unwrap();
12117 let message = if accepted {
12118 first.expect("a passing rewrite never asks")
12119 } else {
12120 assert!(first.is_none());
12122 assert!(runner.state.parked);
12123 let store = ask::Questions::open();
12124 let asked: Vec<_> = store
12125 .list()
12126 .into_iter()
12127 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12128 .collect();
12129 assert_eq!(asked.len(), 1);
12130 assert!(!asked[0].detail.contains("secret"));
12131 store
12134 .update(&asked[0].id, |q| {
12135 q.abandon("test");
12136 Ok(())
12137 })
12138 .unwrap();
12139 let again = runner
12140 .guarded_pr_message(&winner, None, true)
12141 .await
12142 .unwrap()
12143 .expect("an abandoned question falls back");
12144 let n = store
12145 .list()
12146 .into_iter()
12147 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12148 .count();
12149 assert_eq!(n, 1, "asked once per rejected text");
12150 again
12151 };
12152 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12153 assert_eq!(
12154 message.body.contains("Add retries for failed requests."),
12155 accepted
12156 );
12157 assert_eq!(
12158 runner.state.seats["impl-A"].turns, 1,
12159 "only one rewrite invocation"
12160 );
12161 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12162 if !accepted {
12163 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12164 assert!(
12165 message
12166 .body
12167 .contains(&format!("magi:run/{}", runner.state.id))
12168 );
12169 }
12170 }
12171 }
12172
12173 #[tokio::test]
12174 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12175 crate::run::pin_test_home();
12176 let dir = tempfile::tempdir().unwrap();
12177 let mut runner = runner_at(RunStatus::Gating);
12178 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12179 runner.state.repo = dir.path().to_owned();
12180 runner.state.candidates[0].worktree = dir.path().to_owned();
12181 let winner = runner.state.candidates[0].clone();
12182 assert!(
12183 runner
12184 .guarded_pr_message(&winner, None, true)
12185 .await
12186 .unwrap()
12187 .is_none()
12188 );
12189 let store = ask::Questions::open();
12190 let find = |run: &str| {
12191 store
12192 .list()
12193 .into_iter()
12194 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12195 .unwrap()
12196 };
12197 let run = runner.state.id.clone();
12198 let q = find(&run);
12199 assert!(q.cwd.is_none());
12200 assert_eq!(q.choices, ["use fallback", "use my text"]);
12201 let say = |id: &str, text: &str, choice: &str| {
12203 store
12204 .update(id, |q| {
12205 q.thread.push(ask::Turn {
12206 who: ask::Who::Operator,
12207 body: text.to_owned(),
12208 at: jiff::Timestamp::now(),
12209 note: None,
12210 });
12211 q.answer(ask::Answer::Choice(choice.to_owned()))
12212 })
12213 .unwrap();
12214 };
12215 say(&q.id, "まだ日本語", "use my text");
12216 assert!(
12217 runner
12218 .guarded_pr_message(&winner, None, true)
12219 .await
12220 .unwrap()
12221 .is_none()
12222 );
12223 let second = find(&run);
12224 assert_ne!(second.id, q.id);
12225 assert!(second.detail.contains("did not pass"));
12226 say(&second.id, "fix: retry failed requests", "use my text");
12227 let message = runner
12228 .guarded_pr_message(&winner, None, true)
12229 .await
12230 .unwrap()
12231 .expect("a vetted replacement is posted");
12232 assert_eq!(message.title, "fix: retry failed requests");
12233 assert_eq!(
12234 runner
12235 .state
12236 .github_text
12237 .as_ref()
12238 .unwrap()
12239 .chosen_title
12240 .as_deref(),
12241 Some("fix: retry failed requests")
12242 );
12243 assert!(runner.state.github_text.as_ref().unwrap().resolved);
12244 let again = runner
12246 .guarded_pr_message(&winner, None, true)
12247 .await
12248 .unwrap()
12249 .unwrap();
12250 assert_eq!(again.title, message.title);
12251 assert_eq!(
12252 store
12253 .list()
12254 .into_iter()
12255 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12256 .count(),
12257 2
12258 );
12259 }
12260
12261 #[test]
12262 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12263 let mut state = state_with_summary(
12267 "add retries",
12268 "TITLE: fix(web): batch reads\n- reads run.json once",
12269 );
12270 state.config.graph.language = "ja".to_owned();
12271 let m = pr_message(&state, 'A');
12272 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
12273 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
12274
12275 let task = "今回やってほしいこと: results projector を直す";
12278 let mut state = state_with_summary(task, "- no title line");
12279 state.config.graph.language = "ja".to_owned();
12280 let m = pr_message(&state, 'A');
12281 assert_eq!(
12282 m.title,
12283 format!("chore: land candidate A of run {}", state.id)
12284 );
12285 assert!(
12286 m.body.contains(&format!(
12287 "<summary>Original task</summary>\n\n{task}\n\n</details>"
12288 )),
12289 "{}",
12290 m.body
12291 );
12292 }
12293
12294 #[test]
12295 fn pr_message_scrubs_home_paths_and_addresses() {
12296 let state = state_with_summary(
12297 "fix it in /Users/someone/src/x",
12298 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
12299 );
12300 let m = pr_message(&state, 'A');
12301 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
12302 assert!(!m.body.contains(leak), "{}", m.body);
12303 }
12304 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
12305 }
12306
12307 #[test]
12308 fn pr_message_survives_a_task_that_closes_details() {
12309 let state = state_with_summary("a </details> b", "TITLE: fix: x");
12310 let m = pr_message(&state, 'A');
12311 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
12312 }
12313
12314 #[test]
12315 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
12316 let cmd = manual_merge_command(
12317 MergeStyle::Squash,
12318 Path::new("/repo"),
12319 "b",
12320 "fix: \"quoted\" $(x) `y`\n\nbody",
12321 );
12322 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
12323 }
12324
12325 #[test]
12326 fn manual_merge_command_matches_the_configured_style() {
12327 let repo = Path::new("/repo");
12328 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
12329
12330 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
12331 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
12332
12333 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
12334 assert_eq!(
12335 squash,
12336 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
12337 \"Merge magi run 0832 (candidate A)\""
12338 );
12339
12340 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
12341 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
12342 }
12343
12344 #[test]
12345 fn a_nudge_gets_a_quarter_of_the_budget() {
12346 assert_eq!(retry_budget(secs(1200), true), secs(300));
12348 assert_eq!(retry_budget(secs(3600), true), secs(900));
12349 }
12350
12351 #[test]
12352 fn a_resent_prompt_keeps_the_whole_budget() {
12353 assert_eq!(retry_budget(secs(1200), false), secs(1200));
12356 assert_eq!(retry_budget(secs(60), false), secs(60));
12357 }
12358
12359 #[test]
12360 fn the_floor_never_exceeds_the_original_budget() {
12361 assert_eq!(retry_budget(secs(60), true), secs(60));
12365 assert_eq!(retry_budget(secs(480), true), secs(120));
12366 assert_eq!(retry_budget(secs(0), true), secs(0));
12367 }
12368
12369 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12370 agent::CommandEvidence {
12371 id: "item1".to_owned(),
12372 description: "cargo test".to_owned(),
12373 exit_code,
12374 result_summary: String::new(),
12375 source: "codex".to_owned(),
12376 }
12377 }
12378
12379 #[test]
12380 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12381 assert!(!has_unconfirmed_command(&[]));
12385 }
12386
12387 #[test]
12388 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12389 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12393 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12394 assert!(!has_unconfirmed_command(&[
12395 evidence(Some(0)),
12396 evidence(Some(101))
12397 ]));
12398 }
12399
12400 #[test]
12401 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12402 assert!(has_unconfirmed_command(&[
12403 evidence(Some(0)),
12404 evidence(None)
12405 ]));
12406 }
12407
12408 #[test]
12409 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12410 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12411 assert_eq!(
12412 verified_noop_claim(true, &[], text).as_deref(),
12413 Some("already fixed by b32cfc4, on main.")
12414 );
12415 }
12416
12417 #[test]
12418 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12419 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12422 assert!(verified_noop_claim(false, &[], text).is_none());
12423 }
12424
12425 #[test]
12426 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12427 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12428 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12429 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12431 }
12432
12433 #[test]
12434 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12435 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12436 }
12437
12438 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12441 runner.state.candidates = shape
12442 .iter()
12443 .enumerate()
12444 .map(|(i, &(empty, verified))| Candidate {
12445 index: i,
12446 label: (b'A' + i as u8) as char,
12447 agent: "sonnet".to_owned(),
12448 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12449 worktree: PathBuf::from(format!("/wt/{i}")),
12450 summary: String::new(),
12451 stat: String::new(),
12452 files: 0,
12453 commits: 0,
12454 empty,
12455 failed: None,
12456 verified_noop: verified.map(str::to_owned),
12457 duration_ms: 0,
12458 folded: false,
12459 })
12460 .collect();
12461 }
12462
12463 #[test]
12464 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12465 ask_test_home();
12466 let mut runner = runner_at(RunStatus::Implementing);
12467 set_candidates(
12468 &mut runner,
12469 &[
12470 (true, Some("already on main at b32cfc4")),
12471 (true, Some("same fix, see the existing test")),
12472 ],
12473 );
12474
12475 runner
12476 .after_implement()
12477 .expect("a verified no-op is not an error");
12478
12479 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12480 }
12481
12482 #[test]
12483 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12484 ask_test_home();
12485 let mut runner = runner_at(RunStatus::Implementing);
12486 set_candidates(
12490 &mut runner,
12491 &[(true, Some("already on main at b32cfc4")), (true, None)],
12492 );
12493
12494 let err = runner
12495 .after_implement()
12496 .expect_err("an unverified empty candidate must still fail the run");
12497
12498 assert!(
12499 err.to_string().contains("no candidate produced a change"),
12500 "{err}"
12501 );
12502 assert_eq!(runner.state.status, RunStatus::Failed);
12503 }
12504
12505 #[test]
12506 fn after_implement_still_fails_an_ordinary_all_empty_run() {
12507 ask_test_home();
12508 let mut runner = runner_at(RunStatus::Implementing);
12509 set_candidates(&mut runner, &[(true, None), (true, None)]);
12510
12511 let err = runner
12512 .after_implement()
12513 .expect_err("no candidate declared anything; this is an ordinary failure");
12514
12515 assert!(
12516 err.to_string().contains("no candidate produced a change"),
12517 "{err}"
12518 );
12519 assert_eq!(runner.state.status, RunStatus::Failed);
12520 }
12521}