1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35 AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36 ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51 GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52 MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, ParkCause,
53 QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory,
54 Tally, VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58 ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62const OUTPUT_TAIL: usize = 8_000;
64
65const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106const BASE_SYNC_ROUNDS: usize = 4;
119
120const MAX_FIX_CONTINUATIONS: usize = 2;
136
137#[derive(Clone)]
143struct SeatJob {
144 spec: AgentSpec,
145 seat: SeatState,
146 cwd: PathBuf,
147 prompt: String,
148 timeout: Duration,
149 allow_write: bool,
150 sessions: bool,
151 artifacts: PathBuf,
152 stem: String,
153 handover: Option<String>,
158}
159
160enum AgentOutcome {
172 Ok(AgentOutput),
174 Quota(AgentOutput),
176 Dropped(AgentOutput),
179 Failed(String),
181 ResourceBlocked(String),
187}
188
189#[derive(Debug, Clone, Default)]
216pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<ParkCause>>>);
217
218impl Pause {
219 #[must_use]
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 pub fn park(&self) {
227 self.0.store(true, Ordering::SeqCst);
228 }
229
230 pub fn park_because(&self, reason: impl Into<String>) {
236 self.park_for(ParkCause::Interrupt {
237 reason: reason.into(),
238 });
239 }
240
241 pub fn park_for(&self, cause: ParkCause) {
244 let mut reason_guard = self
245 .1
246 .lock()
247 .unwrap_or_else(std::sync::PoisonError::into_inner);
248 if reason_guard.is_none() {
249 *reason_guard = Some(cause);
250 }
251 drop(reason_guard);
252 self.park();
253 }
254
255 #[must_use]
257 pub fn parked(&self) -> bool {
258 self.0.load(Ordering::SeqCst)
259 }
260
261 #[must_use]
263 pub fn reason(&self) -> Option<String> {
264 self.cause().map(|c| c.label())
265 }
266
267 #[must_use]
269 pub fn cause(&self) -> Option<ParkCause> {
270 self.1
271 .lock()
272 .unwrap_or_else(std::sync::PoisonError::into_inner)
273 .clone()
274 }
275}
276
277pub struct Runner {
279 pub state: RunState,
281 roles: ResolvedRoles,
282 sem: Arc<Semaphore>,
283 pause: Pause,
287 interrupt: Pause,
293}
294
295async fn review_base(
300 repo: &Path,
301 remote: &str,
302 base_branch: &str,
303 base_commit: &str,
304 branch: &str,
305) -> String {
306 review_base_checked(repo, remote, base_branch, base_commit, branch)
307 .await
308 .0
309}
310
311async fn review_base_checked(
316 repo: &Path,
317 remote: &str,
318 base_branch: &str,
319 base_commit: &str,
320 branch: &str,
321) -> (String, bool) {
322 let tracking = format!("{remote}/{base_branch}");
323 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
324 if git::rev_exists(repo, &tracking).await
325 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
326 && !mb.is_empty()
327 {
328 return (mb, fresh);
329 }
330 let mb = git::merge_base(repo, base_commit, branch)
331 .await
332 .ok()
333 .filter(|mb| !mb.is_empty())
334 .unwrap_or_else(|| base_commit.to_owned());
335 (mb, false)
336}
337
338pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
342 if !is_review_run(state) {
343 return;
344 }
345 let base = review_base(
346 &state.repo,
347 &state.config.merge.remote,
348 &state.base_branch,
349 &state.base_commit,
350 branch,
351 )
352 .await;
353 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
354 && !subjects.is_empty()
355 && state.reviewed_commits.as_ref() != Some(&subjects)
356 {
357 state.reviewed_commits = Some(subjects);
358 state.save().ok();
359 }
360}
361
362async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
366 let (base, trusted) = review_base_checked(
367 &state.repo,
368 &state.config.merge.remote,
369 &state.base_branch,
370 &state.base_commit,
371 branch,
372 )
373 .await;
374 if !trusted {
375 return None;
376 }
377 git::subjects(&state.repo, &state.base_commit, &base)
378 .await
379 .ok()
380}
381
382pub(crate) fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
386 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
387 if is_leak(computed) {
388 return false;
389 }
390 let cur = current.trim();
391 cur.is_empty()
392 || cur.starts_with(REVIEW_PROMPT_OPENING)
393 || cur.starts_with("chore: land candidate")
394 || cur.starts_with("magi: candidate")
395 || is_leak(cur)
396}
397
398async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
427 let tracking = format!("{remote}/{branch}");
428 let fetched = git::fetch(repo, remote, branch).await;
429 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
430 let local_exists = git::branch_exists(repo, branch).await?;
431 if !fresh {
432 if !local_exists {
433 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
434 }
435 tracing::warn!(
436 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
437 );
438 return Ok(());
439 }
440 let remote_sha = git::rev_parse(repo, &tracking).await?;
441 if !local_exists {
442 git::git(repo, &["branch", branch, &tracking]).await?;
443 return Ok(());
444 }
445 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
446 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
447 return Ok(());
448 }
449 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
450 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
456 .await?
457 {
458 crate::reconcile::Reconciliation::Pushed => {
459 tracing::warn!(
460 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
461 short(&local_sha),
462 short(&remote_sha)
463 );
464 return Ok(());
465 }
466 crate::reconcile::Reconciliation::Placeholder => {}
467 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
468 }
469 }
470 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
471 if !out.ok() {
472 bail!(
473 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
474 short(&local_sha),
475 short(&remote_sha),
476 out.stderr
477 );
478 }
479 tracing::warn!(
480 "local `{branch}` was stale: fast-forwarded {} -> {}",
481 short(&local_sha),
482 short(&remote_sha)
483 );
484 Ok(())
485}
486
487async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
488 let tracking = format!("{remote}/{base_branch}");
489 let fetched = git::fetch(repo, remote, base_branch).await;
490 if let Ok(out) = &fetched
491 && out.ok()
492 && git::rev_exists(repo, &tracking).await
493 {
494 return git::rev_parse(repo, &tracking).await;
495 }
496 let why = match &fetched {
497 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
498 Ok(_) => format!("{remote} has no {base_branch}"),
499 Err(e) => e.to_string(),
500 };
501 bail!(
504 "cannot read {tracking} ({why}); refusing to branch off the local \
505 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
506 base / remote in magi.toml"
507 )
508}
509
510struct FixClaim {
527 path: PathBuf,
528}
529
530impl FixClaim {
531 fn acquire(dir: &Path) -> Result<Self> {
532 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
533 let path = dir.join("fix.lock");
534 match Self::create(&path) {
535 Ok(claim) => Ok(claim),
536 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
537 if Self::reclaim_if_dead(&path) {
538 Self::create(&path).with_context(|| format!("lock {}", path.display()))
539 } else {
540 bail!(
541 "another `magi fix` is already running for this run ({} exists)",
542 path.display()
543 )
544 }
545 }
546 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
547 }
548 }
549
550 fn create(path: &Path) -> std::io::Result<Self> {
551 let mut f = std::fs::OpenOptions::new()
552 .write(true)
553 .create_new(true)
554 .open(path)?;
555 use std::io::Write as _;
556 writeln!(f, "{}", std::process::id())?;
558 Ok(Self {
559 path: path.to_owned(),
560 })
561 }
562
563 fn reclaim_if_dead(path: &Path) -> bool {
567 let dead = std::fs::read_to_string(path)
568 .ok()
569 .and_then(|body| body.trim().parse::<u32>().ok())
570 .is_some_and(|pid| !crate::proc::pid_alive(pid));
571 dead && std::fs::remove_file(path).is_ok()
572 }
573}
574
575impl Drop for FixClaim {
576 fn drop(&mut self) {
577 let _ = std::fs::remove_file(&self.path);
578 }
579}
580
581impl Runner {
582 pub async fn start(
584 repo: &Path,
585 instruction: String,
586 config: Config,
587 origin: Origin,
588 ) -> Result<Self> {
589 Self::start_naming(repo, instruction, "", config, origin).await
590 }
591
592 pub async fn start_naming(
596 repo: &Path,
597 instruction: String,
598 also_scan: &str,
599 config: Config,
600 origin: Origin,
601 ) -> Result<Self> {
602 let repo = git::toplevel(repo).await?;
603 let missing = agent::missing_programs(&config.agents);
604 if !missing.is_empty() {
605 bail!(
606 "these agent programs are not on PATH: {}. Fix the roster in \
607 magi.toml or install them.",
608 missing.join(", ")
609 );
610 }
611 let base_branch =
612 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
613 .await?;
614 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
615 let roles = config.resolve_roles()?;
616 let max_parallel = config.graph.max_parallel.max(1);
617 let seeds = refs::resolve(
620 &repo,
621 &base_commit,
622 &config.merge.remote,
623 &format!("{also_scan}\n{instruction}"),
624 )
625 .await;
626 refs::plan(&repo, &seeds).await?;
627 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
628 state.origin = Some(origin);
632 for seed in &seeds {
633 state.event(
634 "seed",
635 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
636 );
637 }
638 state.seeds = seeds;
639 state.event("start", format!("run {} created", state.id));
640 state.save()?;
641 Ok(Self {
642 state,
643 roles,
644 sem: Arc::new(Semaphore::new(max_parallel)),
645 pause: Pause::new(),
646 interrupt: Pause::new(),
647 })
648 }
649
650 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
664 Self::review_taking_over(repo, branch, config, None, origin).await
665 }
666
667 pub async fn review_taking_over(
674 repo: &Path,
675 branch: &str,
676 config: Config,
677 takeover: Option<crate::handover::Takeover>,
678 origin: Origin,
679 ) -> Result<Self> {
680 let repo = git::toplevel(repo).await?;
681 let missing = agent::missing_programs(&config.agents);
682 if !missing.is_empty() {
683 bail!(
684 "these agent programs are not on PATH: {}. Fix the roster in \
685 magi.toml or install them.",
686 missing.join(", ")
687 );
688 }
689 let base_branch =
690 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
691 .await?;
692 if base_branch == branch {
693 bail!("`{branch}` is the base branch; there is nothing to review against");
694 }
695 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
696
697 let roles = config.resolve_roles()?;
698 let max_parallel = config.graph.max_parallel.max(1);
699 let mut state = RunState::new(
700 repo.clone(),
701 base_branch,
702 base_commit.clone(),
703 String::new(),
704 config,
705 );
706 state.origin = Some(origin);
707
708 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
715 earlier: Vec::new(),
716 home: crate::run::home(),
717 choice: None,
718 task_instruction: None,
719 });
720 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
721 if let Some(released) = &released {
722 state.event(
723 "release",
724 format!(
725 "took `{branch}` over from run {}: its worktree was released: {}",
726 crate::run::short_of(&released.old_id),
727 released.audit
728 ),
729 );
730 }
731 if let Some(choice) = takeover.choice.as_ref()
735 && let Err(e) =
736 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
737 .await
738 {
739 if let Some(released) = &released {
740 released.restore(&repo, branch).await;
741 }
742 return Err(e.context("applying the owner's answer about the diverged branch"));
743 }
744 for id in takeover.earlier.iter().rev() {
750 if let Ok(old) = RunState::load_under(id, &takeover.home)
751 && let Some(head) = old.pr_head.clone()
752 && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
753 {
754 state.pr_head = Some(head);
755 break;
756 }
757 }
758 let opened = Self::open_review(
759 &repo,
760 branch,
761 state,
762 roles,
763 max_parallel,
764 base_commit,
765 &takeover,
766 )
767 .await;
768 if opened.is_err()
769 && let Some(released) = &released
770 {
771 released.restore(&repo, branch).await;
772 }
773 opened
774 }
775
776 async fn inherit(
781 repo: &Path,
782 branch: &str,
783 base_commit: &str,
784 takeover: &crate::handover::Takeover,
785 ) -> Option<crate::recovery::RecoveryRecord> {
786 use crate::recovery::{Relation, assess};
787 let newest = takeover.earlier.last()?;
788 let mut chosen = None;
789 for id in takeover.earlier.iter().rev() {
790 if let Ok(old) = RunState::load_under(id, &takeover.home)
791 && !old.reviews.is_empty()
792 && (old.winner().is_some_and(|w| w.branch == branch)
793 || old.pr_head.as_deref() == Some(branch))
794 {
795 chosen = Some(old);
796 break;
797 }
798 }
799 let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
800 .await
801 .unwrap_or_default();
802 let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
803 Some(last) if last.head == tip => Relation::Same,
804 Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
805 _ => Relation::Diverged,
806 };
807 let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
808 Some(assess(from, chosen.as_ref(), base_commit, relation))
809 }
810
811 async fn open_review(
814 repo: &Path,
815 branch: &str,
816 mut state: RunState,
817 roles: ResolvedRoles,
818 max_parallel: usize,
819 base_commit: String,
820 takeover: &crate::handover::Takeover,
821 ) -> Result<Self> {
822 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
823 let start = review_base(
826 repo,
827 &state.config.merge.remote,
828 &state.base_branch,
829 &base_commit,
830 branch,
831 )
832 .await;
833 let log = git::log_oneline(repo, &start, branch)
834 .await
835 .unwrap_or_default();
836 let log_text = if log.trim().is_empty() {
837 "(no commit messages)"
838 } else {
839 log.trim()
840 };
841 let mut instruction = format!(
842 "Review the work already on branch `{branch}`. There is no task \
843 statement: what the change claims to do is whatever its commits \
844 say.\n\n{log_text}"
845 );
846 if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
850 if let Some(task) = takeover
851 .task_instruction
852 .as_deref()
853 .filter(|t| !t.trim().is_empty())
854 && rec.reviewed_head.is_some()
855 {
856 instruction = format!(
857 "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
858 task.trim()
859 );
860 rec.criteria_inherited = true;
861 }
862 state.event("recovery", rec.summary());
863 state.recovery = Some(rec);
864 }
865 state.instruction = instruction;
866 state.reviewed_commits = Some(
867 git::subjects(repo, &start, branch)
868 .await
869 .unwrap_or_default(),
870 );
871
872 let worktree = state.worktree_root().join("under-review");
875 if let Some(parent) = worktree.parent() {
876 tokio::fs::create_dir_all(parent).await.ok();
877 }
878 let path = worktree.to_string_lossy().to_string();
879 git::git(repo, &["worktree", "add", &path, branch])
880 .await
881 .with_context(|| {
882 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
883 })?;
884
885 let setup = match ensure_setup_config(&mut state, repo).await {
886 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
887 Err(e) => Err(e),
888 };
889 if let Err(e) = setup {
890 git::worktree_remove(repo, &worktree).await.ok();
891 return Err(e);
892 }
893 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
894 .await
895 .unwrap_or(0);
896 if commits == 0 {
897 git::worktree_remove(repo, &worktree).await.ok();
898 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
899 }
900 let files = git::changed_files(&worktree, &base_commit, "HEAD")
901 .await
902 .map(|f| f.len())
903 .unwrap_or(0);
904 if files == 0
905 && let (Ok(head_tree), Ok(base_tree)) = (
906 git::tree_of(&worktree, "HEAD").await,
907 git::tree_of(&worktree, &base_commit).await,
908 )
909 && head_tree == base_tree
910 {
911 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
912 git::worktree_remove(repo, &worktree).await.ok();
913 bail!(
914 "`{branch}` at {} has a tree identical to base {}; this usually means \
915 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
916 against `{}/{branch}`) rather than an empty change",
917 short(&head),
918 short(&base_commit),
919 state.config.merge.remote
920 );
921 }
922 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
923 .await
924 .unwrap_or_default();
925
926 state.candidates.push(Candidate {
927 index: 0,
928 label: 'A',
929 agent: EXISTING_BRANCH.to_owned(),
932 branch: branch.to_owned(),
933 worktree,
934 summary: String::new(),
935 stat,
936 files,
937 commits,
938 empty: false,
939 failed: None,
940 verified_noop: None,
941 duration_ms: 0,
942 folded: false,
943 });
944 state.tally = Some(Tally {
945 first_choice: BTreeMap::from([('A', 0)]),
946 borda: BTreeMap::new(),
947 winner: 'A',
948 rankings: 0,
949 unanimous_initial: false,
950 deliberated: false,
951 changed_votes: 0,
952 unanimous_final: false,
953 tie_break: None,
954 judges: 0,
958 present: 0,
959 quorum: 0,
960 met_quorum: true,
961 uncontested: Some("review-only run: nothing competed".to_owned()),
962 });
963 state.status = RunStatus::Reviewing;
964 state.event(
965 "start",
966 format!(
967 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
968 state.id
969 ),
970 );
971 state.save()?;
972 Ok(Self {
973 state,
974 roles,
975 sem: Arc::new(Semaphore::new(max_parallel)),
976 pause: Pause::new(),
977 interrupt: Pause::new(),
978 })
979 }
980
981 pub fn resume(id: &str) -> Result<Self> {
983 let state = RunState::load(id)?;
984 if let Some(to) = &state.released_to {
985 bail!(
986 "run {} cannot be resumed: its worktree was released to run {}",
987 state.short(),
988 crate::run::short_of(to)
989 );
990 }
991 let roles = state.config.resolve_roles()?;
992 let max_parallel = state.config.graph.max_parallel.max(1);
993 Ok(Self {
994 state,
995 roles,
996 sem: Arc::new(Semaphore::new(max_parallel)),
997 pause: Pause::new(),
998 interrupt: Pause::new(),
999 })
1000 }
1001
1002 pub async fn execute(&mut self) -> Result<()> {
1009 let result = self.execute_graph().await;
1010 self.mark_driver_exited();
1011 let ended = if result.is_err() {
1012 Some(crate::notices::run_stopped(&self.state.id, &self.state))
1013 } else {
1014 crate::notices::run_ended(&self.state)
1015 };
1016 if let Some(notice) = ended {
1017 crate::notices::raise(notice);
1018 }
1019 result
1020 }
1021
1022 fn mark_driver_exited(&mut self) {
1031 self.state.driver_exited = true;
1032 let pid = std::process::id();
1033 let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1034 return;
1035 };
1036 let Ok(mut disk) = RunState::load(&self.state.id) else {
1037 return;
1038 };
1039 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1040 return;
1041 }
1042 disk.driver_exited = true;
1043 if let Err(e) = disk.save() {
1044 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1045 }
1046 }
1047
1048 fn claim_driver(&mut self) -> Result<()> {
1050 let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1051 let disk = RunState::load(&self.state.id)?;
1052 crate::pr_head::ensure_owner(&disk)?;
1053 self.state.parked = false;
1058 self.state.resource_wait = None;
1059 self.state.resource_block = None;
1060 self.state.clear_active();
1067 if let Some(to) = &disk.released_to {
1085 bail!(
1086 "run {} cannot continue: its worktree was released to run {}",
1087 self.state.short(),
1088 crate::run::short_of(to)
1089 );
1090 }
1091 let pid = std::process::id();
1092 self.state.driver_pid = Some(pid);
1093 self.state.driver_started_at = crate::proc::process_started_at(pid);
1094 self.state.driver_exited = false;
1095 self.state.begin_pass();
1096 self.state.save()?;
1097 Ok(())
1098 }
1099
1100 async fn execute_graph(&mut self) -> Result<()> {
1101 self.claim_driver()?;
1102 if self.state.pr_publication_pending {
1105 self.merge().await?;
1106 if self.resource_halt()? {
1107 return Ok(());
1108 }
1109 self.state.save()?;
1110 self.settle_questions();
1111 return Ok(());
1112 }
1113 if self.state.status == RunStatus::Stalled {
1126 if self.recover_stall().await? {
1127 self.finish_after_tally().await?;
1128 } else {
1129 self.state.save()?;
1131 }
1132 return Ok(());
1133 }
1134 if self.state.status == RunStatus::Landing {
1144 self.run_land().await?;
1145 self.settle_questions();
1150 return Ok(());
1151 }
1152 if self.state.github_text.is_some()
1157 && self.state.merge.is_none()
1158 && self.state.status == RunStatus::Gating
1159 {
1160 self.merge().await?;
1161 if self.resource_halt()? {
1162 return Ok(());
1163 }
1164 self.state.save()?;
1165 return Ok(());
1166 }
1167 self.prep().await?;
1168 if self.park_here()? {
1169 return Ok(());
1170 }
1171 self.advise().await?;
1172 if self.park_here()? {
1173 return Ok(());
1174 }
1175 self.implement().await?;
1176 if self.park_here()? {
1177 return Ok(());
1178 }
1179 if self.state.status == RunStatus::VerifiedNoop {
1183 return Ok(());
1184 }
1185 self.judge().await?;
1186 if self.park_here()? {
1187 return Ok(());
1188 }
1189 self.deliberate().await?;
1190 if self.park_here()? {
1191 return Ok(());
1192 }
1193 self.vote().await?;
1194 if self.park_here()? {
1195 return Ok(());
1196 }
1197 self.tally()?;
1198 if self.state.status == RunStatus::Stalled {
1203 self.state.save()?;
1207 return Ok(());
1208 }
1209 self.finish_after_tally().await?;
1210 Ok(())
1211 }
1212
1213 fn stop_for_resource(&mut self) -> Result<()> {
1218 if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1220 {
1221 self.state.status = RunStatus::Blocked;
1222 }
1223 self.state.save()
1224 }
1225
1226 fn resource_halt(&mut self) -> Result<bool> {
1228 if self.state.resource_block.is_none() {
1229 return Ok(false);
1230 }
1231 self.stop_for_resource()?;
1232 Ok(true)
1233 }
1234
1235 fn park_here(&mut self) -> Result<bool> {
1242 let parking = self.pause.parked() || self.interrupt.parked();
1243 if !parking && self.state.resource_block.is_some() {
1244 self.stop_for_resource()?;
1245 return Ok(true);
1246 }
1247 if !self.pause.parked() && !self.interrupt.parked() {
1252 return Ok(false);
1253 }
1254 let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1255 let why = match cause.as_ref().map(ParkCause::label) {
1256 Some(reason) => format!(
1257 "parked after `{}` ({reason}) — resume to carry on from here",
1258 self.state.status.as_str()
1259 ),
1260 None => format!(
1261 "parked after `{}` — resume to carry on from here",
1262 self.state.status.as_str()
1263 ),
1264 };
1265 self.state.event("park", why);
1266 self.state
1267 .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1268 self.state.parked = true;
1269 self.state.save()?;
1270 Ok(true)
1271 }
1272
1273 pub fn on_pause(&mut self, pause: Pause) {
1275 self.pause = pause;
1276 }
1277
1278 pub fn watch_interrupt(&mut self, pause: Pause) {
1284 self.interrupt = pause;
1285 }
1286
1287 fn settle_questions(&mut self) {
1307 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1308 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1309 }
1310 }
1311
1312 async fn finish_after_tally(&mut self) -> Result<()> {
1315 self.graft().await?;
1318 if self.resource_halt()? {
1319 return Ok(());
1320 }
1321 self.fold_losers().await?;
1322 self.sync_to_base().await?;
1327 if self.state.status == RunStatus::AlreadyInBase {
1328 return Ok(());
1329 }
1330 self.review_loop().await?;
1331 if self.resource_halt()? {
1332 return Ok(());
1333 }
1334 self.sync_to_base().await?;
1335 if self.state.status == RunStatus::AlreadyInBase {
1336 return Ok(());
1337 }
1338 self.gate().await?;
1339 self.merge().await?;
1340 if self.resource_halt()? {
1341 return Ok(());
1342 }
1343 self.state.save()?;
1344 Ok(())
1345 }
1346
1347 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1354 let result = match ensure_setup_config(&mut self.state, repo).await {
1355 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1356 Err(e) => Err(e),
1357 };
1358 if let Err(e) = result {
1359 git::worktree_remove(repo, wt).await.ok();
1360 if in_prep {
1361 self.rollback_prep(repo).await;
1362 }
1363 self.state.status = RunStatus::Blocked;
1364 self.state.event(
1365 "setup",
1366 format!("worktree setup failed in {}: {e:#}", wt.display()),
1367 );
1368 self.state.save()?;
1369 return Err(e);
1370 }
1371 Ok(())
1372 }
1373
1374 async fn rollback_prep(&mut self, repo: &Path) {
1376 let root = self.state.worktree_root();
1377 for c in std::mem::take(&mut self.state.candidates) {
1378 git::worktree_remove(repo, &c.worktree).await.ok();
1379 git::branch_delete(repo, &c.branch).await.ok();
1380 }
1381 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1382 for j in 1..=seats {
1383 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1384 .await
1385 .ok();
1386 }
1387 for k in 1..=self.state.config.graph.advisors {
1388 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1389 .await
1390 .ok();
1391 }
1392 }
1393
1394 async fn prep(&mut self) -> Result<()> {
1395 if !self.state.candidates.is_empty() {
1396 return Ok(());
1397 }
1398 self.state.status = RunStatus::Prep;
1399 let repo = self.state.repo.clone();
1400 let base = self.state.base_commit.clone();
1401 let plan = refs::plan(&repo, &self.state.seeds).await?;
1402 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1403 let root = self.state.worktree_root();
1404 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1405
1406 let hooks_dir = self.state.dir().join("hooks");
1409 if self.state.config.blind.commit_msg_hook {
1410 std::fs::create_dir_all(&hooks_dir)
1411 .with_context(|| format!("create {}", hooks_dir.display()))?;
1412 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1413 let path = hooks_dir.join("commit-msg");
1414 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1415 make_executable(&path)?;
1416 git::acquire_worktree_config(&repo).await?;
1424 self.state.enabled_worktree_config = true;
1425 }
1426
1427 ensure_setup_config(&mut self.state, &repo).await?;
1430
1431 for (index, (spec, label)) in self
1432 .roles
1433 .implementers
1434 .clone()
1435 .into_iter()
1436 .zip(labels)
1437 .enumerate()
1438 {
1439 let branch = self.state.branch_for(label);
1440 let worktree = root.join(format!("cand-{label}"));
1441 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1442 if self.state.config.blind.commit_msg_hook {
1443 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1444 }
1445 git::local_exclude(&worktree, "/.magi/").await?;
1446 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1447 git::worktree_remove(&repo, &worktree).await.ok();
1450 git::branch_delete(&repo, &branch).await.ok();
1451 self.rollback_prep(&repo).await;
1452 self.state.status = RunStatus::Blocked;
1453 self.state
1454 .event("prep", format!("worktree setup failed: {e:#}"));
1455 self.state.save()?;
1456 return Err(e);
1457 }
1458 for pick in &plan.picks {
1459 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1460 self.state.status = RunStatus::Blocked;
1461 self.state
1462 .event("prep", format!("cannot apply referenced commit: {e}"));
1463 self.state.save()?;
1464 return Err(e);
1465 }
1466 }
1467 self.state.candidates.push(Candidate {
1468 index,
1469 label,
1470 agent: spec.id.clone(),
1471 branch,
1472 worktree,
1473 summary: String::new(),
1474 stat: String::new(),
1475 files: 0,
1476 commits: 0,
1477 empty: false,
1478 failed: None,
1479 verified_noop: None,
1480 duration_ms: 0,
1481 folded: false,
1482 });
1483 }
1484
1485 for j in 1..=self.roles.judges.len() {
1486 let wt = root.join(format!("judge-{j}"));
1487 if !wt.exists() {
1488 git::worktree_add_detached(&repo, &wt, &base).await?;
1489 self.setup_seat_worktree(&repo, &wt, true).await?;
1490 }
1491 }
1492
1493 if self.state.config.graph.advise {
1501 for k in 1..=self.state.config.graph.advisors {
1502 let wt = root.join(format!("advisor-{k}"));
1503 if !wt.exists() {
1504 git::worktree_add_detached(&repo, &wt, &base).await?;
1505 self.setup_seat_worktree(&repo, &wt, true).await?;
1506 }
1507 }
1508 }
1509
1510 let authors: Vec<&str> = self
1515 .roles
1516 .implementers
1517 .iter()
1518 .map(|a| a.id.as_str())
1519 .collect();
1520 let overlap: Vec<String> = self
1521 .roles
1522 .judges
1523 .iter()
1524 .enumerate()
1525 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1526 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1527 .collect();
1528 if !overlap.is_empty() {
1529 let note = format!(
1530 "{} also authored a candidate; blind, but the panel is less \
1531 independent than {} distinct agents would be",
1532 overlap.join(", "),
1533 self.roles.judges.len()
1534 );
1535 self.state.event("prep", note);
1536 }
1537
1538 self.state.event(
1539 "prep",
1540 format!(
1541 "{} candidates, {} judges, base {} ({})",
1542 self.state.candidates.len(),
1543 self.roles.judges.len(),
1544 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1545 self.state.base_branch
1546 ),
1547 );
1548 self.state.status = RunStatus::Implementing;
1549 self.state.save()?;
1550 Ok(())
1551 }
1552
1553 async fn advise(&mut self) -> Result<()> {
1586 let implement_untouched = self
1587 .state
1588 .candidates
1589 .iter()
1590 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1591 if !self.state.config.graph.advise || self.state.advise_attempted {
1592 return Ok(());
1593 }
1594 if !implement_untouched {
1595 self.state.event(
1596 "advise",
1597 "skipping the design-deliberation stage: at least one \
1598 candidate already shows implementation progress, so this \
1599 run is past the point the stage exists to run before"
1600 .to_owned(),
1601 );
1602 self.state.advise_attempted = true;
1603 self.state.save()?;
1604 return Ok(());
1605 }
1606 let run_id = self.state.id.clone();
1607 let prompts = self.state.config.prompts.clone();
1608 let instruction = self.state.instruction.clone();
1609 let language = self.state.config.graph.language.clone();
1610 let root = self.state.worktree_root();
1611 let n = self.state.config.graph.advisors;
1612 let where_recorded = self.state.dir().join("run.json");
1613
1614 let seats = match self.state.config.advisors() {
1615 Ok(seats) if !seats.is_empty() => seats,
1616 Ok(_) => {
1617 self.state.event(
1618 "advise",
1619 format!(
1620 "[graph] advisors is 0; skipping the design-deliberation \
1621 stage and continuing without a synthesis brief (see {})",
1622 where_recorded.display()
1623 ),
1624 );
1625 self.state.advise_attempted = true;
1626 self.state.save()?;
1627 return Ok(());
1628 }
1629 Err(e) => {
1630 self.state.event(
1631 "advise",
1632 format!(
1633 "could not resolve advisor seats ({e:#}); continuing \
1634 without a design-deliberation brief (see {})",
1635 where_recorded.display()
1636 ),
1637 );
1638 self.state.advise_attempted = true;
1639 self.state.save()?;
1640 return Ok(());
1641 }
1642 };
1643
1644 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1645 let artifacts = agent::artifacts_dir(&self.state.dir());
1646 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1647
1648 let mut jobs = Vec::new();
1649 for (i, spec) in seats.iter().cloned().enumerate() {
1650 let seat_key = format!("advisor-{}", i + 1);
1651 let seat = self.seat(&seat_key, &spec.id);
1652 jobs.push(SeatJob {
1653 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1654 spec,
1655 seat,
1656 cwd: worktrees[i % worktrees.len()].clone(),
1657 timeout,
1658 allow_write: false,
1659 sessions: false,
1660 artifacts: artifacts.clone(),
1661 stem: seat_key,
1662 handover: None,
1663 });
1664 }
1665
1666 self.state.event(
1667 "advise",
1668 format!(
1669 "{} advisor seat(s) sketching a design in parallel",
1670 jobs.len()
1671 ),
1672 );
1673 let mut quota_losses = Vec::new();
1674 let cache = self.state.config.cache_dir();
1675 let ctx = WaveCtx {
1676 carry_seats: false,
1677 pauses: Some((&self.pause, &self.interrupt)),
1678 run: &run_id,
1679 node: "advise",
1680 prompts: &prompts,
1681 cache: cache.as_deref(),
1682 round: None,
1683 };
1684 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1685 let results = ask_json_wave::<Proposal>(
1686 jobs,
1687 Arc::clone(&self.sem),
1688 self.state.config.graph.retries,
1689 &advisor_roster,
1690 &ctx,
1691 &mut quota_losses,
1692 &mut self.state,
1693 &|p: &Proposal| p.validate(),
1694 )
1695 .await;
1696 if self.state.resource_block.is_some() {
1697 return self.stop_for_resource();
1698 }
1699 self.state.quota.extend(quota_losses);
1700
1701 let mut records = Vec::with_capacity(results.len());
1702 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1703 let agent_id = seat.agent.clone();
1704 self.state.seats.insert(seat.key.clone(), seat);
1705 match res {
1706 Ok((proposal, out)) => {
1707 self.state
1708 .event("advise", format!("advisor-{} proposed a design", i + 1));
1709 records.push(advise::AdvisorRecord::proposed(
1710 i + 1,
1711 agent_id,
1712 proposal,
1713 out.duration_ms,
1714 ));
1715 }
1716 Err(e) => {
1717 self.state.event(
1718 "advise",
1719 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1720 );
1721 records.push(advise::AdvisorRecord::failed(
1722 i + 1,
1723 agent_id,
1724 e.to_string(),
1725 ));
1726 }
1727 }
1728 }
1729
1730 let mut advice = advise::Advice {
1731 records,
1732 synthesis: None,
1733 };
1734 if advice.proposals().is_empty() {
1735 self.state.event(
1736 "advise",
1737 "no advisor produced a usable proposal; continuing without a \
1738 synthesis brief"
1739 .to_owned(),
1740 );
1741 } else {
1742 match self
1743 .synthesize_brief(
1744 &advice,
1745 &instruction,
1746 &language,
1747 &worktrees[0],
1748 &artifacts,
1749 &run_id,
1750 &prompts,
1751 cache.as_deref(),
1752 )
1753 .await
1754 {
1755 Ok(Some(text)) => {
1756 self.state.event(
1757 "advise",
1758 "synthesized a design brief for the implementer".to_owned(),
1759 );
1760 advice.synthesis = Some(text);
1761 }
1762 Ok(None) => {
1763 self.state.event(
1764 "advise",
1765 "the synthesis seat produced nothing usable; continuing \
1766 without a design brief"
1767 .to_owned(),
1768 );
1769 }
1770 Err(e) => {
1771 self.state.event(
1772 "advise",
1773 format!("could not synthesize a design brief: {e:#}"),
1774 );
1775 }
1776 }
1777 }
1778 advise::apply_reflection(&mut advice);
1779
1780 self.state.advice = Some(advice);
1781 self.state.advise_attempted = true;
1782 self.state.save()?;
1783 Ok(())
1784 }
1785
1786 #[allow(clippy::too_many_arguments)]
1798 async fn synthesize_brief(
1799 &mut self,
1800 advice: &advise::Advice,
1801 instruction: &str,
1802 language: &str,
1803 cwd: &Path,
1804 artifacts: &Path,
1805 run_id: &str,
1806 prompts: &Prompts,
1807 cache: Option<&Path>,
1808 ) -> Result<Option<String>> {
1809 let chain = agent::pick_chain(
1810 &self.state.config.agents,
1811 self.state.config.roles.synthesizer.as_ref(),
1812 &agent::installed,
1813 "synthesizer",
1814 )?;
1815 let proposals = advice.proposals();
1816 let mut prompt = prompt::with_overlay(
1817 prompt::synthesize_brief(instruction, &proposals, language),
1818 prompts.overlay("advise"),
1819 );
1820 if cache.is_some() {
1821 prompt.push('\n');
1826 prompt.push_str(&prompt::build_cache_note("advise", false));
1827 }
1828 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1829 let mut last = None;
1834 for (n, spec) in chain.iter().enumerate() {
1835 if n > 0 {
1836 self.state
1837 .event("advise", format!("synthesis falling back to {}", spec.id));
1838 }
1839 let mut seat = self.seat("advise-synthesis", &spec.id);
1840 let outcome = agent::invoke(
1841 spec,
1842 &mut seat,
1843 &Invocation {
1844 cwd,
1845 prompt: &prompt,
1846 timeout,
1847 allow_write: false,
1848 unsandboxed: false,
1849 sessions: false,
1850 artifacts,
1851 stem: &if n == 0 {
1852 "advise-synthesis".to_owned()
1853 } else {
1854 format!("advise-synthesis-{}", spec.id)
1855 },
1856 run: run_id,
1857 node: "advise",
1858 cache_dir: None,
1859 attachments: &[],
1860 writable: &[],
1861 },
1862 )
1863 .await;
1864 if outcome.is_ok() {
1865 self.state.seats.insert(seat.key.clone(), seat);
1866 }
1867 let advance = agent::chain_advances(&outcome);
1868 last = Some(outcome);
1869 if !advance {
1870 break;
1871 }
1872 }
1873 let out = last.expect("a chain holds at least one agent")?;
1876 if !out.usable() {
1877 return Ok(None);
1878 }
1879 let text =
1880 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1881 Ok((!text.trim().is_empty()).then_some(text))
1882 }
1883
1884 async fn implement(&mut self) -> Result<()> {
1887 let run_id = self.state.id.clone();
1892 let prompts = self.state.config.prompts.clone();
1893 let todo: Vec<usize> = self
1894 .state
1895 .candidates
1896 .iter()
1897 .enumerate()
1898 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1899 .map(|(i, _)| i)
1900 .collect();
1901 if todo.is_empty() {
1902 return self.after_implement();
1903 }
1904 self.state.status = RunStatus::Implementing;
1905
1906 let language = self.state.config.graph.language.clone();
1907 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1908 let sessions = self.state.config.graph.sessions;
1909 let artifacts = agent::artifacts_dir(&self.state.dir());
1910 let brief = self
1914 .state
1915 .advice
1916 .as_ref()
1917 .and_then(|a| a.synthesis.as_deref())
1918 .map(str::to_owned);
1919 let attachments = self.state.attachments.clone();
1920
1921 let mut jobs = Vec::new();
1922 for &i in &todo {
1923 let (index, label, worktree) = {
1924 let c = &self.state.candidates[i];
1925 (c.index, c.label, c.worktree.clone())
1926 };
1927 let spec = self.roles.implementers[index].clone();
1928 let seat_key = format!("impl-{label}");
1929 let seat = self.seat(&seat_key, &spec.id);
1930 let instruction = seeded_instruction(&self.state);
1931 jobs.push(SeatJob {
1932 spec,
1933 seat,
1934 prompt: prompt::implement(
1935 &instruction,
1936 &worktree.to_string_lossy(),
1937 &language,
1938 brief.as_deref(),
1939 &attachments,
1940 ),
1941 cwd: worktree,
1942 timeout,
1943 allow_write: true,
1944 sessions,
1945 artifacts: artifacts.clone(),
1946 stem: format!("impl-{label}"),
1947 handover: None,
1948 });
1949 }
1950
1951 self.state.event(
1952 "implement",
1953 format!("{} candidates in parallel", jobs.len()),
1954 );
1955 let mut sent = jobs.clone();
1961 let cache = self.state.config.cache_dir();
1962 let ctx = WaveCtx {
1963 carry_seats: false,
1964 pauses: Some((&self.pause, &self.interrupt)),
1965 run: &run_id,
1966 node: "implement",
1967 prompts: &prompts,
1968 cache: cache.as_deref(),
1969 round: None,
1970 };
1971 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1972 if wave_blocked(&results) {
1973 return self.stop_for_resource();
1974 }
1975 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1976 .await;
1977 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1978 .await;
1979 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1980 .await;
1981 if self.state.resource_block.is_some() {
1982 return self.stop_for_resource();
1983 }
1984
1985 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1986 let seat_key = seat.key.clone();
1987 let agent = seat.agent.clone();
1996 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1997 self.state.seats.insert(seat.key.clone(), seat);
1998 let label = self.state.candidates[i].label;
1999 let worktree = self.state.candidates[i].worktree.clone();
2000 let base = self.state.base_commit.clone();
2001
2002 let (summary, duration, failed, verified_claim) = match out {
2003 AgentOutcome::Ok(o) => {
2004 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2005 let failed = (!o.usable()).then(|| {
2006 if o.timed_out {
2007 "agent timed out".to_owned()
2008 } else {
2009 format!("agent exited with {:?}", o.exit_code)
2010 }
2011 });
2012 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2013 (text, o.duration_ms, failed, verified_claim)
2014 }
2015 AgentOutcome::Dropped(o) => {
2021 let why = o
2022 .dropped
2023 .as_ref()
2024 .map(|d| d.why.as_str())
2025 .unwrap_or("the CLI ended the stream without delivering its answer");
2026 (
2027 String::new(),
2028 o.duration_ms,
2029 Some(format!("the CLI dropped the stream ({why})")),
2030 None,
2031 )
2032 }
2033 AgentOutcome::Quota(o) => {
2034 self.state.quota.push(QuotaLoss {
2035 seat: seat_key,
2036 node: "implement".to_owned(),
2037 at: Timestamp::now(),
2038 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2039 });
2040 (
2041 String::new(),
2042 o.duration_ms,
2043 Some("rate limited (quota); produced no change".to_owned()),
2044 None,
2045 )
2046 }
2047 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2048 (String::new(), 0, Some(e), None)
2049 }
2050 };
2051
2052 let rescued = match git::rescue_commit(
2055 &worktree,
2056 &format!("magi: candidate {label} (uncommitted work)"),
2057 )
2058 .await
2059 {
2060 Ok(r) => {
2061 self.state.note_withheld("implement", &r.withheld);
2062 r.committed
2063 }
2064 Err(_) => false,
2065 };
2066 let commits = git::commits_ahead(&worktree, &base, "HEAD")
2067 .await
2068 .unwrap_or(0);
2069 let patch = git::diff(&worktree, &base, "HEAD")
2070 .await
2071 .unwrap_or_default();
2072 let stat = git::diff_stat(&worktree, &base, "HEAD")
2073 .await
2074 .unwrap_or_default();
2075 let files = git::changed_files(&worktree, &base, "HEAD")
2076 .await
2077 .map(|f| f.len())
2078 .unwrap_or(0);
2079 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2080
2081 let c = &mut self.state.candidates[i];
2082 if !exhausted_the_fallback_chain {
2083 c.agent = agent;
2084 }
2085 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2086 c.stat = stat;
2087 c.files = files;
2088 c.commits = commits;
2089 c.duration_ms = duration;
2090 c.empty = commits == 0 || patch.trim().is_empty();
2091 c.failed = match failed {
2094 Some(_) if c.empty => failed,
2095 _ => None,
2096 };
2097 c.verified_noop = if c.empty { verified_claim } else { None };
2102 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2103 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2104 (None, true, Some(_), _) => {
2105 format!("candidate {label}: no change produced (agent-verified no-op)")
2106 }
2107 (None, true, None, _) => format!("candidate {label}: no change produced"),
2108 (None, false, _, true) => {
2109 format!(
2110 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2111 )
2112 }
2113 (None, false, _, false) => {
2114 format!("candidate {label}: {files} files, {commits} commits")
2115 }
2116 };
2117 self.state.event("implement", note);
2118 self.state.save()?;
2119 }
2120
2121 self.after_implement()
2122 }
2123
2124 async fn resume_undelivered(
2152 &mut self,
2153 results: &mut [(usize, SeatState, AgentOutcome)],
2154 sent: &[SeatJob],
2155 prompts: &Prompts,
2156 run_id: &str,
2157 ) {
2158 for (wi, seat, out) in results.iter_mut() {
2159 let Some(dropped) = (match &*out {
2160 AgentOutcome::Dropped(o) => o.dropped.clone(),
2161 _ => None,
2162 }) else {
2163 continue;
2164 };
2165 let Some(job) = sent.get(*wi) else { continue };
2166 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2168 self.state.event(
2169 "implement",
2170 format!(
2171 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2172 work is in the tree",
2173 seat.key, dropped.output_tokens, dropped.why
2174 ),
2175 );
2176 continue;
2177 }
2178 if !has_context(&job.spec, seat, job.sessions) {
2186 self.state.event(
2187 "implement",
2188 format!(
2189 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2190 is no session left to resume",
2191 seat.key, dropped.output_tokens, dropped.why
2192 ),
2193 );
2194 continue;
2195 }
2196 self.state.event(
2197 "implement",
2198 format!(
2199 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2200 conversation",
2201 seat.key, dropped.output_tokens, dropped.why
2202 ),
2203 );
2204 let mut retry = job.clone();
2205 retry.seat = seat.clone();
2206 retry.prompt = prompt::resume_after_drop(&dropped.why);
2207 retry.timeout = retry_budget(job.timeout, true);
2208 retry.stem = format!("{}-resume", job.stem);
2209 let cache = self.state.config.cache_dir();
2210 let ctx = WaveCtx {
2211 carry_seats: false,
2212 pauses: Some((&self.pause, &self.interrupt)),
2213 run: run_id,
2214 node: "implement",
2215 prompts,
2216 cache: cache.as_deref(),
2217 round: None,
2218 };
2219 let (resumed_seat, resumed) =
2220 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2221 *seat = resumed_seat;
2222 *out = resumed;
2223 }
2224 }
2225
2226 async fn resume_seat_handovers(
2291 &mut self,
2292 results: &mut [(usize, SeatState, AgentOutcome)],
2293 sent: &mut [SeatJob],
2294 prompts: &Prompts,
2295 run_id: &str,
2296 ) {
2297 let instruction = seeded_instruction(&self.state);
2298 let language = self.state.config.graph.language.clone();
2299 let brief = self
2300 .state
2301 .advice
2302 .as_ref()
2303 .and_then(|a| a.synthesis.as_deref())
2304 .map(str::to_owned);
2305 let attachments = self.state.attachments.clone();
2306 for (wi, seat, out) in results.iter_mut() {
2307 let others: BTreeSet<String> = sent
2310 .iter()
2311 .enumerate()
2312 .filter(|(j, _)| j != wi)
2313 .map(|(_, j)| j.spec.id.clone())
2314 .collect();
2315 let Some(job) = sent.get_mut(*wi) else {
2316 continue;
2317 };
2318 let start = self
2323 .roles
2324 .implementer_roster
2325 .iter()
2326 .position(|s| s.id == job.spec.id)
2327 .unwrap_or(0);
2328 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2329 let mut fallback_attempt = 0usize;
2330 let mut prev: Option<FailClass> = None;
2331 while let Some(cur) = FailClass::of(&*out) {
2332 if !should_hand_over(prev.as_ref(), &cur) {
2333 break;
2334 }
2335 let Some(next) =
2336 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2337 .cloned()
2338 else {
2339 break;
2340 };
2341 tried.insert(next.id.clone());
2342 fallback_attempt += 1;
2343
2344 if let Ok(r) = git::rescue_commit(
2345 &job.cwd,
2346 &format!(
2347 "magi: candidate {} (uncommitted work before {} fallback)",
2348 seat.key,
2349 if cur == FailClass::Quota {
2350 "quota"
2351 } else {
2352 "handover"
2353 }
2354 ),
2355 )
2356 .await
2357 {
2358 self.state.note_withheld("implement", &r.withheld);
2359 }
2360
2361 record_handover(
2362 &mut self.state,
2363 "implement",
2364 &seat.key,
2365 &seat.agent,
2366 &next.id,
2367 &cur,
2368 &fail_reason(&*out),
2369 );
2370 prev = Some(cur.clone());
2371
2372 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2373 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2374 job.spec = next.clone();
2382 let mut retry = job.clone();
2383 retry.seat = new_seat;
2384 retry.prompt = prompt::implement(
2385 &instruction,
2386 &job.cwd.to_string_lossy(),
2387 &language,
2388 brief.as_deref(),
2389 &attachments,
2390 );
2391 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2392 let cache = self.state.config.cache_dir();
2393 let ctx = WaveCtx {
2394 carry_seats: false,
2395 pauses: Some((&self.pause, &self.interrupt)),
2396 run: run_id,
2397 node: "implement",
2398 prompts,
2399 cache: cache.as_deref(),
2400 round: None,
2401 };
2402 let (fallback_seat, fallback_out) = run_one(
2403 retry,
2404 Arc::clone(&self.sem),
2405 &ctx,
2406 &mut self.state,
2407 fallback_attempt,
2408 )
2409 .await;
2410 *seat = fallback_seat;
2411 *out = fallback_out;
2412 }
2413 }
2414 }
2415
2416 async fn resume_unconfirmed_commands(
2440 &mut self,
2441 results: &mut [(usize, SeatState, AgentOutcome)],
2442 sent: &[SeatJob],
2443 prompts: &Prompts,
2444 run_id: &str,
2445 ) {
2446 for (wi, seat, out) in results.iter_mut() {
2447 let AgentOutcome::Ok(o) = &*out else {
2448 continue;
2449 };
2450 if !has_unconfirmed_command(&o.commands) {
2451 continue;
2452 }
2453 let Some(job) = sent.get(*wi) else { continue };
2454 if !has_context(&job.spec, seat, job.sessions) {
2455 self.state.event(
2456 "implement",
2457 format!(
2458 "{}: the reply named a command whose own CLI never confirmed the exit \
2459 status of, but there is no session left to resume",
2460 seat.key
2461 ),
2462 );
2463 continue;
2464 }
2465 self.state.event(
2466 "implement",
2467 format!(
2468 "{}: the reply named a command whose own CLI never confirmed the exit \
2469 status of; resuming the conversation",
2470 seat.key
2471 ),
2472 );
2473 let mut retry = job.clone();
2474 retry.seat = seat.clone();
2475 retry.prompt = prompt::resume_incomplete(
2476 "a command in your last reply had no confirmed exit status",
2477 );
2478 retry.timeout = retry_budget(job.timeout, true);
2479 retry.stem = format!("{}-confirm", job.stem);
2480 let cache = self.state.config.cache_dir();
2481 let ctx = WaveCtx {
2482 carry_seats: false,
2483 pauses: Some((&self.pause, &self.interrupt)),
2484 run: run_id,
2485 node: "implement",
2486 prompts,
2487 cache: cache.as_deref(),
2488 round: None,
2489 };
2490 let (resumed_seat, resumed) =
2491 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2492 *seat = resumed_seat;
2493 *out = resumed;
2494 }
2495 }
2496
2497 async fn continue_fix_report(
2518 &mut self,
2519 mut seat: SeatState,
2520 parse_err: String,
2521 job: &SeatJob,
2522 prompts: &Prompts,
2523 run_id: &str,
2524 round: usize,
2525 ) -> (
2526 SeatState,
2527 Option<FixReport>,
2528 Option<String>,
2529 ContinuationRecord,
2530 ) {
2531 let mut last_err = parse_err;
2532 let mut cumulative_wait_ms = 0u64;
2533 let mut attempts = 0usize;
2534 loop {
2535 if !has_context(&job.spec, &seat, job.sessions) {
2536 self.state.event(
2537 "fix",
2538 format!(
2539 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2540 session left to resume into"
2541 ),
2542 );
2543 let outcome = if attempts == 0 {
2544 ContinuationOutcome::NoSession
2545 } else {
2546 ContinuationOutcome::Exhausted
2547 };
2548 return (
2549 seat,
2550 None,
2551 Some(format!("unparsable fix report: {last_err}")),
2552 ContinuationRecord {
2553 attempts,
2554 cumulative_wait_ms,
2555 outcome,
2556 },
2557 );
2558 }
2559 if attempts >= MAX_FIX_CONTINUATIONS {
2560 self.state.event(
2561 "fix",
2562 format!(
2563 "round {round}: fixer's reply still had no adoption report after \
2564 {attempts} continuation(s) ({last_err}); giving up"
2565 ),
2566 );
2567 return (
2568 seat,
2569 None,
2570 Some(format!(
2571 "unparsable fix report after {attempts} continuation(s): {last_err}"
2572 )),
2573 ContinuationRecord {
2574 attempts,
2575 cumulative_wait_ms,
2576 outcome: ContinuationOutcome::Exhausted,
2577 },
2578 );
2579 }
2580 attempts += 1;
2581 self.state.event(
2582 "fix",
2583 format!(
2584 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2585 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2586 ),
2587 );
2588 let mut retry = job.clone();
2589 retry.seat = seat.clone();
2590 retry.prompt = prompt::resume_incomplete(&last_err);
2591 retry.timeout = retry_budget(job.timeout, true);
2592 retry.stem = format!("{}-continue{attempts}", job.stem);
2593 let cache = self.state.config.cache_dir();
2594 let ctx = WaveCtx {
2595 carry_seats: false,
2596 pauses: Some((&self.pause, &self.interrupt)),
2597 run: run_id,
2598 node: "fix",
2599 prompts,
2600 cache: cache.as_deref(),
2601 round: Some(round),
2602 };
2603 let (resumed_seat, resumed_out) = run_one(
2604 retry,
2605 Arc::clone(&self.sem),
2606 &ctx,
2607 &mut self.state,
2608 attempts,
2609 )
2610 .await;
2611 seat = resumed_seat;
2612 match resumed_out {
2613 AgentOutcome::Ok(o) => {
2614 cumulative_wait_ms += o.duration_ms;
2615 match verdict::extract_json::<FixReport>(&o.text) {
2616 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2617 self.state.event(
2618 "fix",
2619 format!(
2620 "round {round}: fixer's adoption report recovered after \
2621 {attempts} continuation(s)"
2622 ),
2623 );
2624 return (
2625 seat,
2626 Some(report),
2627 None,
2628 ContinuationRecord {
2629 attempts,
2630 cumulative_wait_ms,
2631 outcome: ContinuationOutcome::Resumed,
2632 },
2633 );
2634 }
2635 Ok(_) => {
2643 last_err = "the reply parsed, but it reported a command whose own CLI \
2644 never confirmed an exit status"
2645 .to_owned();
2646 }
2647 Err(e) => last_err = e.to_string(),
2648 }
2649 }
2650 AgentOutcome::Quota(o) => {
2651 cumulative_wait_ms += o.duration_ms;
2652 self.state.quota.push(QuotaLoss {
2653 seat: seat.key.clone(),
2654 node: "fix".to_owned(),
2655 at: Timestamp::now(),
2656 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2657 });
2658 self.state.event(
2659 "fix",
2660 format!(
2661 "round {round}: continuation rate limited (quota); not retrying now"
2662 ),
2663 );
2664 return (
2665 seat,
2666 None,
2667 Some("rate limited (quota) while recovering the fix report".to_owned()),
2668 ContinuationRecord {
2669 attempts,
2670 cumulative_wait_ms,
2671 outcome: ContinuationOutcome::QuotaLost,
2672 },
2673 );
2674 }
2675 AgentOutcome::Dropped(o) => {
2676 cumulative_wait_ms += o.duration_ms;
2677 let why = o
2678 .dropped
2679 .as_ref()
2680 .map(|d| d.why.as_str())
2681 .unwrap_or("the CLI ended the stream without delivering its answer");
2682 last_err = format!("the CLI dropped the stream ({why})");
2683 }
2684 AgentOutcome::Failed(e) => last_err = e,
2685 AgentOutcome::ResourceBlocked(e) => {
2688 return (
2689 seat,
2690 None,
2691 Some(format!("the continuation was not started: {e}")),
2692 ContinuationRecord {
2693 attempts: attempts.saturating_sub(1),
2694 cumulative_wait_ms,
2695 outcome: ContinuationOutcome::NoSession,
2696 },
2697 );
2698 }
2699 }
2700 }
2701 }
2702
2703 fn after_implement(&mut self) -> Result<()> {
2704 if self.state.leaks.is_empty() {
2706 let cfg = self.state.config.blind.clone();
2707 let mut leaks = Vec::new();
2708 for c in &self.state.candidates {
2709 let Some(patch) =
2710 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2711 else {
2712 continue;
2713 };
2714 leaks.extend(blind::scan(
2715 &format!("candidate {} patch", c.label),
2716 &patch,
2717 &cfg.vendor_tokens,
2718 ));
2719 }
2720 if !leaks.is_empty() {
2721 let summary = leaks
2722 .iter()
2723 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2724 .collect::<Vec<_>>()
2725 .join(", ");
2726 match cfg.on_leak {
2727 LeakPolicy::Fail => {
2728 self.state.status = RunStatus::Failed;
2729 self.state
2730 .event("blind", format!("vendor text in a patch: {summary}"));
2731 self.state.leaks = leaks;
2732 self.state.save()?;
2733 self.settle_questions();
2734 bail!(
2735 "blind.on_leak = \"fail\" and vendor text reached a \
2736 judged patch: {summary}"
2737 );
2738 }
2739 LeakPolicy::Redact => self.state.event(
2740 "blind",
2741 format!("redacting vendor text for judging: {summary}"),
2742 ),
2743 LeakPolicy::Warn => self.state.event(
2744 "blind",
2745 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2746 ),
2747 }
2748 self.state.leaks = leaks;
2749 }
2750 }
2751
2752 if self.state.viable().is_empty() {
2753 if self.state.all_candidates_verified_noop() {
2754 self.state.status = RunStatus::VerifiedNoop;
2765 self.state.save()?;
2766 self.settle_questions();
2767 return Ok(());
2768 }
2769 self.state.status = RunStatus::Failed;
2770 self.state.save()?;
2771 self.settle_questions();
2772 bail!("no candidate produced a change; nothing to judge");
2773 }
2774 self.state.status = RunStatus::Judging;
2775 self.state.save()?;
2776 Ok(())
2777 }
2778
2779 async fn judge(&mut self) -> Result<()> {
2782 let run_id = self.state.id.clone();
2787 let prompts = self.state.config.prompts.clone();
2788 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2789 return Ok(());
2790 }
2791 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2792 if viable.len() == 1 {
2793 self.state.judge_skipped = true;
2800 self.state.event(
2801 "judge",
2802 format!(
2803 "only candidate {} produced a change; judging skipped",
2804 viable[0].label
2805 ),
2806 );
2807 self.state.save()?;
2808 return Ok(());
2809 }
2810 self.state.status = RunStatus::Judging;
2811
2812 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2813 let language = self.state.config.graph.language.clone();
2814 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2815 let sessions = self.state.config.graph.sessions;
2816 let artifacts = agent::artifacts_dir(&self.state.dir());
2817 let root = self.state.worktree_root();
2818 let base_short = short(&self.state.base_commit);
2819
2820 let mut jobs = Vec::new();
2821 let mut orders = Vec::new();
2822 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2823 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2824 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2825 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2826 let seat_key = format!("judge-{}", j + 1);
2827 let seat = self.seat(&seat_key, &spec.id);
2828 jobs.push(SeatJob {
2829 prompt: prompt::judge(
2830 &self.state.instruction,
2831 &views,
2832 self.roles.judges.len(),
2833 &base_short,
2834 &language,
2835 ),
2836 spec,
2837 seat,
2838 cwd: root.join(format!("judge-{}", j + 1)),
2839 timeout,
2840 allow_write: false,
2841 sessions,
2842 artifacts: artifacts.clone(),
2843 stem: format!("judge-{}", j + 1),
2844 handover: None,
2845 });
2846 }
2847
2848 self.state.event(
2849 "judge",
2850 format!(
2851 "{} judges ranking {} candidates blind",
2852 jobs.len(),
2853 viable.len()
2854 ),
2855 );
2856 let labels_for_check = labels.clone();
2857 let mut quota_losses = Vec::new();
2858 let cache = self.state.config.cache_dir();
2859 let ctx = WaveCtx {
2860 carry_seats: false,
2861 pauses: Some((&self.pause, &self.interrupt)),
2862 run: &run_id,
2863 node: "judge",
2864 prompts: &prompts,
2865 cache: cache.as_deref(),
2866 round: None,
2867 };
2868 let results = ask_json_wave::<Ranking>(
2869 jobs,
2870 Arc::clone(&self.sem),
2871 self.state.config.graph.retries,
2872 &self.roles.judge_roster,
2873 &ctx,
2874 &mut quota_losses,
2875 &mut self.state,
2876 &move |r: &Ranking| r.validate(&labels_for_check),
2877 )
2878 .await;
2879 if self.state.resource_block.is_some() {
2880 return self.stop_for_resource();
2881 }
2882 self.state.quota.extend(quota_losses);
2883
2884 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2885 let agent_id = seat.agent.clone();
2886 self.state.seats.insert(seat.key.clone(), seat);
2887 let mut record = Judgement {
2888 judge: j + 1,
2889 seat: format!("judge-{}", j + 1),
2890 agent: agent_id,
2891 ranking: Vec::new(),
2892 reasons: BTreeMap::new(),
2893 confidence: None,
2894 strengths: BTreeMap::new(),
2895 order: orders[j].clone(),
2896 failed: None,
2897 duration_ms: 0,
2898 };
2899 match res {
2900 Ok((ranking, out)) => {
2901 record.ranking = ranking.normalized();
2902 record.reasons = ranking.reasons;
2903 record.strengths = ranking.strengths;
2904 record.confidence = ranking.confidence;
2905 record.duration_ms = out.duration_ms;
2906 self.state.event(
2907 "judge",
2908 format!(
2909 "judge {} ranked {}",
2910 j + 1,
2911 record.ranking.iter().collect::<String>()
2912 ),
2913 );
2914 }
2915 Err(e) => {
2916 record.failed = Some(e.to_string());
2917 self.state
2918 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2919 }
2920 }
2921 self.state.judgements.push(record);
2922 self.state.save()?;
2923 }
2924 Ok(())
2925 }
2926
2927 async fn deliberate(&mut self) -> Result<()> {
2930 let run_id = self.state.id.clone();
2935 let prompts = self.state.config.prompts.clone();
2936 if !self.state.deliberation.is_empty() {
2937 return Ok(());
2938 }
2939 let tops: Vec<char> = self
2940 .state
2941 .judgements
2942 .iter()
2943 .filter_map(|j| j.ranking.first().copied())
2944 .collect();
2945 let rounds = self.state.config.graph.deliberate_rounds;
2946 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2947 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2948 self.state.event(
2949 "deliberate",
2950 format!("judges agreed on {} outright; no deliberation", tops[0]),
2951 );
2952 }
2953 self.state.status = RunStatus::Voting;
2954 self.state.save()?;
2955 return Ok(());
2956 }
2957
2958 self.state.status = RunStatus::Deliberating;
2959 self.state.event(
2960 "deliberate",
2961 format!(
2962 "split: first choices were {} — opening {rounds} round(s)",
2963 tops.iter().collect::<String>()
2964 ),
2965 );
2966
2967 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2968 let language = self.state.config.graph.language.clone();
2969 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2970 let sessions = self.state.config.graph.sessions;
2971 let artifacts = agent::artifacts_dir(&self.state.dir());
2972 let root = self.state.worktree_root();
2973 let base_short = short(&self.state.base_commit);
2974
2975 for round in 1..=rounds {
2979 let mut turns: Vec<DeliberationTurn> = Vec::new();
2980 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2981 if self.state.judgements[j].failed.is_some() {
2982 continue;
2983 }
2984 let seat_key = format!("judge-{}", j + 1);
2985 let spec = self.occupant(&seat_key, spec);
2986 let mut seat = self.seat(&seat_key, &spec.id);
2987 let transcript = self.transcript(&turns, j);
2988 let build = |context: Option<&str>| {
2989 prompt::deliberate(
2990 &self.state.instruction,
2991 context,
2992 &transcript,
2993 round,
2994 rounds,
2995 &language,
2996 )
2997 };
2998 let block = self.candidate_block(&viable, &base_short);
2999 let full = build(Some(&block));
3000 let text = if has_context(&spec, &seat, sessions) {
3001 build(None)
3002 } else {
3003 full.clone()
3004 };
3005 let job = SeatJob {
3006 spec,
3007 seat: seat.clone(),
3008 prompt: text,
3009 cwd: root.join(format!("judge-{}", j + 1)),
3010 timeout,
3011 allow_write: false,
3012 sessions,
3013 artifacts: artifacts.clone(),
3014 stem: format!("delib-{round}-judge-{}", j + 1),
3015 handover: Some(full),
3016 };
3017 let cache = self.state.config.cache_dir();
3018 let ctx = WaveCtx {
3019 carry_seats: false,
3020 pauses: Some((&self.pause, &self.interrupt)),
3021 run: &run_id,
3022 node: "deliberate",
3023 prompts: &prompts,
3024 cache: cache.as_deref(),
3025 round: None,
3026 };
3027 let mut losses = Vec::new();
3031 let mut results = ask_wave_with::<String>(
3032 vec![job],
3033 Arc::clone(&self.sem),
3034 0,
3035 &self.roles.judge_roster,
3036 &ctx,
3037 &mut losses,
3038 &mut self.state,
3039 &|text: &str| {
3040 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3041 },
3042 )
3043 .await;
3044 self.state.quota.extend(losses);
3045 let (updated, res, _) = results.pop().expect("one job in, one result out");
3046 seat = updated;
3047 let agent_id = seat.agent.clone();
3048 self.state.seats.insert(seat.key.clone(), seat);
3049 let body = match res {
3050 Ok((body, _)) => body,
3051 Err(e) => {
3054 self.state
3055 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3056 continue;
3057 }
3058 };
3059 let tentative = verdict::extract_json::<Position>(&body)
3060 .ok()
3061 .and_then(|p| p.tentative)
3062 .and_then(|s| s.trim().chars().next())
3063 .map(|c| c.to_ascii_uppercase());
3064 self.state.event(
3065 "deliberate",
3066 format!(
3067 "round {round}: judge {} now favours {}",
3068 j + 1,
3069 tentative.map_or("—".to_owned(), |c| c.to_string())
3070 ),
3071 );
3072 turns.push(DeliberationTurn {
3073 judge: j + 1,
3074 agent: agent_id,
3075 body: blind::sanitize_prose(&body, &self.state.config.blind),
3076 tentative,
3077 });
3078 }
3079 self.state
3080 .deliberation
3081 .push(DeliberationRound { round, turns });
3082 self.state.save()?;
3083 }
3084
3085 self.state.status = RunStatus::Voting;
3086 self.state.save()?;
3087 Ok(())
3088 }
3089
3090 async fn vote(&mut self) -> Result<()> {
3093 let run_id = self.state.id.clone();
3098 let prompts = self.state.config.prompts.clone();
3099 if !self.state.votes.is_empty() {
3100 return Ok(());
3101 }
3102 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3103 if viable.len() == 1 {
3104 return Ok(());
3105 }
3106 self.state.status = RunStatus::Voting;
3107
3108 let language = self.state.config.graph.language.clone();
3109 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3110 let sessions = self.state.config.graph.sessions;
3111 let artifacts = agent::artifacts_dir(&self.state.dir());
3112 let root = self.state.worktree_root();
3113 let base_short = short(&self.state.base_commit);
3114 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3115
3116 let mut jobs = Vec::new();
3117 let mut seats_at = Vec::new();
3118 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3119 if self
3120 .state
3121 .judgements
3122 .get(j)
3123 .is_some_and(|r| r.failed.is_some())
3124 {
3125 continue;
3126 }
3127 let seat_key = format!("judge-{}", j + 1);
3128 let spec = self.occupant(&seat_key, spec);
3129 let seat = self.seat(&seat_key, &spec.id);
3130 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3131 let text = if has_context(&spec, &seat, sessions) {
3132 prompt::final_vote(&viable, &language)
3133 } else {
3134 full.clone()
3135 };
3136 jobs.push(SeatJob {
3137 spec,
3138 seat,
3139 prompt: text,
3140 cwd: root.join(format!("judge-{}", j + 1)),
3141 timeout,
3142 allow_write: false,
3143 sessions,
3144 artifacts: artifacts.clone(),
3145 stem: format!("vote-judge-{}", j + 1),
3146 handover: Some(full),
3147 });
3148 seats_at.push(j);
3149 }
3150
3151 self.state.event(
3152 "vote",
3153 format!(
3154 "collecting {} final votes one by one, privately",
3155 jobs.len()
3156 ),
3157 );
3158 let allowed = viable.clone();
3159 let mut quota_losses = Vec::new();
3160 let cache = self.state.config.cache_dir();
3161 let ctx = WaveCtx {
3162 carry_seats: false,
3163 pauses: Some((&self.pause, &self.interrupt)),
3164 run: &run_id,
3165 node: "vote",
3166 prompts: &prompts,
3167 cache: cache.as_deref(),
3168 round: None,
3169 };
3170 let results = ask_json_wave::<FinalVote>(
3171 jobs,
3172 Arc::clone(&self.sem),
3173 self.state.config.graph.retries,
3174 &self.roles.judge_roster,
3175 &ctx,
3176 &mut quota_losses,
3177 &mut self.state,
3178 &move |v: &FinalVote| match v.label() {
3179 Some(c) if allowed.contains(&c) => Ok(()),
3180 other => bail!("vote {other:?} is not one of {allowed:?}"),
3181 },
3182 )
3183 .await;
3184 if self.state.resource_block.is_some() {
3185 return self.stop_for_resource();
3186 }
3187 self.state.quota.extend(quota_losses);
3188
3189 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3190 let agent_id = seat.agent.clone();
3191 self.state.seats.insert(seat.key.clone(), seat);
3192 let initial = self
3193 .state
3194 .judgements
3195 .get(j)
3196 .and_then(|r| r.ranking.first().copied());
3197 let mut record = VoteRecord {
3198 judge: j + 1,
3199 agent: agent_id,
3200 vote: None,
3201 reason: String::new(),
3202 changed: false,
3203 };
3204 match res {
3205 Ok((v, _)) => {
3206 record.vote = v.label();
3207 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3208 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3209 self.state.event(
3210 "vote",
3211 format!(
3212 "judge {} voted {}{}",
3213 j + 1,
3214 record.vote.unwrap_or('?'),
3215 if record.changed { " (changed)" } else { "" }
3216 ),
3217 );
3218 }
3219 Err(e) => {
3220 self.state
3221 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3222 }
3223 }
3224 self.state.votes.push(record);
3225 self.state.save()?;
3226 }
3227 Ok(())
3228 }
3229
3230 fn tally(&mut self) -> Result<()> {
3233 if self.state.tally.is_some() {
3234 return Ok(());
3235 }
3236 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3237 let tops: Vec<char> = self
3238 .state
3239 .judgements
3240 .iter()
3241 .filter_map(|j| j.ranking.first().copied())
3242 .collect();
3243 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3244
3245 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3248 let mut cast: Vec<char> = Vec::new();
3249 for (i, j) in self.state.judgements.iter().enumerate() {
3250 let vote = self
3251 .state
3252 .votes
3253 .iter()
3254 .find(|v| v.judge == i + 1)
3255 .and_then(|v| v.vote)
3256 .or_else(|| j.ranking.first().copied());
3257 if let Some(v) = vote {
3258 *first_choice.entry(v).or_insert(0) += 1;
3259 cast.push(v);
3260 }
3261 }
3262
3263 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3264 for j in &self.state.judgements {
3265 let n = j.ranking.len();
3266 for (pos, label) in j.ranking.iter().enumerate() {
3267 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3268 }
3269 }
3270
3271 let best = first_choice.values().copied().max().unwrap_or(0);
3272 let mut leaders: Vec<char> = first_choice
3273 .iter()
3274 .filter(|(_, v)| **v == best)
3275 .map(|(k, _)| *k)
3276 .collect();
3277 let mut tie_break = None;
3278 if leaders.len() > 1 {
3279 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3280 let borda_leaders: Vec<char> = leaders
3281 .iter()
3282 .copied()
3283 .filter(|l| borda[l] == top_borda)
3284 .collect();
3285 tie_break = Some(if borda_leaders.len() == 1 {
3286 format!(
3287 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3288 leaders.len()
3289 )
3290 } else {
3291 format!(
3292 "{} way tie on both first-choice votes and Borda points, broken by label order",
3293 leaders.len()
3294 )
3295 });
3296 leaders = borda_leaders;
3297 leaders.sort_unstable();
3298 }
3299 let winner = *leaders
3300 .first()
3301 .or(viable.first())
3302 .context("no candidate to declare a winner from")?;
3303
3304 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3305 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3306 let deliberated = !self.state.deliberation.is_empty();
3307
3308 let quota_seats: std::collections::BTreeSet<&str> =
3312 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3313 let mut present = 0usize;
3314 for (i, j) in self.state.judgements.iter().enumerate() {
3315 if quota_seats.contains(j.seat.as_str()) {
3316 continue;
3317 }
3318 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3319 let voted = self
3320 .state
3321 .votes
3322 .iter()
3323 .any(|v| v.judge == i + 1 && v.vote.is_some());
3324 if ranked || voted {
3325 present += 1;
3326 }
3327 }
3328 let needs_quorum = viable.len() > 1;
3334 let judges_total = if needs_quorum {
3335 self.roles.judges.len()
3336 } else {
3337 0
3338 };
3339 let quorum = if needs_quorum {
3340 judges_total / 2 + 1
3341 } else {
3342 0
3343 };
3344 let met_quorum = !needs_quorum || present >= quorum;
3345 let uncontested = (!needs_quorum).then(|| {
3346 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3347 });
3348
3349 self.state.event(
3350 "tally",
3351 match &uncontested {
3352 Some(reason) => format!("winner {winner} — {reason}"),
3353 None => format!(
3354 "winner {winner} — votes {} | initial {} | {} changed | \
3355 {present}/{judges_total} judges{}",
3356 first_choice
3357 .iter()
3358 .map(|(k, v)| format!("{k}:{v}"))
3359 .collect::<Vec<_>>()
3360 .join(" "),
3361 if unanimous_initial {
3362 "unanimous"
3363 } else {
3364 "split"
3365 },
3366 changed_votes,
3367 if met_quorum {
3368 String::new()
3369 } else {
3370 format!(" — below quorum ({quorum} required)")
3371 },
3372 ),
3373 },
3374 );
3375 if !met_quorum {
3376 self.state.event(
3377 "stall",
3378 format!(
3379 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3380 the run stops here, resumable"
3381 ),
3382 );
3383 }
3384 self.state.tally = Some(Tally {
3385 first_choice,
3386 borda,
3387 winner,
3388 rankings: tops.len(),
3389 unanimous_initial,
3390 deliberated,
3391 changed_votes,
3392 unanimous_final,
3393 tie_break,
3394 judges: judges_total,
3395 present,
3396 quorum,
3397 met_quorum,
3398 uncontested,
3399 });
3400 self.state.status = if met_quorum {
3401 RunStatus::Reviewing
3402 } else {
3403 RunStatus::Stalled
3404 };
3405 self.state.save()?;
3406 Ok(())
3407 }
3408
3409 #[allow(clippy::too_many_lines)]
3430 async fn recover_stall(&mut self) -> Result<bool> {
3431 let run_id = self.state.id.clone();
3436 let prompts = self.state.config.prompts.clone();
3437 let quota_seats: BTreeSet<&str> =
3442 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3443 let absent: Vec<String> = self
3444 .state
3445 .judgements
3446 .iter()
3447 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3448 .map(|j| j.seat.clone())
3449 .collect();
3450 if absent.is_empty() {
3451 return Ok(false);
3452 }
3453 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3454 if viable.len() <= 1 {
3455 return Ok(false);
3456 }
3457 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3458 let language = self.state.config.graph.language.clone();
3459 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3460 let sessions = self.state.config.graph.sessions;
3461 let artifacts = agent::artifacts_dir(&self.state.dir());
3462 let root = self.state.worktree_root();
3463 let base_short = short(&self.state.base_commit);
3464 let candidates: Vec<Candidate> = viable.clone();
3465
3466 let mut positions: Vec<usize> = absent
3468 .iter()
3469 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3470 .collect();
3471 if positions.is_empty() {
3472 return Ok(false);
3473 }
3474 positions.sort_unstable();
3475 positions.dedup();
3476
3477 let mut judge_jobs = Vec::new();
3479 for &j in &positions {
3480 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3481 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3482 let seat_key = format!("judge-{}", j + 1);
3483 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3484 let seat = self.seat(&seat_key, &spec.id);
3485 judge_jobs.push(SeatJob {
3486 spec,
3487 seat,
3488 prompt: prompt::judge(
3489 &self.state.instruction,
3490 &views,
3491 self.roles.judges.len(),
3492 &base_short,
3493 &language,
3494 ),
3495 cwd: root.join(seat_key),
3496 timeout,
3497 allow_write: false,
3498 sessions,
3499 artifacts: artifacts.clone(),
3500 stem: format!("judge-{}-recover", j + 1),
3501 handover: None,
3502 });
3503 }
3504
3505 let labels_for_check = labels.clone();
3506 let mut judge_losses = Vec::new();
3507 let retries = self.state.config.graph.retries;
3508 let cache = self.state.config.cache_dir();
3509 let ctx = WaveCtx {
3510 carry_seats: false,
3511 pauses: Some((&self.pause, &self.interrupt)),
3512 run: &run_id,
3513 node: "judge",
3514 prompts: &prompts,
3515 cache: cache.as_deref(),
3516 round: None,
3517 };
3518 let results = ask_json_wave::<Ranking>(
3519 judge_jobs,
3520 Arc::clone(&self.sem),
3521 retries,
3522 &self.roles.judge_roster,
3523 &ctx,
3524 &mut judge_losses,
3525 &mut self.state,
3526 &move |r: &Ranking| r.validate(&labels_for_check),
3527 )
3528 .await;
3529 if self.state.resource_block.is_some() {
3530 self.stop_for_resource()?;
3531 return Ok(false);
3532 }
3533
3534 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3536 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3537 let agent_id = seat.agent.clone();
3538 self.state.seats.insert(seat.key.clone(), seat);
3539 let record = &mut self.state.judgements[j];
3540 match res {
3541 Ok((ranking, out)) => {
3542 record.agent = agent_id;
3543 record.ranking = ranking.normalized();
3544 record.reasons = ranking.reasons;
3545 record.strengths = ranking.strengths;
3546 record.confidence = ranking.confidence;
3547 record.failed = None;
3548 record.duration_ms = out.duration_ms;
3549 recovered.insert(j);
3550 self.state.event(
3551 "recover",
3552 format!("judge {} ranked again after the limit", j + 1),
3553 );
3554 }
3555 Err(e) => {
3556 self.state
3557 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3558 }
3559 }
3560 }
3561
3562 let mut vote_jobs = Vec::new();
3564 let mut vote_pos: Vec<usize> = Vec::new();
3565 for &j in &recovered {
3566 let seat_key = format!("judge-{}", j + 1);
3567 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3568 let seat = self.seat(&seat_key, &spec.id);
3569 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3570 let text = if has_context(&spec, &seat, sessions) {
3571 prompt::final_vote(&labels, &language)
3572 } else {
3573 full.clone()
3574 };
3575 vote_jobs.push(SeatJob {
3576 spec,
3577 seat,
3578 prompt: text,
3579 cwd: root.join(seat_key),
3580 timeout,
3581 allow_write: false,
3582 sessions,
3583 artifacts: artifacts.clone(),
3584 stem: format!("vote-judge-{}-recover", j + 1),
3585 handover: Some(full),
3586 });
3587 vote_pos.push(j);
3588 }
3589 let allowed = labels.clone();
3590 let mut vote_losses = Vec::new();
3591 let vote_retries = self.state.config.graph.retries;
3592 let vote_cache = self.state.config.cache_dir();
3593 let ctx = WaveCtx {
3594 carry_seats: false,
3595 pauses: Some((&self.pause, &self.interrupt)),
3596 run: &run_id,
3597 node: "vote",
3598 prompts: &prompts,
3599 cache: vote_cache.as_deref(),
3600 round: None,
3601 };
3602 let votes = ask_json_wave::<FinalVote>(
3603 vote_jobs,
3604 Arc::clone(&self.sem),
3605 vote_retries,
3606 &self.roles.judge_roster,
3607 &ctx,
3608 &mut vote_losses,
3609 &mut self.state,
3610 &move |v: &FinalVote| match v.label() {
3611 Some(c) if allowed.contains(&c) => Ok(()),
3612 other => bail!("vote {other:?} is not one of {allowed:?}"),
3613 },
3614 )
3615 .await;
3616 if self.state.resource_block.is_some() {
3617 self.stop_for_resource()?;
3618 return Ok(false);
3619 }
3620 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3621 let agent_id = seat.agent.clone();
3622 self.state.seats.insert(seat.key.clone(), seat);
3623 match res {
3624 Ok((v, _)) => {
3625 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3626 rec.vote = v.label();
3627 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3628 } else {
3629 self.state.votes.push(VoteRecord {
3630 judge: j + 1,
3631 agent: agent_id,
3632 vote: v.label(),
3633 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3634 changed: false,
3635 });
3636 }
3637 self.state.event(
3638 "recover",
3639 format!("judge {} voted again after the limit", j + 1),
3640 );
3641 }
3642 Err(e) => {
3643 self.state
3644 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3645 }
3646 }
3647 }
3648
3649 let recovered_keys: BTreeSet<String> = recovered
3653 .iter()
3654 .map(|&j| format!("judge-{}", j + 1))
3655 .collect();
3656 self.state
3657 .quota
3658 .retain(|q| !recovered_keys.contains(&q.seat));
3659 for loss in judge_losses.into_iter().chain(vote_losses) {
3663 if recovered_keys.contains(&loss.seat) {
3664 continue;
3665 }
3666 self.state.quota.retain(|q| q.seat != loss.seat);
3667 self.state.quota.push(loss);
3668 }
3669
3670 self.state.tally = None;
3672 self.tally()?;
3673 Ok(self
3674 .state
3675 .tally
3676 .as_ref()
3677 .map(|t| t.met_quorum)
3678 .unwrap_or(false))
3679 }
3680
3681 async fn graft(&mut self) -> Result<()> {
3698 let Err(e) = self.graft_inner().await else {
3699 return Ok(());
3700 };
3701 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3702 self.state.status = RunStatus::Blocked;
3703 self.state.event("graft", format!("blocked: {e:#}"));
3704 self.state.save()?;
3705 return Err(e);
3706 }
3707 if let Some(rec) = self.state.graft.clone()
3712 && !rec.outcome.settled()
3713 {
3714 if let Some(winner) = self.state.winner().cloned() {
3715 if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3716 self.state.status = RunStatus::Blocked;
3717 self.state.event("graft", format!("blocked: {re:#}"));
3718 self.state.save()?;
3719 return Err(re);
3720 }
3721 }
3722 if let Some(g) = self.state.graft.as_mut() {
3723 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3724 }
3725 }
3726 self.state.event("graft", format!("skipped: {e:#}"));
3727 if let Err(e) = self.state.save() {
3728 tracing::warn!("save after graft: {e:#}");
3729 }
3730 Ok(())
3731 }
3732
3733 async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3736 let restored = git::restore_to(wt, pre).await;
3737 let tip = git::rev_parse(wt, "HEAD").await;
3738 match (restored, tip) {
3739 (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3740 (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3741 (Ok(()), Ok(tip)) => {
3742 Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3743 }
3744 (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3745 }
3746 }
3747
3748 fn graft_skipped(&mut self, why: &str) {
3750 let message = format!("skipped: {why}");
3751 if !self
3752 .state
3753 .events
3754 .iter()
3755 .any(|e| e.node == "graft" && e.message == message)
3756 {
3757 self.state.event("graft", message);
3758 }
3759 }
3760
3761 async fn graft_inner(&mut self) -> Result<()> {
3762 if let Some(rec) = self.state.graft.clone()
3765 && !rec.outcome.settled()
3766 {
3767 if self.state.reviews.is_empty()
3771 && let Some(winner) = self.state.winner().cloned()
3772 {
3773 Self::graft_restore(&winner.worktree, &rec.pre).await?;
3774 }
3775 if let Some(g) = self.state.graft.as_mut() {
3776 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3777 }
3778 self.state.event(
3779 "graft",
3780 "rolled back: the previous round was interrupted; the winner is untouched",
3781 );
3782 self.state.save()?;
3783 return Ok(());
3784 }
3785 let plan = match graft::plan(&self.state) {
3786 Ok(plan) => plan,
3787 Err(skip) => {
3788 if skip != graft::Skip::AlreadyDone {
3789 self.graft_skipped(skip.reason());
3790 self.state.save()?;
3791 }
3792 return Ok(());
3793 }
3794 };
3795 let Some(winner) = self.state.winner().cloned() else {
3796 return Ok(());
3797 };
3798 let wt = winner.worktree.clone();
3799 if !git::is_clean(&wt).await? {
3800 self.graft_skipped("the winner's worktree has uncommitted changes");
3801 self.state.save()?;
3802 return Ok(());
3803 }
3804 let pre = git::rev_parse(&wt, "HEAD").await?;
3805 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3806 else {
3807 self.graft_skipped("the winner's diff could not be measured");
3808 self.state.save()?;
3809 return Ok(());
3810 };
3811 let limit = graft::limit(winner_lines);
3812 self.state.graft = Some(GraftRecord {
3815 pre: pre.clone(),
3816 post: None,
3817 lines: None,
3818 limit: Some(limit),
3819 agent: None,
3820 outcome: GraftOutcome::Running,
3821 requested: graft::requested(&plan),
3822 adoption: None,
3823 adoption_error: None,
3824 });
3825 self.state.event(
3826 "graft",
3827 format!(
3828 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3829 plan.strengths
3830 .iter()
3831 .map(|(l, _, _)| l.to_string())
3832 .collect::<Vec<_>>()
3833 .join(", "),
3834 plan.winner
3835 ),
3836 );
3837 self.state.save()?;
3838
3839 let fix_prompt = prompt::graft(
3840 &self.state.instruction,
3841 plan.winner,
3842 &plan.strengths,
3843 limit,
3844 &self.state.config.graph.language,
3845 );
3846 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3847 let sessions = self.state.config.graph.sessions;
3848 let artifacts = agent::artifacts_dir(&self.state.dir());
3849 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3850 let (_, seat, out) = self
3851 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3852 SeatJob {
3853 prompt: fix_prompt.clone(),
3854 spec,
3855 seat,
3856 cwd: wt.clone(),
3857 timeout,
3858 allow_write: true,
3859 sessions,
3860 artifacts: artifacts.clone(),
3861 stem: "graft".to_owned(),
3862 handover: None,
3863 }
3864 })
3865 .await;
3866 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
3871 Self::graft_restore(&wt, &pre).await?;
3872 self.state.graft = None;
3873 self.state.save()?;
3874 return self.stop_for_resource();
3875 }
3876 let mut why: Option<String> = None;
3877 match &out {
3878 AgentOutcome::Ok(o) if agent::output_advances(o) => {
3879 why = Some("the fixer produced nothing usable".to_owned());
3880 }
3881 AgentOutcome::Ok(_) => {}
3882 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3883 AgentOutcome::Quota(o) => {
3884 self.state.quota.push(QuotaLoss {
3885 seat: seat.key.clone(),
3886 node: "graft".to_owned(),
3887 at: Timestamp::now(),
3888 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3889 });
3890 why = Some("rate limited (quota)".to_owned());
3891 }
3892 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
3893 why = Some(format!(
3894 "the fixer failed: {}",
3895 e.lines()
3896 .next()
3897 .unwrap_or("")
3898 .chars()
3899 .take(160)
3900 .collect::<String>()
3901 ));
3902 }
3903 }
3904 let agent_id = seat.agent.clone();
3905 self.state.seats.insert(seat.key.clone(), seat);
3906
3907 let mut post = None;
3908 let mut lines = None;
3909 if why.is_none() {
3910 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3911 if let Ok(r) = &rescued {
3912 self.state.note_withheld("graft", &r.withheld);
3913 }
3914 let head = git::rev_parse(&wt, "HEAD").await?;
3915 let dirty = match &rescued {
3919 Ok(r) => git::status_porcelain(&wt)
3920 .await
3921 .map(|st| {
3922 st.lines().any(|l| {
3923 !r.withheld
3924 .iter()
3925 .any(|w| l.trim_end().ends_with(w.path.as_str()))
3926 })
3927 })
3928 .unwrap_or(true),
3929 Err(_) => true,
3930 };
3931 if dirty {
3932 why = Some("the fixer left changes that could not be committed".to_owned());
3933 } else if head == pre {
3934 why = Some("the fixer changed nothing".to_owned());
3935 } else {
3936 lines = git::changed_lines(&wt, &pre, &head).await;
3937 if graft::within_budget(lines, limit) {
3938 post = Some(head);
3939 } else {
3940 why = Some(format!(
3941 "{} changed lines against a limit of {limit}",
3942 lines.map_or_else(
3943 || "an unmeasurable number of".to_owned(),
3944 |l| l.to_string()
3945 )
3946 ));
3947 }
3948 }
3949 }
3950 let (adoption, adoption_error) = match &out {
3951 AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
3952 _ => (None, None),
3953 };
3954 let requested = graft::requested(&plan);
3955 let outcome = match why {
3956 None => {
3957 self.state.event(
3958 "graft",
3959 format!(
3960 "applied: {} changed lines (limit {limit})",
3961 lines.unwrap_or(0)
3962 ),
3963 );
3964 GraftOutcome::Applied
3965 }
3966 Some(why) => {
3967 Self::graft_restore(&wt, &pre).await?;
3970 self.state.event(
3971 "graft",
3972 format!("rolled back: {why}; the winner is untouched"),
3973 );
3974 GraftOutcome::RolledBack(why)
3975 }
3976 };
3977 self.state.graft = Some(GraftRecord {
3978 pre,
3979 post,
3980 lines,
3981 limit: Some(limit),
3982 agent: Some(agent_id),
3983 outcome,
3984 requested,
3985 adoption,
3986 adoption_error,
3987 });
3988 self.state.save()?;
3989 Ok(())
3990 }
3991
3992 async fn fold_losers(&mut self) -> Result<()> {
3995 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3996 return Ok(());
3997 };
3998 let repo = self.state.repo.clone();
3999 let mut folded = Vec::new();
4000 for i in 0..self.state.candidates.len() {
4001 let c = &self.state.candidates[i];
4002 if c.label == winner || c.folded {
4003 continue;
4004 }
4005 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4006 git::worktree_remove(&repo, &wt).await.ok();
4007 git::branch_delete(&repo, &branch).await.ok();
4008 self.state.candidates[i].folded = true;
4009 folded.push(label.to_string());
4010 }
4011 let root = self.state.worktree_root();
4013 for j in 1..=self.roles.judges.len() {
4014 let wt = root.join(format!("judge-{j}"));
4015 if wt.exists() {
4016 git::worktree_remove(&repo, &wt).await.ok();
4017 }
4018 }
4019 if self.state.config.graph.advise {
4022 for k in 1..=self.state.config.graph.advisors {
4023 let wt = root.join(format!("advisor-{k}"));
4024 if wt.exists() {
4025 git::worktree_remove(&repo, &wt).await.ok();
4026 }
4027 }
4028 }
4029 if !folded.is_empty() {
4030 self.state
4031 .event("fold", format!("folded candidates {}", folded.join(", ")));
4032 self.state.save()?;
4033 }
4034 Ok(())
4035 }
4036
4037 async fn sync_to_base(&mut self) -> Result<()> {
4077 if self.state.status == RunStatus::AlreadyInBase {
4078 return Ok(());
4079 }
4080 let conflicted = self
4081 .state
4082 .base_sync
4083 .as_ref()
4084 .is_some_and(|s| s.conflict.is_some());
4085 let Some(winner) = self.state.winner().cloned() else {
4086 return Ok(());
4087 };
4088
4089 let repo = self.state.repo.clone();
4090 let remote = self.state.config.merge.remote.clone();
4091 let base_branch = self.state.base_branch.clone();
4092 let tracking = format!("{remote}/{base_branch}");
4093
4094 git::fetch(&repo, &remote, &base_branch).await.ok();
4095 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4100 return Ok(());
4101 };
4102
4103 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4104 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4105 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4106
4107 if (behind > 0 || conflicted || head == tip)
4116 && self
4117 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4118 .await?
4119 {
4120 return Ok(());
4121 }
4122 if conflicted {
4123 return Ok(());
4124 }
4125
4126 if behind == 0 {
4127 if let Some(from) = self
4136 .state
4137 .rebase_fixes
4138 .iter()
4139 .rev()
4140 .find_map(|r| r.from.clone())
4141 && from != head
4142 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4143 .await
4144 .is_ok_and(|o| o.ok())
4145 {
4146 git::sync_to_head(&winner.worktree).await?;
4147 }
4148 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4154 if let Some(why) = &conflict {
4155 self.state.status = RunStatus::Blocked;
4156 self.state.event("land", why.clone());
4157 }
4158 self.state.base_sync = Some(BaseSync {
4159 tip,
4160 behind: 0,
4161 attempts,
4162 conflict,
4163 already_in: None,
4164 });
4165 self.state.save()?;
4166 return Ok(());
4167 }
4168
4169 if attempts >= BASE_SYNC_ROUNDS {
4170 let why = format!(
4171 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4172 rebase(s); rebasing again would only race it",
4173 winner.branch
4174 );
4175 self.state.status = RunStatus::Blocked;
4176 self.state.base_sync = Some(BaseSync {
4177 tip,
4178 behind,
4179 attempts,
4180 conflict: Some(why.clone()),
4181 already_in: None,
4182 });
4183 self.state.event("land", why);
4184 self.state.save()?;
4185 return Ok(());
4186 }
4187
4188 self.state.event(
4189 "land",
4190 format!(
4191 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4192 winner.branch
4193 ),
4194 );
4195 self.state.save()?;
4196
4197 let branch_tracking = format!("{remote}/{}", winner.branch);
4203 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4204 let remote_tip = if self.state.task_pr_branch().is_none()
4205 && matches!(&fetched_branch, Ok(o) if o.ok())
4206 {
4207 git::rev_parse(&repo, &branch_tracking).await.ok()
4208 } else {
4209 None
4210 };
4211 if let Some(theirs) = &remote_tip
4215 && !git::is_ancestor(&repo, theirs, &head).await
4216 && !crate::reconcile::origin_missing(&repo, &head, theirs)
4217 .await
4218 .is_ok_and(|missing| missing.is_empty())
4219 {
4220 let why = format!(
4221 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4222 them",
4223 short(theirs),
4224 winner.branch
4225 );
4226 self.state.status = RunStatus::Blocked;
4227 self.state.base_sync = Some(BaseSync {
4228 tip,
4229 behind,
4230 attempts,
4231 conflict: Some(why.clone()),
4232 already_in: None,
4233 });
4234 self.state.event("land", why);
4235 self.state.save()?;
4236 return Ok(());
4237 }
4238
4239 let scratch = self.state.dir().join("base-sync");
4240 let rebased = match crate::rebase::rebase_with_fixer(
4241 &mut self.state,
4242 &scratch,
4243 &winner.branch,
4244 &tracking,
4245 )
4246 .await
4247 {
4248 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4249 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4250 Err(e) => Err(e),
4251 };
4252 let attempts = attempts + 1;
4253 match rebased {
4254 Ok(None) => {
4255 git::sync_to_head(&winner.worktree).await?;
4259 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4260 let mut conflict = None;
4261 if let Some(pinned) = &remote_tip {
4262 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4263 match pushed {
4264 Ok(o) if o.ok() => self.state.event(
4265 "land",
4266 format!("pushed rebased {} to {remote}", winner.branch),
4267 ),
4268 Ok(o) => {
4269 conflict = Some(format!(
4270 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4271 winner.branch,
4272 short(pinned),
4273 o.stderr.chars().take(600).collect::<String>()
4274 ));
4275 }
4276 Err(e) => {
4277 conflict = Some(format!(
4278 "rebased {} locally but could not push it: {e:#}",
4279 winner.branch
4280 ));
4281 }
4282 }
4283 }
4284 if let Some(why) = &conflict {
4285 self.state.status = RunStatus::Blocked;
4286 self.state.event("land", why.clone());
4287 }
4288 self.state.base_sync = Some(BaseSync {
4289 tip: tip.clone(),
4290 behind: 0,
4291 attempts,
4292 conflict,
4293 already_in: None,
4294 });
4295 self.state
4296 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4297 }
4298 Ok(Some(conflict)) => {
4299 let why = format!(
4300 "{} conflicts with {tracking} and did not rebase: {}",
4301 winner.branch,
4302 conflict.chars().take(600).collect::<String>()
4303 );
4304 self.state.status = RunStatus::Blocked;
4305 self.state.base_sync = Some(BaseSync {
4306 tip,
4307 behind,
4308 attempts,
4309 conflict: Some(why.clone()),
4310 already_in: None,
4311 });
4312 self.state.event("land", why);
4313 }
4314 Err(e) => {
4315 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4316 self.state.status = RunStatus::Blocked;
4317 self.state.base_sync = Some(BaseSync {
4318 tip,
4319 behind,
4320 attempts,
4321 conflict: Some(why.clone()),
4322 already_in: None,
4323 });
4324 self.state.event("land", why);
4325 }
4326 }
4327 self.state.save()?;
4328 Ok(())
4329 }
4330
4331 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4337 if self.state.task_pr_branch().is_some() {
4340 return None;
4341 }
4342 let repo = self.state.repo.clone();
4343 let remote = self.state.config.merge.remote.clone();
4344 let fetched = git::fetch(&repo, &remote, branch).await;
4345 if !matches!(&fetched, Ok(o) if o.ok()) {
4346 return None;
4347 }
4348 let branch_tracking = format!("{remote}/{branch}");
4349 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4350 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4351 return None;
4352 }
4353 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4354 .await
4355 .is_ok_and(|missing| missing.is_empty())
4356 {
4357 return Some(format!(
4358 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4359 them",
4360 short(&theirs)
4361 ));
4362 }
4363 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4364 Ok(o) if o.ok() => {
4365 self.state
4366 .event("land", format!("pushed rebased {branch} to {remote}"));
4367 None
4368 }
4369 Ok(o) => Some(format!(
4370 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4371 someone may have pushed): {}",
4372 short(&theirs),
4373 o.stderr.chars().take(600).collect::<String>()
4374 )),
4375 Err(e) => Some(format!(
4376 "{branch} is rebased locally but could not be pushed: {e:#}"
4377 )),
4378 }
4379 }
4380
4381 async fn settle_already_in(
4398 &mut self,
4399 branch: &str,
4400 tip: &str,
4401 head: &str,
4402 attempts: usize,
4403 behind: usize,
4404 ) -> Result<bool> {
4405 let repo = self.state.repo.clone();
4406 let remote = self.state.config.merge.remote.clone();
4407 let start = self.state.base_commit.clone();
4408 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4409 Ok(Some(e)) => e,
4410 Ok(None) => return Ok(false),
4411 Err(e) => {
4412 tracing::warn!("already-in-base check for {branch}: {e:#}");
4413 return Ok(false);
4414 }
4415 };
4416 let mut verified = vec![head.to_owned()];
4417 let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4418 let fetched = git::fetch(&repo, &remote, &public_branch).await;
4419 if matches!(&fetched, Ok(o) if o.ok())
4420 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4421 && theirs != head
4422 {
4423 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4424 Ok(Some(_)) => verified.push(theirs),
4425 _ => return Ok(false),
4426 }
4427 }
4428 let base_branch = self.state.base_branch.clone();
4429 let message = format!(
4430 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4431 land",
4432 evidence.names(),
4433 evidence.proof.as_str()
4434 );
4435 let closed =
4436 crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4437 .await;
4438 match closed {
4439 Ok(Ok(url)) => self
4440 .state
4441 .event("land", format!("closed {url}: superseded on {base_branch}")),
4442 Ok(Err(why)) => self
4443 .state
4444 .event("land", format!("did not close a pull request: {why}")),
4445 Err(e) => {
4446 let why = format!(
4447 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4448 not be closed ({e:#}); resume to retry"
4449 );
4450 self.state.status = RunStatus::Blocked;
4451 self.state.base_sync = Some(BaseSync {
4452 tip: tip.to_owned(),
4453 behind,
4454 attempts,
4455 conflict: Some(why.clone()),
4456 already_in: None,
4457 });
4458 self.state.event("land", why);
4459 self.state.save()?;
4460 return Ok(true);
4461 }
4462 }
4463 self.state.status = RunStatus::AlreadyInBase;
4464 self.state.base_sync = Some(BaseSync {
4465 tip: tip.to_owned(),
4466 behind,
4467 attempts,
4468 conflict: None,
4469 already_in: Some(evidence),
4470 });
4471 self.state.event("land", message);
4472 self.state.save()?;
4473 self.settle_questions();
4474 Ok(true)
4475 }
4476
4477 fn landing_base(&self) -> String {
4487 self.state
4488 .base_sync
4489 .as_ref()
4490 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4491 }
4492
4493 pub async fn fix_selected(
4526 &mut self,
4527 ids: &[String],
4528 reason: &str,
4529 allow_stale: bool,
4530 ) -> Result<()> {
4531 let reason = reason.trim();
4532 if reason.is_empty() {
4533 bail!("a fix request needs a reason — that is the operator's own record of why");
4534 }
4535 if ids.is_empty() {
4536 bail!("no finding id given");
4537 }
4538 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4539 bail!(
4540 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4541 has already concluded — can be given a targeted fix. A run still \
4542 in progress should simply be resumed; a `merged` run's branch has \
4543 already landed, so its answer is a fresh `magi review <branch>`, \
4544 not reopening this run's own record",
4545 self.state.id,
4546 self.state.status.as_str()
4547 );
4548 }
4549 let Some(winner) = self.state.winner().cloned() else {
4550 bail!("run {} has no winning candidate to fix", self.state.id);
4551 };
4552 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4553 bail!(
4554 "branch `{}` no longer exists; this run cannot be extended",
4555 winner.branch
4556 );
4557 }
4558 let home = crate::run::home();
4559 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4560 bail!(
4561 "run {} is currently being worked on by another magi process",
4562 self.state.id
4563 );
4564 }
4565 let _claim = FixClaim::acquire(&self.state.dir())?;
4571
4572 let mut seen = BTreeSet::new();
4576 let mut findings = Vec::new();
4577 let mut missing = Vec::new();
4578 for id in ids {
4579 if !seen.insert(id.clone()) {
4580 continue;
4581 }
4582 match self.state.finding(id) {
4583 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4584 id: f.id.clone(),
4585 severity: f.severity,
4586 reviewer_vote: rec.vote,
4587 round: round.round,
4588 round_head: round.head.clone(),
4589 reviewer: rec.reviewer,
4590 agent: rec.agent.clone(),
4591 file: f.file.clone(),
4592 line: f.line,
4593 title: f.title.clone(),
4594 detail: f.detail.clone(),
4595 outcome: OperatorFixOutcome::Pending,
4596 }),
4597 None => missing.push(id.clone()),
4598 }
4599 }
4600 if !missing.is_empty() {
4601 bail!(
4602 "unknown finding id(s): {}; nothing was changed",
4603 missing.join(", ")
4604 );
4605 }
4606
4607 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4608 let stale_details: Vec<(String, String)> = findings
4609 .iter()
4610 .filter(|f| f.round_head != head_at_request)
4611 .map(|f| (f.id.clone(), f.round_head.clone()))
4612 .collect();
4613 let stale = !stale_details.is_empty();
4614 if stale && !allow_stale {
4615 bail!(
4616 "the branch has moved since some finding(s) were raised — {} — now \
4617 at {}; pass --allow-stale to fix anyway, or re-run review first",
4618 stale_details
4619 .iter()
4620 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4621 .collect::<Vec<_>>()
4622 .join(", "),
4623 short(&head_at_request)
4624 );
4625 }
4626
4627 let request = OperatorFixRequest {
4628 requested_at: Timestamp::now(),
4629 reason: reason.to_owned(),
4630 findings,
4631 head_at_request: head_at_request.clone(),
4632 allow_stale,
4633 stale,
4634 fix: None,
4635 result_head: None,
4636 follow_up_review_run: None,
4637 };
4638 self.state.event(
4639 "fix",
4640 format!(
4641 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4642 request.findings.len(),
4643 request
4644 .findings
4645 .iter()
4646 .map(|f| f.id.as_str())
4647 .collect::<Vec<_>>()
4648 .join(", "),
4649 ),
4650 );
4651 self.state.operator_fixes.push(request);
4658 self.state.save()?;
4659 let request_index = self.state.operator_fixes.len() - 1;
4660
4661 if winner.worktree.exists() {
4670 let dirty = git::git(
4673 &winner.worktree,
4674 &["status", "--porcelain", "--untracked-files=all"],
4675 )
4676 .await?;
4677 let only_withheld = dirty.lines().all(|l| {
4678 l.strip_prefix("?? ")
4679 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4680 });
4681 if !only_withheld {
4682 bail!(
4683 "`{}` has uncommitted changes; refusing to touch it — commit or \
4684 discard them first",
4685 winner.worktree.display()
4686 );
4687 }
4688 git::worktree_remove(&self.state.repo, &winner.worktree)
4689 .await
4690 .ok();
4691 }
4692 let fix_worktree = self.state.worktree_root().join("operator-fix");
4693 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4694 git::git(
4695 &self.state.repo,
4696 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4697 )
4698 .await
4699 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4700 if !git::is_clean(&fix_worktree).await? {
4701 git::worktree_remove(&self.state.repo, &fix_worktree)
4702 .await
4703 .ok();
4704 bail!(
4705 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4706 winner.branch
4707 );
4708 }
4709 let repo_for_setup = self.state.repo.clone();
4710 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4711 Ok(()) => {
4712 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4713 }
4714 Err(e) => Err(e),
4715 };
4716 if let Err(e) = setup {
4717 git::worktree_remove(&self.state.repo, &fix_worktree)
4718 .await
4719 .ok();
4720 return Err(e);
4721 }
4722
4723 let run_id = self.state.id.clone();
4724 let prompts = self.state.config.prompts.clone();
4725 let language = self.state.config.graph.language.clone();
4726 let sessions = self.state.config.graph.sessions;
4727 let artifacts = agent::artifacts_dir(&self.state.dir());
4728 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4729 .findings
4730 .iter()
4731 .map(|f| Finding {
4732 id: f.id.clone(),
4733 severity: f.severity,
4734 file: f.file.clone(),
4735 line: f.line,
4736 title: f.title.clone(),
4737 detail: f.detail.clone(),
4738 })
4739 .collect();
4740 let fix_prompt = prompt::operator_fix(
4741 &self.state.instruction,
4742 &finding_list,
4743 reason,
4744 &stale_details,
4745 &head_at_request,
4746 &language,
4747 );
4748 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4749 let (job, seat, out) = self
4750 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4751 prompt: fix_prompt.clone(),
4752 spec,
4753 seat,
4754 cwd: fix_worktree.clone(),
4755 timeout,
4756 allow_write: true,
4757 sessions,
4758 artifacts: artifacts.clone(),
4759 stem: "operator-fix".to_owned(),
4760 handover: None,
4761 })
4762 .await;
4763 let agent_id = seat.agent.clone();
4764
4765 let mut fix = FixRecord {
4766 agent: agent_id,
4767 addressed: Vec::new(),
4768 rejected: Vec::new(),
4769 notes: String::new(),
4770 committed: false,
4771 failed: None,
4772 duration_ms: 0,
4773 continuation: None,
4774 };
4775 let mut final_seat = seat.clone();
4776 match out {
4777 AgentOutcome::Ok(o) => {
4778 fix.duration_ms = o.duration_ms;
4779 let parsed = verdict::extract_json::<FixReport>(&o.text);
4780 let incomplete_reason = match &parsed {
4781 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4782 "the reply parsed, but it reported a command whose own CLI \
4783 never confirmed an exit status"
4784 .to_owned(),
4785 ),
4786 Ok(_) => None,
4787 Err(e) => Some(e.to_string()),
4788 };
4789 match incomplete_reason {
4790 None => {
4791 let report = parsed.expect("checked Ok above");
4792 fix.addressed = report.addressed;
4793 fix.rejected = report.rejected;
4794 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4795 }
4796 Some(reason) => {
4797 let (resumed_seat, resolved, failure, cont) = self
4798 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4799 .await;
4800 fix.duration_ms += cont.cumulative_wait_ms;
4801 fix.continuation = Some(cont);
4802 final_seat = resumed_seat;
4803 match resolved {
4804 Some(report) => {
4805 fix.addressed = report.addressed;
4806 fix.rejected = report.rejected;
4807 fix.notes =
4808 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4809 }
4810 None => fix.failed = failure,
4811 }
4812 }
4813 }
4814 }
4815 AgentOutcome::Dropped(o) => {
4816 fix.duration_ms = o.duration_ms;
4817 let why = o
4818 .dropped
4819 .as_ref()
4820 .map(|d| d.why.as_str())
4821 .unwrap_or("the CLI ended the stream without delivering its answer");
4822 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4823 }
4824 AgentOutcome::Quota(o) => {
4825 self.state.quota.push(QuotaLoss {
4826 seat: final_seat.key.clone(),
4827 node: "fix".to_owned(),
4828 at: Timestamp::now(),
4829 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4830 });
4831 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4832 }
4833 AgentOutcome::Failed(e) => fix.failed = Some(e),
4834 AgentOutcome::ResourceBlocked(e) => {
4835 fix.failed = Some(format!("the fixer was not started: {e}"));
4836 }
4837 }
4838 if fix.continuation.is_none() {
4839 fix.continuation = Some(ContinuationRecord::not_needed());
4840 }
4841 self.state.seats.insert(final_seat.key.clone(), final_seat);
4842
4843 let rescue_message = format!(
4844 "magi: operator-selected fix ({}) (uncommitted work)",
4845 self.state.operator_fixes[request_index]
4846 .findings
4847 .iter()
4848 .map(|f| f.id.as_str())
4849 .collect::<Vec<_>>()
4850 .join(", ")
4851 );
4852 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4853 self.state.note_withheld("fix", &r.withheld);
4854 }
4855 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4856 fix.committed = after != head_at_request;
4857 git::worktree_remove(&self.state.repo, &fix_worktree)
4858 .await
4859 .ok();
4860
4861 self.state.event(
4862 "fix",
4863 match &fix.failed {
4864 Some(reason) => format!(
4865 "operator fix: adoption report was lost ({reason}); {}",
4866 if fix.committed {
4867 "committed"
4868 } else {
4869 "NO new commit"
4870 }
4871 ),
4872 None => format!(
4873 "operator fix: {} addressed, {} rejected, {}",
4874 fix.addressed.len(),
4875 fix.rejected.len(),
4876 if fix.committed {
4877 "committed"
4878 } else {
4879 "NO new commit"
4880 }
4881 ),
4882 },
4883 );
4884
4885 for f in &mut self.state.operator_fixes[request_index].findings {
4892 f.outcome = if fix.failed.is_some() {
4893 OperatorFixOutcome::Unreported
4894 } else if fix.addressed.contains(&f.id) {
4895 OperatorFixOutcome::Addressed
4896 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4897 OperatorFixOutcome::Rejected { why: r.why.clone() }
4898 } else {
4899 OperatorFixOutcome::Unreported
4900 };
4901 }
4902
4903 let committed = fix.committed;
4904 if committed {
4905 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4906 }
4907 self.state.operator_fixes[request_index].fix = Some(fix);
4908 self.state.save()?;
4911
4912 if committed {
4913 self.state.event(
4914 "fix",
4915 format!(
4916 "operator fix committed {}; opening a follow-up review-only run",
4917 short(&after)
4918 ),
4919 );
4920 let origin =
4923 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4924 match Self::review(
4925 &self.state.repo,
4926 &winner.branch,
4927 self.state.config.clone(),
4928 origin,
4929 )
4930 .await
4931 {
4932 Ok(mut follow_up) => {
4933 follow_up.state.event(
4934 "start",
4935 format!(
4936 "requested by an operator fix on run {} for finding(s) {}",
4937 self.state.id,
4938 self.state.operator_fixes[request_index]
4939 .findings
4940 .iter()
4941 .map(|f| f.id.as_str())
4942 .collect::<Vec<_>>()
4943 .join(", "),
4944 ),
4945 );
4946 follow_up.state.save()?;
4947 let follow_up_id = follow_up.state.id.clone();
4948 let adopted = match crate::direct::adopt(
4951 &follow_up.state,
4952 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4953 ) {
4954 Ok(a) => a,
4955 Err(e) => {
4956 self.state.event(
4959 "fix",
4960 format!(
4961 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4962 ),
4963 );
4964 self.state.save()?;
4965 return Ok(());
4966 }
4967 };
4968 let executed = follow_up.execute().await;
4969 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4970 if let Some(a) = adopted {
4971 a.finish(&follow_up.state, executed);
4972 }
4973 if let Some(e) = failure {
4974 self.state.event(
4975 "fix",
4976 format!(
4977 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4978 ),
4979 );
4980 }
4981 self.state.operator_fixes[request_index].follow_up_review_run =
4982 Some(follow_up_id);
4983 }
4984 Err(e) => {
4985 self.state.event(
4986 "fix",
4987 format!("committed the fix but could not open a follow-up review: {e:#}"),
4988 );
4989 }
4990 }
4991 self.state.save()?;
4992 }
4993
4994 Ok(())
4995 }
4996
4997 async fn ask_fixer(
5017 &mut self,
5018 winner: &Candidate,
5019 node: &'static str,
5020 round: Option<usize>,
5021 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5022 ) -> (SeatJob, SeatState, AgentOutcome) {
5023 let attempts = fixer::attempts(&self.state, &self.roles, winner);
5024 self.ask_fixer_among(attempts, node, round, None, build)
5025 .await
5026 }
5027
5028 async fn ask_fixer_among(
5033 &mut self,
5034 attempts: Vec<(AgentSpec, String)>,
5035 node: &'static str,
5036 round: Option<usize>,
5037 restore: Option<(&Path, &str)>,
5038 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5039 ) -> (SeatJob, SeatState, AgentOutcome) {
5040 let run_id = self.state.id.clone();
5041 let prompts = self.state.config.prompts.clone();
5042 let cache = self.state.config.cache_dir();
5043 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5044 let mut last = None;
5045 for (i, (spec, key)) in attempts.into_iter().enumerate() {
5046 let seat = self.seat(&key, &spec.id);
5047 let mut job = build(spec.clone(), seat);
5048 if i > 0 {
5049 job.stem = format!("{}-{}", job.stem, spec.id);
5050 }
5051 let ctx = WaveCtx {
5052 carry_seats: false,
5053 pauses: Some((&self.pause, &self.interrupt)),
5054 run: &run_id,
5055 node,
5056 prompts: &prompts,
5057 cache: cache.as_deref(),
5058 round,
5059 };
5060 let (seat, out) =
5061 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5062 let advances = match &out {
5065 AgentOutcome::Ok(o) => agent::output_advances(o),
5066 AgentOutcome::ResourceBlocked(_) => false,
5067 _ => true,
5068 };
5069 if let Some(next) = ids.get(i + 1)
5070 && advances
5071 {
5072 self.state.seats.insert(seat.key.clone(), seat.clone());
5073 let class =
5074 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5075 let reason = match &out {
5076 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5077 other => fail_reason(other),
5078 };
5079 record_handover(
5080 &mut self.state,
5081 node,
5082 &seat.key,
5083 &spec.id,
5084 next,
5085 &class,
5086 &reason,
5087 );
5088 if let Some((wt, rev)) = restore
5092 && let Err(e) = git::restore_to(wt, rev).await
5093 {
5094 let why =
5095 format!("could not restore the worktree before the next fixer: {e:#}");
5096 tracing::warn!("{why}");
5097 last = Some((job, seat, AgentOutcome::Failed(why)));
5098 break;
5099 }
5100 continue;
5101 }
5102 last = Some((job, seat, out));
5103 break;
5104 }
5105 last.expect("the fixer chain always has an entry")
5106 }
5107
5108 async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5119 use crate::recovery::{Mode, Repair};
5120 if !self.state.reviews.is_empty() {
5121 return Ok(());
5122 }
5123 let Some(mut rec) = self.state.recovery.clone() else {
5124 return Ok(());
5125 };
5126 if rec.mode != Mode::VerifyRepair {
5127 return Ok(());
5128 }
5129 let wt = winner.worktree.clone();
5130 let head = git::rev_parse(&wt, "HEAD").await?;
5131 match rec.repair.clone() {
5132 Repair::Done { .. } => return Ok(()),
5133 Repair::Started { before } => {
5134 if let Err(e) = Self::graft_restore(&wt, &before).await {
5138 return self
5139 .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5140 .await;
5141 }
5142 rec.repair = Repair::Done {
5143 before: before.clone(),
5144 after: before,
5145 committed: false,
5146 failed: Some("interrupted; rolled back".to_owned()),
5147 };
5148 self.state
5149 .event("recovery", "the repair was interrupted and rolled back");
5150 self.state.recovery = Some(rec);
5151 self.state.save()?;
5152 return Ok(());
5153 }
5154 Repair::NotStarted => {}
5155 }
5156 if crate::recovery::recheck(&mut rec, &head) {
5157 self.state.event("recovery", rec.summary());
5158 self.state.recovery = Some(rec);
5159 self.state.save()?;
5160 return Ok(());
5161 }
5162 rec.repair = Repair::Started {
5163 before: head.clone(),
5164 };
5165 self.state.recovery = Some(rec.clone());
5166 self.state.save()?;
5167
5168 let language = self.state.config.graph.language.clone();
5169 let sessions = self.state.config.graph.sessions;
5170 let artifacts = agent::artifacts_dir(&self.state.dir());
5171 let fix_prompt = prompt::recovery_fix(
5172 &self.state.instruction,
5173 &rec,
5174 self.state.config.graph.review_rounds,
5175 &language,
5176 );
5177 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5178 self.state.event(
5179 "recovery",
5180 format!(
5181 "repair: the fixer starts from the failed verification on {}",
5182 short(&head)
5183 ),
5184 );
5185 let (_job, seat, out) = self
5186 .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5187 prompt: fix_prompt.clone(),
5188 spec,
5189 seat,
5190 cwd: wt.clone(),
5191 timeout,
5192 allow_write: true,
5193 sessions,
5194 artifacts: artifacts.clone(),
5195 stem: "fix-recovery".to_owned(),
5196 handover: None,
5197 })
5198 .await;
5199 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5202 if let Err(e) = Self::graft_restore(&wt, &head).await {
5203 return self
5204 .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5205 .await;
5206 }
5207 rec.repair = Repair::NotStarted;
5208 self.state.recovery = Some(rec);
5209 self.state.save()?;
5210 return self.stop_for_resource();
5211 }
5212 let failed = match &out {
5213 AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5214 AgentOutcome::Quota(o) => {
5215 self.state.quota.push(QuotaLoss {
5216 seat: seat.key.clone(),
5217 node: "fix".to_owned(),
5218 at: Timestamp::now(),
5219 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5220 });
5221 Some("rate limited (quota); the fixer could not run".to_owned())
5222 }
5223 AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5224 AgentOutcome::Failed(e) => Some(e.clone()),
5225 };
5226 self.state.seats.insert(seat.key.clone(), seat);
5227 let failed = match failed {
5228 Some(why) => {
5230 if let Err(e) = git::restore_to(&wt, &head).await {
5231 return self
5232 .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5233 .await;
5234 }
5235 Some(why)
5236 }
5237 None => {
5238 if let Ok(r) = git::rescue_commit(
5239 &wt,
5240 "magi: recovery repair of the failed verification (uncommitted work)",
5241 )
5242 .await
5243 {
5244 self.state.note_withheld("fix", &r.withheld);
5245 }
5246 None
5247 }
5248 };
5249 let after = git::rev_parse(&wt, "HEAD").await?;
5250 self.state.event(
5251 "recovery",
5252 match &failed {
5253 Some(why) => format!("repair: nothing kept ({why})"),
5254 None if after != head => {
5255 format!("repair: committed {}", short(&after))
5256 }
5257 None => "repair: the fixer made no commit".to_owned(),
5258 },
5259 );
5260 rec.repair = Repair::Done {
5261 before: head.clone(),
5262 committed: after != head,
5263 after,
5264 failed,
5265 };
5266 self.state.recovery = Some(rec);
5267 self.state.save()?;
5268 Ok(())
5269 }
5270
5271 async fn stop_recovery(
5274 &mut self,
5275 mut rec: crate::recovery::RecoveryRecord,
5276 head: &str,
5277 why: String,
5278 ) -> Result<()> {
5279 rec.repair = crate::recovery::Repair::Started {
5281 before: head.to_owned(),
5282 };
5283 self.state.recovery = Some(rec);
5284 self.state
5285 .event("recovery", format!("repair could not be undone: {why}"));
5286 self.state.status = RunStatus::Blocked;
5287 self.state.save()?;
5288 bail!("recovery repair could not be rolled back: {why}")
5289 }
5290
5291 async fn review_loop(&mut self) -> Result<()> {
5292 if self
5297 .state
5298 .base_sync
5299 .as_ref()
5300 .is_some_and(|s| s.conflict.is_some())
5301 {
5302 return Ok(());
5303 }
5304 let run_id = self.state.id.clone();
5309 let prompts = self.state.config.prompts.clone();
5310 let Some(winner) = self.state.winner().cloned() else {
5311 return Ok(());
5312 };
5313 let max_rounds = self.state.config.graph.review_rounds;
5314 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5324 if status == RunStatus::Gating {
5327 self.record_contested_handoff();
5328 }
5329 self.state.status = status;
5330 self.state.save()?;
5331 return Ok(());
5332 }
5333 self.state.status = RunStatus::Reviewing;
5334 if self
5344 .state
5345 .reviews
5346 .last()
5347 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5348 {
5349 let shell = self.state.config.shell();
5350 return self
5351 .stop_reviewing(
5352 "the last round's own verification never resolved",
5353 &shell,
5354 &winner.worktree,
5355 )
5356 .await;
5357 }
5358
5359 self.recovery_repair(&winner).await?;
5360 if self.resource_halt()? {
5361 return Ok(());
5362 }
5363
5364 let repo = self.state.repo.clone();
5365 let root = self.state.worktree_root();
5366 let language = self.state.config.graph.language.clone();
5367 let sessions = self.state.config.graph.sessions;
5368 let artifacts = agent::artifacts_dir(&self.state.dir());
5369 let base = self.landing_base();
5370 let base_short = short(&base);
5371 let reviewers = self.roles.reviewers.clone();
5372 let shell = self.state.config.shell();
5373
5374 for round in (self.state.reviews.len() + 1)..=max_rounds {
5375 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5376 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5377 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5378 let prev_verification = self
5387 .state
5388 .reviews
5389 .last()
5390 .and_then(|r| r.verification_summary(&head));
5391 let prior_section = match (&self.state.recovery, round) {
5394 (Some(rec), 1) if self.state.reviews.is_empty() => {
5395 let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5396 (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5397 Some(crate::recovery::head_of(
5398 &git::diff(&winner.worktree, reviewed, "HEAD")
5399 .await
5400 .unwrap_or_default(),
5401 crate::recovery::SINCE_DIFF_MAX,
5402 ))
5403 }
5404 _ => None,
5405 };
5406 Some(prompt::recovery_review(rec, since.as_deref()))
5407 }
5408 _ => None,
5409 };
5410
5411 let mut jobs = Vec::new();
5415 for (r, spec) in reviewers.iter().cloned().enumerate() {
5416 let wt = root.join(format!("review-{}", r + 1));
5417 if wt.exists() {
5420 git::reset_detached(&wt, &head).await?;
5421 } else {
5422 git::worktree_add_detached(&repo, &wt, &head).await?;
5423 }
5424 self.setup_seat_worktree(&repo, &wt, false).await?;
5425 let seat_key = format!("review-{}", r + 1);
5426 let spec = pick_start_spec(
5429 &self.roles.reviewer_roster,
5430 spec,
5431 self.state.seat_history.get(&seat_key),
5432 );
5433 let seat = self.seat(&seat_key, &spec.id);
5434 jobs.push(SeatJob {
5435 prompt: prompt::review(&prompt::ReviewCtx {
5436 instruction: &self.state.instruction,
5437 branch: &winner.branch,
5438 base_short: &base_short,
5439 stat: &stat,
5440 patch: &patch,
5441 verification: prev_verification.as_ref(),
5442 prior: prior_section.as_deref(),
5443 reviewers: reviewers.len(),
5444 round,
5445 rounds: max_rounds,
5446 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5449 lens: Lens::for_seat(r),
5450 language: &language,
5451 }),
5452 spec,
5453 seat,
5454 cwd: wt,
5455 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5456 allow_write: false,
5457 sessions,
5458 artifacts: artifacts.clone(),
5459 stem: format!("review-{round}-{}", r + 1),
5460 handover: None,
5461 });
5462 }
5463
5464 self.state.event(
5465 "review",
5466 format!(
5467 "round {round}: {} reviewers on {}",
5468 jobs.len(),
5469 short(&head)
5470 ),
5471 );
5472 let mut quota_losses = Vec::new();
5473 let review_retries = self.state.config.graph.retries;
5474 let review_cache = self.state.config.cache_dir();
5475 let ctx = WaveCtx {
5476 carry_seats: true,
5477 pauses: Some((&self.pause, &self.interrupt)),
5478 run: &run_id,
5479 node: "review",
5480 prompts: &prompts,
5481 cache: review_cache.as_deref(),
5482 round: Some(round),
5483 };
5484 let results = ask_json_wave::<Review>(
5485 jobs,
5486 Arc::clone(&self.sem),
5487 review_retries,
5488 &self.roles.reviewer_roster,
5489 &ctx,
5490 &mut quota_losses,
5491 &mut self.state,
5492 &|_: &Review| Ok(()),
5493 )
5494 .await;
5495 if self.state.resource_block.is_some() {
5496 return self.stop_for_resource();
5497 }
5498 let round_quota_missing = quota_losses.len();
5502 self.state.quota.extend(quota_losses);
5503
5504 let mut records = Vec::new();
5505 let mut all_findings = Vec::new();
5506 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5507 let agent_id = seat.agent.clone();
5508 self.state.seats.insert(seat.key.clone(), seat);
5509 let mut record = ReviewRecord {
5510 reviewer: r + 1,
5511 agent: agent_id,
5512 summary: String::new(),
5513 findings: Vec::new(),
5514 vote: None,
5515 failed: None,
5516 duration_ms: 0,
5517 attempts,
5523 };
5524 match res {
5525 Ok((review, out)) => {
5526 record.summary =
5534 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5535 record.vote = Some(review.vote);
5536 record.duration_ms = out.duration_ms;
5537 for (n, mut f) in review.findings.into_iter().enumerate() {
5538 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5541 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5542 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5543 f.file = f
5549 .file
5550 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5551 all_findings.push(f.clone());
5552 record.findings.push(f);
5553 }
5554 self.state.event(
5555 "review",
5556 format!(
5557 "round {round}: reviewer {} voted {} with {} finding(s)",
5558 r + 1,
5559 review.vote.label(),
5560 record.findings.len()
5561 ),
5562 );
5563 }
5564 Err(e) => {
5565 record.failed = Some(e.to_string());
5566 self.state.event(
5567 "review",
5568 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5569 );
5570 }
5571 }
5572 records.push(record);
5573 }
5574
5575 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5582 let vote_split =
5583 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5584 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5585 if vote_split {
5586 self.state.event(
5587 "review",
5588 format!(
5589 "round {round}: votes split ({}) — one round of reconsideration",
5590 initial_votes
5591 .iter()
5592 .map(|v| v.label())
5593 .collect::<Vec<_>>()
5594 .join(", ")
5595 ),
5596 );
5597 let panel: Vec<ReviewSeatReport<'_>> = records
5600 .iter()
5601 .filter_map(|r| {
5602 r.vote.map(|vote| ReviewSeatReport {
5603 reviewer: r.reviewer,
5604 vote,
5605 summary: &r.summary,
5606 findings: &r.findings,
5607 })
5608 })
5609 .collect();
5610
5611 let mut jobs = Vec::new();
5612 let mut seats_at = Vec::new();
5613 for (r, spec) in reviewers.iter().cloned().enumerate() {
5614 if records[r].vote.is_none() {
5618 continue;
5619 }
5620 let wt = root.join(format!("review-{}", r + 1));
5621 let seat_key = format!("review-{}", r + 1);
5622 let spec = self.occupant(&seat_key, spec);
5623 let seat = self.seat(&seat_key, &spec.id);
5624 let build = |with_patch: bool| {
5632 prompt::review_reconsider(&ReviewReconsiderCtx {
5633 instruction: &self.state.instruction,
5634 reviewer: r + 1,
5635 lens: Lens::for_seat(r),
5636 panel: &panel,
5637 patch: with_patch.then_some(ReviewPatch {
5638 branch: &winner.branch,
5639 base_short: &base_short,
5640 stat: &stat,
5641 patch: &patch,
5642 }),
5643 round,
5644 rounds: max_rounds,
5645 language: &language,
5646 })
5647 };
5648 let full = build(true);
5649 let prompt = if has_context(&spec, &seat, sessions) {
5650 build(false)
5651 } else {
5652 full.clone()
5653 };
5654 jobs.push(SeatJob {
5655 prompt,
5656 spec,
5657 seat,
5658 cwd: wt,
5659 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5660 allow_write: false,
5661 sessions,
5662 artifacts: artifacts.clone(),
5663 stem: format!("review-{round}-reconsider-{}", r + 1),
5664 handover: Some(full),
5665 });
5666 seats_at.push(r);
5667 }
5668
5669 let mut recon_quota_losses = Vec::new();
5670 let recon_cache = self.state.config.cache_dir();
5671 let recon_ctx = WaveCtx {
5672 carry_seats: true,
5673 pauses: Some((&self.pause, &self.interrupt)),
5674 run: &run_id,
5675 node: "review",
5676 prompts: &prompts,
5677 cache: recon_cache.as_deref(),
5678 round: Some(round),
5679 };
5680 let recon_results = ask_json_wave::<ReviewRevote>(
5681 jobs,
5682 Arc::clone(&self.sem),
5683 review_retries,
5684 &self.roles.reviewer_roster,
5685 &recon_ctx,
5686 &mut recon_quota_losses,
5687 &mut self.state,
5688 &|_: &ReviewRevote| Ok(()),
5689 )
5690 .await;
5691 if self.state.resource_block.is_some() {
5692 return self.stop_for_resource();
5693 }
5694 self.state.quota.extend(recon_quota_losses);
5695
5696 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5697 let agent_id = seat.agent.clone();
5698 self.state.seats.insert(seat.key.clone(), seat);
5699 let mut rec = ReviewRevoteRecord {
5700 reviewer: r + 1,
5701 agent: agent_id,
5702 vote: None,
5703 reason: String::new(),
5704 failed: None,
5705 };
5706 match res {
5707 Ok((rv, _)) => {
5708 rec.vote = Some(rv.vote);
5709 rec.reason =
5710 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5711 self.state.event(
5712 "review",
5713 format!(
5714 "round {round}: reviewer {} revoted {}",
5715 r + 1,
5716 rv.vote.label()
5717 ),
5718 );
5719 }
5720 Err(e) => {
5721 rec.failed = Some(e.to_string());
5722 self.state.event(
5723 "review",
5724 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5725 );
5726 }
5727 }
5728 reconsideration.push(rec);
5729 }
5730 } else if initial_votes.len() > 1 {
5731 self.state.event(
5732 "review",
5733 format!(
5734 "round {round}: votes agreed ({}) — no reconsideration",
5735 initial_votes[0].label()
5736 ),
5737 );
5738 }
5739
5740 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5741 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5742 let defer_e2e =
5753 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5754 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5755 let reason =
5756 format!("{blocking} blocking finding(s) already required a fix this round");
5757 self.state.event(
5758 "verify",
5759 format!(
5760 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5761 {}); it will run once a round has none left",
5762 short(&head)
5763 ),
5764 );
5765 (Vec::new(), false, true, Some(reason))
5766 } else {
5767 let e2e_commands = self.state.config.verify.e2e.clone();
5768 let cache_dir = self.state.config.cache_dir();
5769 let context = format!("round {round}");
5770 let (e2e, verify_retried) = with_cache_lease(
5771 &mut self.state,
5772 cache_dir.as_deref(),
5773 "e2e",
5774 "e2e",
5775 &winner.worktree,
5776 &head,
5777 verify_timeout,
5778 &context,
5779 |state, budget| {
5780 let shell = shell.clone();
5781 let e2e_commands = e2e_commands.clone();
5782 let worktree = winner.worktree.clone();
5783 let context = context.clone();
5784 async move {
5785 run_e2e_with_retry(
5786 state,
5787 &shell,
5788 &e2e_commands,
5789 &worktree,
5790 budget,
5791 &context,
5792 )
5793 .await
5794 }
5795 },
5796 )
5797 .await;
5798 (e2e, verify_retried, false, None)
5799 };
5800
5801 let expected = records.len();
5802 let answered = records.iter().filter(|r| r.failed.is_none()).count();
5803 let incomplete = answered < expected;
5804 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5805 let policy = self.state.config.graph.incomplete_review;
5806 let clean = round_is_clean(
5807 blocking,
5808 e2e_ok,
5809 answered,
5810 expected,
5811 round_quota_missing,
5812 policy,
5813 );
5814
5815 let mut round_record = ReviewRound {
5816 round,
5817 head: head.clone(),
5818 verified_head: None,
5819 verified_at: None,
5820 reviews: records,
5821 e2e,
5822 verify_retried,
5823 e2e_deferred,
5824 e2e_defer_reason,
5825 fix: None,
5826 blocking,
5827 answered,
5828 expected,
5829 clean,
5830 progressed: false,
5831 vote_split,
5832 reconsideration,
5833 verdict: None,
5834 };
5835 round_record.verdict = ReviewVote::worst(
5839 round_record
5840 .final_votes()
5841 .into_iter()
5842 .map(|(_, _, vote)| vote),
5843 );
5844 if !matches!(
5855 round_record.e2e_status(),
5856 E2eStatus::Deferred | E2eStatus::NotConfigured
5857 ) {
5858 round_record.verified_head = Some(head.clone());
5859 round_record.verified_at = Some(Timestamp::now());
5860 }
5861 let this_round_verification = round_record.verification_summary(&head);
5862
5863 if incomplete {
5864 let missing: Vec<String> = round_record
5865 .reviews
5866 .iter()
5867 .filter(|r| r.failed.is_some())
5868 .map(|r| format!("review-{}", r.reviewer))
5869 .collect();
5870 self.state.event(
5871 "review",
5872 format!(
5873 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5874 missing.join(", ")
5875 ),
5876 );
5877 }
5878
5879 if clean {
5880 self.state.event(
5881 "review",
5882 if incomplete && policy == IncompleteReviewPolicy::Warn {
5883 format!(
5884 "round {round}: clean (warn policy, incomplete panel) — no \
5885 blocking findings from the seats that answered, verification green"
5886 )
5887 } else if incomplete {
5888 format!(
5889 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5890 quorum) — no blocking findings from the seats that answered, \
5891 verification green",
5892 expected - answered
5893 )
5894 } else {
5895 format!("round {round}: clean — no blocking findings, verification green")
5896 },
5897 );
5898 self.state.reviews.push(round_record);
5899 self.state.status = RunStatus::Gating;
5900 self.state.save()?;
5901 return Ok(());
5902 }
5903
5904 if incomplete && blocking == 0 && e2e_ok {
5912 self.state.reviews.push(round_record);
5913 self.state.save()?;
5914 if round == max_rounds {
5915 self.state.status = RunStatus::Blocked;
5916 self.state.event(
5917 "review",
5918 format!(
5919 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5920 refusing to call it clean",
5921 expected - answered
5922 ),
5923 );
5924 return Ok(());
5925 }
5926 continue;
5927 }
5928
5929 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5941 self.state.reviews.push(round_record);
5942 return self
5943 .stop_reviewing(
5944 "the round's own verification could not run",
5945 &shell,
5946 &winner.worktree,
5947 )
5948 .await;
5949 }
5950
5951 if round == max_rounds {
5952 self.state.reviews.push(round_record);
5953 return self
5954 .stop_reviewing(
5955 &format!(
5956 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5957 ),
5958 &shell,
5959 &winner.worktree,
5960 )
5961 .await;
5962 }
5963
5964 let blocking_findings: Vec<_> = all_findings
5967 .iter()
5968 .filter(|f| f.severity.blocks())
5969 .cloned()
5970 .collect();
5971 let fix_prompt = prompt::fix(
5972 &self.state.instruction,
5973 &blocking_findings,
5974 this_round_verification.as_ref(),
5975 round,
5976 max_rounds,
5977 &language,
5978 );
5979 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5980 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5981 let (job, seat, out) = self
5982 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5983 prompt: fix_prompt.clone(),
5984 spec,
5985 seat,
5986 cwd: winner.worktree.clone(),
5987 timeout,
5988 allow_write: true,
5989 sessions,
5990 artifacts: artifacts.clone(),
5991 stem: format!("fix-{round}"),
5992 handover: None,
5993 })
5994 .await;
5995 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5998 return self.stop_for_resource();
5999 }
6000 let agent_id = seat.agent.clone();
6001
6002 let mut fix = FixRecord {
6003 agent: agent_id,
6004 addressed: Vec::new(),
6005 rejected: Vec::new(),
6006 notes: String::new(),
6007 committed: false,
6008 failed: None,
6009 duration_ms: 0,
6010 continuation: None,
6011 };
6012 let mut continuation = ContinuationRecord::not_needed();
6013 let mut final_seat = seat.clone();
6014 match out {
6015 AgentOutcome::Ok(o) => {
6016 fix.duration_ms = o.duration_ms;
6017 let parsed = verdict::extract_json::<FixReport>(&o.text);
6018 let incomplete_reason = match &parsed {
6025 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6026 "the reply parsed, but it reported a command whose own CLI never \
6027 confirmed an exit status"
6028 .to_owned(),
6029 ),
6030 Ok(_) => None,
6031 Err(e) => Some(e.to_string()),
6032 };
6033 match incomplete_reason {
6034 None => {
6035 let report = parsed.expect("checked Ok above");
6036 fix.addressed = report.addressed;
6037 fix.rejected = report.rejected;
6038 fix.notes =
6039 blind::sanitize_prose(&report.notes, &self.state.config.blind);
6040 }
6041 Some(reason) => {
6042 let (resumed_seat, resolved, failure, cont) = self
6043 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6044 .await;
6045 fix.duration_ms += cont.cumulative_wait_ms;
6046 continuation = cont;
6047 final_seat = resumed_seat;
6048 match resolved {
6049 Some(report) => {
6050 fix.addressed = report.addressed;
6051 fix.rejected = report.rejected;
6052 fix.notes = blind::sanitize_prose(
6053 &report.notes,
6054 &self.state.config.blind,
6055 );
6056 }
6057 None => fix.failed = failure,
6058 }
6059 }
6060 }
6061 }
6062 AgentOutcome::Dropped(o) => {
6064 fix.duration_ms = o.duration_ms;
6065 let why = o
6066 .dropped
6067 .as_ref()
6068 .map(|d| d.why.as_str())
6069 .unwrap_or("the CLI ended the stream without delivering its answer");
6070 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6071 }
6072 AgentOutcome::Quota(o) => {
6073 self.state.quota.push(QuotaLoss {
6074 seat: final_seat.key.clone(),
6075 node: "fix".to_owned(),
6076 at: Timestamp::now(),
6077 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6078 });
6079 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6080 }
6081 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6082 }
6083 fix.continuation = Some(continuation);
6084 self.state.seats.insert(final_seat.key.clone(), final_seat);
6085 if let Ok(r) = git::rescue_commit(
6086 &winner.worktree,
6087 &format!("magi: review round {round} fixes (uncommitted work)"),
6088 )
6089 .await
6090 {
6091 self.state.note_withheld("fix", &r.withheld);
6092 }
6093 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6094 fix.committed = after != before;
6095 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6103 let progressed = diff_after != patch;
6104 let commit_note = if fix.committed {
6105 "committed"
6106 } else {
6107 "NO new commit"
6108 };
6109 let tree_note = if progressed {
6110 "changed vs base"
6111 } else {
6112 "unchanged vs base"
6113 };
6114 self.state.event(
6115 "fix",
6116 match &fix.failed {
6117 Some(reason) => {
6123 format!(
6124 "round {round}: fixer's adoption report was lost ({reason}); \
6125 {commit_note}, tree {tree_note}"
6126 )
6127 }
6128 None => format!(
6129 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6130 {tree_note}{}",
6131 fix.addressed.len(),
6132 fix.rejected.len(),
6133 if continuation.outcome == ContinuationOutcome::Resumed {
6134 format!(
6135 " (adoption report recovered after {} continuation(s))",
6136 continuation.attempts
6137 )
6138 } else {
6139 String::new()
6140 },
6141 ),
6142 },
6143 );
6144 round_record.fix = Some(fix);
6145 round_record.progressed = progressed;
6146 self.state.reviews.push(round_record);
6147 self.state.save()?;
6148
6149 if matches!(
6162 continuation.outcome,
6163 ContinuationOutcome::Exhausted
6164 | ContinuationOutcome::QuotaLost
6165 | ContinuationOutcome::NoSession
6166 ) {
6167 return self
6168 .stop_reviewing(
6169 "the fixer's adoption report never came back, even after resuming its \
6170 own seat; refusing to start another round against the same worktree \
6171 while that is unresolved",
6172 &shell,
6173 &winner.worktree,
6174 )
6175 .await;
6176 }
6177
6178 let streak = self
6179 .state
6180 .reviews
6181 .iter()
6182 .rev()
6183 .take_while(|r| !r.progressed)
6184 .count();
6185 if streak >= STAGNANT_LIMIT {
6186 return self
6187 .stop_reviewing(
6188 &format!(
6189 "the tree has not moved against base for {streak} round(s) in a row"
6190 ),
6191 &shell,
6192 &winner.worktree,
6193 )
6194 .await;
6195 }
6196 }
6197 Ok(())
6198 }
6199
6200 fn record_contested_handoff(&mut self) {
6234 if self.state.contested_handoff.is_some() {
6235 return;
6236 }
6237 let Some(contested) = self
6238 .state
6239 .reviews
6240 .last()
6241 .and_then(ReviewRound::contested_handoff)
6242 else {
6243 return;
6244 };
6245 self.state.event(
6246 "review",
6247 format!(
6248 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6249 wait for the owner's approval",
6250 contested.findings.len(),
6251 contested.rejecters.len()
6252 ),
6253 );
6254 self.state.contested_handoff = Some(contested);
6255 }
6256
6257 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6258 let round_idx = self.state.reviews.len() - 1;
6259 let needs_catchup_run = matches!(
6267 self.state.reviews[round_idx].e2e_status(),
6268 E2eStatus::Deferred | E2eStatus::ResourceBlocked
6269 );
6270 if needs_catchup_run {
6271 let round = self.state.reviews[round_idx].round;
6272 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6273 let commands = self.state.config.verify.e2e.clone();
6274 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6275 let cache_dir = self.state.config.cache_dir();
6276 let context = format!(
6277 "round {round}: verification unresolved, catching up before the final decision"
6278 );
6279 let (outcomes, verify_retried) = with_cache_lease(
6280 &mut self.state,
6281 cache_dir.as_deref(),
6282 "e2e",
6283 "e2e",
6284 worktree,
6285 &attempted_head,
6286 timeout,
6287 &context,
6288 |state, budget| {
6289 let shell = shell.to_vec();
6290 let commands = commands.clone();
6291 let context = context.clone();
6292 async move {
6293 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6294 .await
6295 }
6296 },
6297 )
6298 .await;
6299 let last = &mut self.state.reviews[round_idx];
6300 last.e2e = outcomes;
6301 last.verify_retried = verify_retried;
6302 last.verified_head = Some(attempted_head);
6309 last.verified_at = Some(Timestamp::now());
6310 if verify_inconclusive(&last.e2e) {
6311 self.state.save()?;
6318 return Ok(());
6319 }
6320 last.e2e_deferred = false;
6321 }
6322 let last = &self.state.reviews[round_idx];
6323 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6324
6325 match last.e2e_status() {
6326 E2eStatus::Failed => {
6327 let red: Vec<String> = last
6328 .e2e
6329 .iter()
6330 .filter(|o| !o.ok())
6331 .map(|o| {
6332 format!(
6333 "`{}` -> {:?}\n{}",
6334 o.command,
6335 o.code,
6336 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6337 )
6338 })
6339 .collect();
6340 self.state
6341 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6342 self.state.status = RunStatus::Blocked;
6343 }
6344 E2eStatus::ResourceBlocked => {
6349 self.state.event(
6350 "review",
6351 format!(
6352 "{why}; e2e could not run (shared build cache unavailable); not \
6353 deciding yet"
6354 ),
6355 );
6356 }
6357 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6358 self.state.event(
6359 "review",
6360 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6361 );
6362 self.record_contested_handoff();
6363 self.state.status = RunStatus::Gating;
6364 }
6365 }
6366 self.state.save()?;
6367 Ok(())
6368 }
6369
6370 async fn gate(&mut self) -> Result<()> {
6373 if self.state.status == RunStatus::Failed
6385 || 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 {
6393 return Ok(());
6394 }
6395 if self.state.gate_ran {
6396 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6407 self.state.status = RunStatus::Blocked;
6408 self.state.save()?;
6409 }
6410 return Ok(());
6411 }
6412 let Some(winner) = self.state.winner().cloned() else {
6413 return Ok(());
6414 };
6415 self.state.status = RunStatus::Gating;
6416 let mut outcomes = self.run_gate(&winner).await?;
6417 loop {
6418 if verify_inconclusive(&outcomes) {
6429 self.state.save()?;
6430 return Ok(());
6431 }
6432 if outcomes.iter().all(CommandOutcome::ok) {
6433 break;
6434 }
6435 match self.gate_fix_round(&winner, &outcomes).await? {
6436 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6437 GateFix::Stop => break,
6438 GateFix::Defer => {
6439 self.state.save()?;
6440 return Ok(());
6441 }
6442 }
6443 }
6444 let passed = outcomes.iter().all(CommandOutcome::ok);
6445 self.state.gate = outcomes;
6446 self.state.gate_ran = true;
6447 self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6448 if !passed {
6449 self.state.status = RunStatus::Blocked;
6450 let spent = self.state.gate_fixes.len();
6451 self.state.event(
6452 "gate",
6453 if spent == 0 {
6454 "gate failed; not merging".to_owned()
6455 } else {
6456 format!("gate failed after {spent} gate-fix round(s); not merging")
6457 },
6458 );
6459 }
6460 self.state.save()?;
6461 Ok(())
6462 }
6463
6464 async fn run_pre_gate(&mut self, winner: &Candidate) {
6474 let commands = self.state.config.verify.pre_gate.clone();
6475 if commands.is_empty() {
6476 return;
6477 }
6478 let shell = self.state.config.shell();
6479 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6480 let (outcomes, _) = run_commands(
6481 &mut self.state,
6482 "pre_gate",
6483 "pre_gate",
6484 0,
6485 &shell,
6486 &commands,
6487 &winner.worktree,
6488 timeout,
6489 )
6490 .await;
6491 for o in &outcomes {
6492 if !o.ok() {
6493 tracing::warn!(
6494 "pre_gate `{}` failed ({:?}); the gate decides",
6495 o.command,
6496 o.code
6497 );
6498 }
6499 self.state.event(
6500 "pre_gate",
6501 format!(
6502 "`{}` -> {}",
6503 o.command,
6504 if o.ok() {
6505 "pass".to_owned()
6506 } else {
6507 format!(
6508 "FAIL ({:?})\n{}",
6509 o.code,
6510 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6511 )
6512 }
6513 ),
6514 );
6515 }
6516 self.state.pre_gate = outcomes;
6517 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6518 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6519 Ok(head) => {
6520 self.state
6521 .event("pre_gate", format!("committed mechanical fixes ({head})"));
6522 self.state.pre_gate_commit = Some(head);
6523 }
6524 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6525 },
6526 Ok(false) => {}
6527 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6528 }
6529 if let Err(e) = self.state.save() {
6530 tracing::warn!("could not persist the pre_gate record: {e:#}");
6531 }
6532 }
6533
6534 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6537 self.run_pre_gate(winner).await;
6538 let shell = self.state.config.shell();
6539 let gate_commands = self.state.config.verify.gate.clone();
6540 let outcomes = if gate_commands.is_empty() {
6549 Vec::new()
6550 } else {
6551 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6552 let cache_dir = self.state.config.cache_dir();
6553 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6554 let (outcomes, _) = with_cache_lease(
6555 &mut self.state,
6556 cache_dir.as_deref(),
6557 "gate",
6558 "gate",
6559 &winner.worktree,
6560 &head,
6561 timeout,
6562 "final gate",
6563 |state, budget| {
6564 let shell = shell.clone();
6565 let gate_commands = gate_commands.clone();
6566 let worktree = winner.worktree.clone();
6567 async move {
6568 let (outcomes, timed_out_pids) = run_commands(
6569 state,
6570 "gate",
6571 "gate",
6572 0,
6573 &shell,
6574 &gate_commands,
6575 &worktree,
6576 budget,
6577 )
6578 .await;
6579 (outcomes, false, timed_out_pids)
6580 }
6581 },
6582 )
6583 .await;
6584 outcomes
6585 };
6586 if outcomes.is_empty() {
6587 self.state.event(
6592 "gate",
6593 "no gate commands configured; nothing to check, passing",
6594 );
6595 }
6596 for o in &outcomes {
6597 self.state.event(
6598 "gate",
6599 format!(
6600 "`{}` -> {}",
6601 o.command,
6602 if o.ok() {
6603 "pass".to_owned()
6604 } else {
6605 format!(
6606 "FAIL ({:?})\n{}",
6607 o.code,
6608 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6609 )
6610 }
6611 ),
6612 );
6613 }
6614 Ok(outcomes)
6615 }
6616
6617 async fn gate_fix_round(
6630 &mut self,
6631 winner: &Candidate,
6632 outcomes: &[CommandOutcome],
6633 ) -> Result<GateFix> {
6634 let cap = self.state.config.graph.gate_fix_rounds;
6635 let spent = self.state.gate_fixes.len();
6636 if spent >= cap {
6637 if cap > 0 {
6638 self.state.event(
6639 "gate",
6640 format!("{spent} gate-fix round(s) spent and the gate still fails"),
6641 );
6642 }
6643 return Ok(GateFix::Stop);
6644 }
6645 if !gate_fixable(outcomes) {
6646 self.state.event(
6647 "gate",
6648 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6649 command or similar); not spending a fix round on it",
6650 );
6651 return Ok(GateFix::Stop);
6652 }
6653 let min_free = self.state.config.disk.min_free_bytes;
6654 if min_free > 0 {
6655 match crate::disk::free_bytes(&winner.worktree) {
6656 Ok(free) if crate::disk::enough_space(free, min_free) => {}
6657 Ok(free) => {
6658 self.state.event(
6659 "gate",
6660 format!(
6661 "only {free} bytes free ({min_free} required by `[disk] \
6662 min_free_bytes`); not spending a fix round on a failure the disk \
6663 may explain"
6664 ),
6665 );
6666 return Ok(GateFix::Stop);
6667 }
6668 Err(e) => {
6669 self.state.event(
6670 "gate",
6671 format!("free disk space could not be measured ({e:#}); no fix round"),
6672 );
6673 return Ok(GateFix::Stop);
6674 }
6675 }
6676 }
6677
6678 let attempt = spent + 1;
6679 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6680 let base = self.landing_base();
6681 let fix_prompt = prompt::gate_fix(
6682 &self.state.instruction,
6683 &failed,
6684 attempt,
6685 cap,
6686 &self.state.config.graph.language,
6687 );
6688 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6689 let sessions = self.state.config.graph.sessions;
6690 let artifacts = agent::artifacts_dir(&self.state.dir());
6691 self.state.event(
6692 "gate",
6693 format!("gate failed; gate-fix round {attempt} of {cap}"),
6694 );
6695 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6696 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6697 let (_, seat, out) = self
6698 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6699 prompt: fix_prompt.clone(),
6700 spec,
6701 seat,
6702 cwd: winner.worktree.clone(),
6703 timeout,
6704 allow_write: true,
6705 sessions,
6706 artifacts: artifacts.clone(),
6707 stem: format!("gate-fix-{attempt}"),
6708 handover: None,
6709 })
6710 .await;
6711 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6714 return Ok(GateFix::Defer);
6715 }
6716 let mut record = GateFixRecord {
6717 agent: seat.agent.clone(),
6718 failed,
6719 notes: String::new(),
6720 committed: false,
6721 error: None,
6722 };
6723 match out {
6724 AgentOutcome::Ok(o) => {
6725 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6728 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6729 }
6730 }
6731 AgentOutcome::Dropped(_) => {
6732 record.error = Some("the CLI dropped the stream".to_owned());
6733 }
6734 AgentOutcome::Quota(o) => {
6735 self.state.quota.push(QuotaLoss {
6736 seat: seat.key.clone(),
6737 node: "gate-fix".to_owned(),
6738 at: Timestamp::now(),
6739 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6740 });
6741 record.error = Some("rate limited (quota); fixer could not run".to_owned());
6742 }
6743 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6744 }
6745 self.state.seats.insert(seat.key.clone(), seat);
6746 if let Ok(r) = git::rescue_commit(
6747 &winner.worktree,
6748 &format!("magi: gate fix {attempt} (uncommitted work)"),
6749 )
6750 .await
6751 {
6752 self.state.note_withheld("gate-fix", &r.withheld);
6753 }
6754 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6755 record.committed = after != before;
6756 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6757 let note = record.error.clone();
6758 self.state.gate_fixes.push(record);
6759 self.state.save()?;
6760 if !changed {
6761 self.state.event(
6762 "gate",
6763 match note {
6764 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6765 None => format!("gate-fix round {attempt}: the tree did not change"),
6766 },
6767 );
6768 return Ok(GateFix::Stop);
6769 }
6770 self.state.event(
6771 "gate",
6772 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6773 );
6774
6775 let commands = self.state.config.verify.e2e.clone();
6776 if !commands.is_empty() {
6777 let shell = self.state.config.shell();
6778 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6779 let cache_dir = self.state.config.cache_dir();
6780 let context = format!("gate-fix round {attempt}");
6781 let (e2e, _) = with_cache_lease(
6782 &mut self.state,
6783 cache_dir.as_deref(),
6784 "e2e",
6785 "e2e",
6786 &winner.worktree,
6787 &after,
6788 timeout,
6789 &context,
6790 |state, budget| {
6791 let shell = shell.clone();
6792 let commands = commands.clone();
6793 let context = context.clone();
6794 let worktree = winner.worktree.clone();
6795 async move {
6796 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6797 .await
6798 }
6799 },
6800 )
6801 .await;
6802 if verify_inconclusive(&e2e) {
6803 return Ok(GateFix::Defer);
6804 }
6805 if e2e.iter().any(|o| !o.ok()) {
6806 self.state.event(
6807 "gate",
6808 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6809 );
6810 return Ok(GateFix::Stop);
6811 }
6812 }
6813 Ok(GateFix::Retry)
6814 }
6815
6816 async fn guarded_pr_message(
6823 &mut self,
6824 winner: &Candidate,
6825 facts: Option<&BranchFacts>,
6826 posting: bool,
6827 ) -> Result<Option<PrMessage>> {
6828 let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6829 let head = match final_head.as_deref() {
6830 Some(h) => crate::pr_outcome::FinalHead::At(h),
6831 None => crate::pr_outcome::FinalHead::Unknown,
6832 };
6833 let summarized = if posting && is_review_run(&self.state) {
6835 Some(self.summarize_change(winner, final_head.as_deref()).await)
6836 } else {
6837 None
6838 };
6839 if self.state.resource_block.is_some() {
6844 self.state.event(
6845 "pr-summary",
6846 "a shared resource was busy; nothing was published, resume to summarize again",
6847 );
6848 return Ok(None);
6849 }
6850 let note = match (&summarized, &self.state.change_summary) {
6851 (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
6852 (Some(Err(why)), _) => ChangeNote::Unavailable(why),
6853 (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
6854 (None, _) => ChangeNote::NotAsked,
6855 };
6856 let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
6857 if !posting {
6858 let identity = crate::scrub::Identity::current();
6859 return Ok(Some(PrMessage {
6860 title: crate::scrub::scrub(&pr.title, &identity),
6861 body: crate::scrub::scrub(&pr.body, &identity),
6862 }));
6863 }
6864 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6867 if let Some(known) = self
6868 .state
6869 .github_text
6870 .clone()
6871 .filter(|g| g.fingerprint == raw_fp)
6872 {
6873 return self.settle_withheld(winner, &pr, known).await;
6874 }
6875 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6876 let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6877 let identity = crate::scrub::Identity::current();
6878 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6879 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6880 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6881 {
6882 violations.push(crate::github_text::Violation::SensitiveData);
6883 }
6884 let has_language = violations
6888 .iter()
6889 .any(|v| *v != crate::github_text::Violation::SensitiveData);
6890 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6891 self.state.event(
6892 "github-text",
6893 if has_language {
6894 format!("description rejected: {violations:?}; requesting one rewrite")
6895 } else {
6896 format!("description rejected: {violations:?}; asking the owner")
6897 },
6898 );
6899 let mut rewritten = false;
6900 let mut rejected_rewrite = None;
6901 if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
6902 {
6903 let output = text;
6904 if let Some(title) = summary_title(&output) {
6905 let raw_body = summary_without_title(&output);
6908 let clean_title = crate::scrub::scrub(&title, &identity);
6909 let mut body = crate::scrub::scrub(&raw_body, &identity);
6910 let redacted = clean_title != title || body != raw_body;
6911 let title = clean_title;
6912 let marker = format!("magi:run/{}", self.state.id);
6913 if !body.trim().is_empty() && !body.contains(&marker) {
6914 body.push_str(&format!("\n\n{marker}"));
6915 }
6916 let rewrite = PrMessage {
6917 title: title.clone(),
6918 body: body.clone(),
6919 };
6920 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6921 let mut failed = crate::github_text::check_with(&title, &body, redecision);
6922 if (!crate::github_text::shareable(&title)
6923 || !crate::github_text::shareable(&body))
6924 && !failed.contains(&crate::github_text::Violation::SensitiveData)
6925 {
6926 failed.push(crate::github_text::Violation::SensitiveData);
6927 }
6928 if !title.trim().is_empty() && !body.trim().is_empty() && !failed.is_empty() {
6929 rejected_rewrite = Some(crate::run::GithubTextRewrite {
6930 title: title.clone(),
6931 body: body.clone(),
6932 failed: failed
6933 .iter()
6934 .map(|v| crate::github_text::category(*v).to_owned())
6935 .collect(),
6936 });
6937 }
6938 if !title.trim().is_empty() && !body.trim().is_empty() && failed.is_empty() {
6939 decision = redecision;
6940 pr = PrMessage { title, body };
6941 rewritten = true;
6942 self.state
6943 .event("github-text", "description rewrite passed");
6944 if redacted {
6945 self.state.event(
6946 "github-text",
6947 "sensitive data removed from description rewrite",
6948 );
6949 }
6950 }
6951 }
6952 }
6953 if !rewritten {
6954 self.state.event(
6955 "github-text",
6956 "description rewrite unavailable or rejected; asking the owner",
6957 );
6958 if let Some(w) =
6959 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6960 {
6961 self.retire_github_text_question("the withheld text changed");
6964 let record = crate::run::GithubTextAsk {
6965 rewrite: rejected_rewrite,
6966 fingerprint: raw_fp,
6967 title: w.title,
6968 body: w.body,
6969 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6970 question: None,
6971 asks: 0,
6972 resolved: false,
6973 chosen_title: None,
6974 choice: None,
6975 };
6976 return self.settle_withheld(winner, &pr, record).await;
6977 }
6978 }
6979 }
6980 let (title, body) =
6981 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6982 Ok(Some(PrMessage { title, body }))
6983 }
6984
6985 async fn summarize_change(
6996 &mut self,
6997 winner: &Candidate,
6998 head: Option<&str>,
6999 ) -> std::result::Result<(), String> {
7000 let Some(head) = head else {
7001 return Err("the final commit could not be read".to_owned());
7002 };
7003 let remote = self.state.config.merge.remote.clone();
7004 let tracking = format!("{remote}/{}", self.state.base_branch);
7005 if !git::rev_exists(&winner.worktree, &tracking).await {
7006 return Err(format!("the remote base `{tracking}` could not be read"));
7007 }
7008 let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7009 Ok(b) if !b.is_empty() => b,
7010 _ => return Err("the merge base with the remote base could not be read".to_owned()),
7011 };
7012 if self
7013 .state
7014 .change_summary
7015 .as_ref()
7016 .is_some_and(|c| c.head == head && c.base == base)
7017 {
7018 return Ok(());
7019 }
7020 if self.state.change_summary.take().is_some() {
7021 self.state.event(
7022 "pr-summary",
7023 "the final commit or base changed; the earlier change summary was discarded",
7024 );
7025 }
7026 let chain = match agent::pick_chain(
7027 &self.state.config.agents,
7028 self.state.config.roles.synthesizer.as_ref(),
7029 &agent::installed,
7030 "synthesizer",
7031 ) {
7032 Ok(chain) => chain,
7033 Err(e) => {
7034 tracing::warn!("no agent for the change summary: {e:#}");
7035 self.state.event("pr-summary", "no summary agent available");
7036 return Err("no agent was available to summarize it".to_owned());
7037 }
7038 };
7039 let subjects = git::subjects(&winner.worktree, &base, head)
7040 .await
7041 .unwrap_or_default();
7042 let stat = git::diff_stat(&winner.worktree, &base, head)
7043 .await
7044 .unwrap_or_default();
7045 let stat: String = stat.chars().take(6_000).collect();
7046 let prompt = crate::prompt::change_summary(&base, head, &subjects, stat.trim());
7047 let sessions = self.state.config.graph.sessions;
7048 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7049 let prompts = self.state.config.prompts.clone();
7050 let cache = self.state.config.cache_dir();
7051 let run = self.state.id.clone();
7052 for (i, spec) in chain.into_iter().enumerate() {
7053 let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7054 let job = SeatJob {
7055 spec: spec.clone(),
7056 seat,
7057 cwd: winner.worktree.clone(),
7058 prompt: prompt.clone(),
7059 timeout,
7060 allow_write: false,
7061 sessions,
7062 artifacts: agent::artifacts_dir(&self.state.dir()),
7063 stem: if i == 0 {
7064 "change-summary".to_owned()
7065 } else {
7066 format!("change-summary-{}", spec.id)
7067 },
7068 handover: None,
7069 };
7070 let ctx = WaveCtx {
7071 run: &run,
7072 node: "pr-summary",
7073 prompts: &prompts,
7074 cache: cache.as_deref(),
7075 round: None,
7076 carry_seats: false,
7077 pauses: Some((&self.pause, &self.interrupt)),
7078 };
7079 let (seat, outcome) =
7080 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7081 self.state.seats.insert(seat.key.clone(), seat);
7082 let text = match outcome {
7083 AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7084 AgentOutcome::ResourceBlocked(why) => {
7085 if self.state.resource_block.is_none() {
7088 self.state.resource_block = Some(crate::run::ResourceBlock {
7089 node: "pr-summary".to_owned(),
7090 kind: "shared resource".to_owned(),
7091 detail: why,
7092 at: Timestamp::now(),
7093 });
7094 }
7095 return Err("a shared resource was busy".to_owned());
7096 }
7097 _ => continue,
7098 };
7099 let blind = &self.state.config.blind;
7100 let Some(title) = summary_title(&text) else {
7101 continue;
7102 };
7103 let body: String = summary_without_title(&text)
7104 .lines()
7105 .filter(|l| !l.contains("magi:run/"))
7106 .collect::<Vec<_>>()
7107 .join("\n");
7108 let body = blind::sanitize_prose(body.trim(), blind);
7109 let title = blind::sanitize_prose(&title, blind);
7110 if body.trim().is_empty() || title.trim().is_empty() {
7111 continue;
7112 }
7113 if git::rev_parse(&winner.worktree, "HEAD")
7115 .await
7116 .ok()
7117 .as_deref()
7118 != Some(head)
7119 {
7120 return Err("the branch moved while the summary was written".to_owned());
7121 }
7122 self.state.change_summary = Some(crate::run::ChangeSummary {
7123 head: head.to_owned(),
7124 base,
7125 title,
7126 body,
7127 });
7128 self.state.event(
7129 "pr-summary",
7130 format!("change summary written by {} for the final diff", spec.id),
7131 );
7132 if let Err(e) = self.state.save() {
7133 tracing::warn!("could not save the change summary: {e:#}");
7134 }
7135 return Ok(());
7136 }
7137 self.state
7138 .event("pr-summary", "no agent produced a change summary");
7139 Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7140 }
7141
7142 async fn rewrite_pr_text(
7150 &mut self,
7151 winner: &Candidate,
7152 pr: &PrMessage,
7153 violations: &[crate::github_text::Violation],
7154 ) -> Option<String> {
7155 let prompt = format!(
7156 "Rewrite only the following pull request description into English. The posting gate reported {violations:?}. Keep every substantive detail (file names, commands, numbers, findings, code blocks and <details> sections) and translate the prose. 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{}",
7157 pr.title, pr.body
7158 );
7159 let sessions = self.state.config.graph.sessions;
7160 let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7161 let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7163 let key = format!("impl-{}", winner.label);
7164 if let Some(spec) = self
7165 .state
7166 .config
7167 .agents
7168 .iter()
7169 .find(|a| a.id == winner.agent)
7170 .cloned()
7171 && let Some(existing) = self.state.seats.get(&key)
7172 && existing.agent == spec.id
7173 && agent::has_session(spec.kind, existing, sessions)
7174 {
7175 attempts.push((spec, existing.clone(), true));
7176 } else {
7177 let chain = agent::pick_chain(
7178 &self.state.config.agents,
7179 self.state.config.roles.language_judge.as_ref(),
7180 &agent::installed,
7181 "language_judge",
7182 );
7183 match chain {
7184 Ok(chain) => {
7185 for spec in chain {
7186 let seat = crate::fixer::seat_for(
7187 &mut self.state,
7188 "github-text-rewrite",
7189 &spec.id,
7190 );
7191 attempts.push((spec, seat, false));
7192 }
7193 }
7194 Err(e) => {
7195 tracing::warn!("no agent for the description rewrite: {e:#}");
7196 self.state
7197 .event("github-text", "no rewrite agent available");
7198 return None;
7199 }
7200 }
7201 }
7202 let prompts = self.state.config.prompts.clone();
7203 let cache = self.state.config.cache_dir();
7204 let run = self.state.id.clone();
7205 for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7206 let job = SeatJob {
7207 spec: spec.clone(),
7208 seat,
7209 cwd: winner.worktree.clone(),
7210 prompt: prompt.clone(),
7211 timeout: retry_budget(timeout_full, resumed),
7212 allow_write: false,
7213 sessions,
7214 artifacts: agent::artifacts_dir(&self.state.dir()),
7215 stem: if i == 0 {
7216 "github-text-rewrite".to_owned()
7217 } else {
7218 format!("github-text-rewrite-{}", spec.id)
7219 },
7220 handover: None,
7221 };
7222 let ctx = WaveCtx {
7223 run: &run,
7224 node: "github-text",
7225 prompts: &prompts,
7226 cache: cache.as_deref(),
7227 round: None,
7228 carry_seats: false,
7229 pauses: Some((&self.pause, &self.interrupt)),
7230 };
7231 let (seat, outcome) =
7232 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7233 self.state.seats.insert(seat.key.clone(), seat);
7234 match outcome {
7235 AgentOutcome::Ok(o)
7236 if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7237 {
7238 return Some(o.text);
7239 }
7240 AgentOutcome::ResourceBlocked(_) => return None,
7241 _ => {}
7242 }
7243 }
7244 None
7245 }
7246
7247 fn retire_github_text_question(&mut self, why: &str) {
7249 let Some(id) = self
7250 .state
7251 .github_text
7252 .as_ref()
7253 .and_then(|g| g.question.clone())
7254 else {
7255 return;
7256 };
7257 let _ = ask::Questions::open().update(&id, |q| {
7258 q.abandon(why);
7259 Ok(())
7260 });
7261 }
7262
7263 async fn settle_withheld(
7267 &mut self,
7268 winner: &Candidate,
7269 pr: &PrMessage,
7270 mut g: crate::run::GithubTextAsk,
7271 ) -> Result<Option<PrMessage>> {
7272 use crate::github_text as gt;
7273 let store = ask::Questions::open();
7274 let timeout = self.state.config.graph.answer_timeout;
7275 if !g.resolved {
7276 let mut standing = g
7277 .question
7278 .as_ref()
7279 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7280 if let Some(q) = standing.as_ref()
7281 && q.status.open()
7282 && gt::expired(q, timeout)
7283 {
7284 let why = format!("no answer within {}s of asking", timeout.max(1));
7285 standing = store
7286 .update(&q.id, |q| {
7287 q.abandon(&why);
7288 Ok(())
7289 })
7290 .ok()
7291 .map(|(q, ())| q);
7292 }
7293 let mut ask_again: Option<Option<Vec<String>>> = None;
7294 match standing {
7295 None if g.asks <= 1 => {
7298 g.asks = 0;
7299 ask_again = Some(None);
7300 }
7301 None => {
7302 g.resolved = true;
7303 g.chosen_title = None;
7304 g.choice = Some(GithubTextChoice::Fallback);
7305 }
7306 Some(q) if q.status.open() => {
7307 self.park_on_github_text(&g)?;
7308 return Ok(None);
7309 }
7310 Some(q) => match gt::read_reply(&q) {
7311 gt::Reply::Fallback => {
7312 g.resolved = true;
7313 g.chosen_title = None;
7314 g.choice = Some(GithubTextChoice::Fallback);
7315 }
7316 gt::Reply::Original => {
7317 g.resolved = true;
7318 g.chosen_title = None;
7319 g.choice = Some(GithubTextChoice::Original);
7320 }
7321 gt::Reply::Rewrite => {
7322 g.resolved = true;
7323 g.chosen_title = None;
7324 g.choice = Some(if g.rewrite.is_some() {
7325 GithubTextChoice::Rewrite
7326 } else {
7327 GithubTextChoice::Fallback
7328 });
7329 }
7330 gt::Reply::Title(text) => {
7331 let first = text
7332 .lines()
7333 .map(str::trim)
7334 .find(|l| !l.is_empty())
7335 .unwrap_or("");
7336 let decision = if first.is_empty() {
7337 None
7338 } else {
7339 let candidate = PrMessage {
7340 title: first.to_owned(),
7341 body: String::new(),
7342 };
7343 self.judge_pr_language(&winner.worktree, &candidate).await
7344 };
7345 match gt::vet_title(&text, decision) {
7346 Ok(title) => {
7347 g.resolved = true;
7348 g.chosen_title = Some(title);
7349 g.choice = Some(GithubTextChoice::MyTitle);
7350 }
7351 Err(cats) if g.asks < 2 => {
7352 ask_again = Some(Some(
7353 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7354 ))
7355 }
7356 Err(_) => {
7357 g.resolved = true;
7358 g.chosen_title = None;
7359 g.choice = Some(GithubTextChoice::Fallback);
7360 }
7361 }
7362 }
7363 },
7364 }
7365 if let Some(rejected) = ask_again {
7366 let w = gt::Withheld {
7369 title: g.title,
7370 body: g.body,
7371 categories: g.categories.clone(),
7372 };
7373 let artifact = match crate::run::write_artifact(
7376 &self.state,
7377 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
7378 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7379 ) {
7380 Ok(p) => Some(p),
7381 Err(e) => {
7382 tracing::warn!("could not write the withheld-text artifact: {e:#}");
7383 None
7384 }
7385 };
7386 let mut q = ask::Question::new(
7387 self.state.id.clone(),
7388 gt::ASK_NODE.to_owned(),
7389 gt::ASK_SEAT.to_owned(),
7390 gt::question_summary(&self.state.config.graph.language, &w),
7391 gt::question_detail(
7392 &w,
7393 &pr.title,
7394 &pr.body,
7395 &self.state.id,
7396 rejected.as_deref(),
7397 artifact.as_deref(),
7398 g.rewrite.as_ref(),
7399 ),
7400 gt::question_choices(&w, g.rewrite.as_ref()),
7401 );
7402 q.answer_timeout = timeout;
7403 g.question = Some(q.id.clone());
7406 g.asks += 1;
7407 self.state.github_text = Some(g.clone());
7408 self.state.event(
7409 "github-text",
7410 format!("asking the owner about the withheld text ({})", q.short()),
7411 );
7412 self.state.save()?;
7413 store
7414 .put(&mut q)
7415 .context("file the posting-gate question")?;
7416 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7417 tracing::warn!(
7418 "could not notify about posting-gate question {}: {e:#}",
7419 q.short()
7420 );
7421 }
7422 self.park_on_github_text(&g)?;
7423 return Ok(None);
7424 }
7425 }
7426 let resolved = g.chosen_title.clone();
7427 let original = g.choice == Some(GithubTextChoice::Original);
7428 self.state.event(
7430 "github-text",
7431 if original {
7432 format!(
7433 "posting the original text for: {}",
7434 match (g.title, g.body) {
7435 (true, true) => "title, description",
7436 (true, false) => "title",
7437 _ => "description",
7438 }
7439 )
7440 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7441 "posting the saved rewrite title and description".to_owned()
7442 } else if resolved.is_some() {
7443 "posting the owner's replacement title".to_owned()
7444 } else {
7445 "posting the neutral text for the withheld fields".to_owned()
7446 },
7447 );
7448 self.state.github_text = Some(g.clone());
7449 self.state.save()?;
7450 if g.choice == Some(GithubTextChoice::Rewrite)
7451 && let Some(r) = g.rewrite.as_ref()
7452 {
7453 return Ok(Some(PrMessage {
7454 title: r.title.clone(),
7455 body: r.body.clone(),
7456 }));
7457 }
7458 let w = gt::Withheld {
7459 title: g.title,
7460 body: g.body,
7461 categories: g.categories.clone(),
7462 };
7463 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7464 if let Some(t) = resolved {
7465 title = t;
7466 }
7467 let english = gt::LanguageDecision {
7469 title_english: true,
7470 body_english: true,
7471 body_complete: true,
7472 };
7473 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7474 if original {
7475 if g.title {
7478 title = pr.title.clone();
7479 }
7480 if g.body {
7481 body = pr.body.clone();
7482 }
7483 }
7484 Ok(Some(PrMessage { title, body }))
7485 }
7486
7487 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7488 self.state.github_text = Some(g.clone());
7489 self.state.end_pass_parked(ParkCause::Question);
7490 self.state.parked = true;
7491 self.state.event(
7492 "github-text",
7493 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7494 );
7495 self.state.save()
7496 }
7497
7498 async fn judge_pr_language(
7501 &mut self,
7502 cwd: &Path,
7503 pr: &PrMessage,
7504 ) -> Option<crate::github_text::LanguageDecision> {
7505 self.state.config.roles.language_judge.as_ref()?;
7506 let decision =
7507 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7508 self.state.event(
7509 "github-text",
7510 if decision.is_some() {
7511 "language decided by the language judge"
7512 } else {
7513 "language judge unavailable; using built-in heuristics"
7514 },
7515 );
7516 decision
7517 }
7518
7519 async fn merge(&mut self) -> Result<()> {
7520 if self
7535 .state
7536 .base_sync
7537 .as_ref()
7538 .is_some_and(|s| s.conflict.is_some())
7539 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7540 != Some(RunStatus::Gating)
7541 || !self.state.gate_status().ok()
7550 {
7551 return Ok(());
7552 }
7553 if self.state.merge.is_some() && !self.state.pr_publication_pending {
7562 return Ok(());
7563 }
7564 let Some(winner) = self.state.winner().cloned() else {
7565 return Ok(());
7566 };
7567 let repo = self.state.repo.clone();
7568 let base = self.state.base_branch.clone();
7569 let mode = self.state.config.merge.mode;
7570 let style = self.state.config.merge.style;
7571 let facts = if is_review_run(&self.state) {
7572 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7573 let start = review_base(
7574 &repo,
7575 &self.state.config.merge.remote,
7576 &base,
7577 &self.state.base_commit,
7578 &winner.branch,
7579 )
7580 .await;
7581 branch_facts(&repo, &start, &winner.branch).await
7582 } else {
7583 None
7584 };
7585 let leaked = if is_review_run(&self.state) {
7588 leaked_subjects(&self.state, &winner.branch).await
7589 } else {
7590 Some(Vec::new())
7591 };
7592 let Some(pr) = self
7596 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7597 .await?
7598 else {
7599 return Ok(());
7600 };
7601 let message = pr.commit_message();
7602
7603 let outcome = match mode {
7604 MergeMode::None => MergeOutcome {
7605 mode,
7606 ok: true,
7607 detail: manual_merge_command(style, &repo, &winner.branch, &message),
7608 empty: false,
7609 },
7610 MergeMode::Pr | MergeMode::Local
7611 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7612 {
7613 MergeOutcome {
7614 mode,
7615 ok: false,
7616 detail: empty_candidate_detail(&self.state, &base),
7617 empty: true,
7618 }
7619 }
7620 MergeMode::Local => {
7621 let on = git::current_branch(&repo).await?;
7622 if on.as_deref() != Some(base.as_str()) {
7623 MergeOutcome {
7624 mode,
7625 ok: false,
7626 detail: format!(
7627 "{} has {} checked out, not the base branch {base}",
7628 repo.display(),
7629 on.unwrap_or_else(|| "a detached HEAD".to_owned())
7630 ),
7631 empty: false,
7632 }
7633 } else if !git::is_clean(&repo).await? {
7634 MergeOutcome {
7635 mode,
7636 ok: false,
7637 detail: format!("{} is dirty; refusing to merge", repo.display()),
7638 empty: false,
7639 }
7640 } else {
7641 let out = match style {
7642 MergeStyle::Merge => {
7643 git::merge_no_ff(&repo, &winner.branch, &message).await?
7644 }
7645 MergeStyle::Squash => {
7646 git::merge_squash(&repo, &winner.branch, &message).await?
7647 }
7648 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7649 };
7650 MergeOutcome {
7651 mode,
7652 ok: out.ok(),
7653 detail: if out.ok() { out.stdout } else { out.stderr },
7654 empty: false,
7655 }
7656 }
7657 }
7658 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7659 self.state.pr_publication_pending = true;
7660 self.state.save()?;
7661 let out =
7662 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7663 self.state.pr_publication_pending = out.is_err();
7664 MergeOutcome {
7665 mode,
7666 ok: out.is_ok(),
7667 detail: match out {
7668 Ok(url) => url,
7669 Err(e) => format!("{e:#}"),
7670 },
7671 empty: false,
7672 }
7673 }
7674 MergeMode::Pr => {
7675 let remote = self.state.config.merge.remote.clone();
7676 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
7677 if !pushed.ok() {
7678 MergeOutcome {
7679 mode,
7680 ok: false,
7681 detail: pushed.stderr,
7682 empty: false,
7683 }
7684 } else {
7685 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7691 let out = match pr_merge_plan(found) {
7692 PrPlan::Create => {
7693 gh_pr_create(
7694 &winner.worktree,
7695 &base,
7696 &winner.branch,
7697 &pr.title,
7698 &pr.body,
7699 )
7700 .await
7701 }
7702 PrPlan::Adopt { url, title } => {
7703 self.state
7704 .event("merge", format!("Pr: adopted open pull request {url}"));
7705 if self.state.github_text.as_ref().is_some_and(|g| {
7706 g.resolved
7707 && g.choice == Some(GithubTextChoice::Rewrite)
7708 && g.rewrite.is_some()
7709 }) {
7710 land::set_pr_text_verbatim(
7713 &winner.worktree,
7714 &url,
7715 &pr.title,
7716 &pr.body,
7717 )
7718 .await
7719 .map(|()| url)
7720 } else {
7721 if title != pr.title
7722 && (!is_review_run(&self.state)
7723 || leaked
7724 .as_deref()
7725 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7726 && let Err(e) =
7727 if self.state.github_text.as_ref().is_some_and(|g| {
7728 g.resolved
7729 && g.title
7730 && g.choice == Some(GithubTextChoice::Original)
7731 }) {
7732 land::set_pr_title_verbatim(
7734 &winner.worktree,
7735 &url,
7736 &pr.title,
7737 )
7738 .await
7739 } else {
7740 land::set_pr_title(
7741 &mut self.state,
7742 &winner.worktree,
7743 &url,
7744 &pr.title,
7745 )
7746 .await
7747 }
7748 {
7749 tracing::warn!("could not refresh title of {url}: {e:#}");
7750 self.state
7751 .event("merge", format!("Pr: title refresh failed: {e:#}"));
7752 }
7753 Ok(url)
7754 }
7755 }
7756 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7757 };
7758 match out {
7759 Ok(url) => MergeOutcome {
7760 mode,
7761 ok: true,
7762 detail: url,
7763 empty: false,
7764 },
7765 Err(e) => MergeOutcome {
7766 mode,
7767 ok: false,
7768 detail: e.to_string(),
7769 empty: false,
7770 },
7771 }
7772 }
7773 }
7774 };
7775
7776 self.state.status = match (mode, outcome.ok) {
7777 (MergeMode::None, _) => RunStatus::Ready,
7778 (_, true) => RunStatus::Merged,
7779 (_, false) => RunStatus::Blocked,
7780 };
7781 self.state.event(
7782 "merge",
7783 format!(
7784 "{:?}: {}",
7785 mode,
7786 outcome.detail.lines().next().unwrap_or("")
7787 ),
7788 );
7789 self.state.merge = Some(outcome);
7790 self.state.save()?;
7791
7792 if self.state.config.graph.land
7798 && mode == MergeMode::Pr
7799 && self.state.status == RunStatus::Merged
7800 {
7801 self.run_land().await?;
7802 }
7803 self.settle_questions();
7808 Ok(())
7809 }
7810
7811 async fn run_land(&mut self) -> Result<()> {
7822 crate::pr_head::ensure_owner(&self.state)?;
7823 let url = self
7824 .state
7825 .merge
7826 .as_ref()
7827 .map(|m| m.detail.clone())
7828 .unwrap_or_default();
7829 let url = url.lines().next().unwrap_or("").trim().to_owned();
7830 if !url.starts_with("http") {
7831 return Ok(());
7832 }
7833 match land::land(&mut self.state, &url).await {
7836 Ok(pr) if self.state.parked => {
7837 let _ = pr;
7841 }
7842 Ok(pr) => {
7843 self.state.status = match pr.state {
7844 land::PrLifecycle::Merged => RunStatus::Merged,
7845 _ => RunStatus::Blocked,
7846 };
7847 if bump::should_release_bump(self.state.status)
7854 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7855 {
7856 self.state
7862 .event("bump", format!("release bump skipped: {e:#}"));
7863 }
7864 if self.state.status == RunStatus::Merged {
7867 crate::followup::after_merge(&mut self.state, &pr.url).await;
7868 }
7869 self.state.save()?;
7870 }
7871 Err(e) => {
7872 self.state.status = RunStatus::Blocked;
7873 self.state.event("land", format!("gave up: {e}"));
7874 self.state.save()?;
7875 }
7876 }
7877 Ok(())
7878 }
7879
7880 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7884 if let Some(existing) = self.state.seats.get(key)
7885 && existing.agent == agent
7886 {
7887 return existing.clone();
7888 }
7889 let fresh = if self.state.seats.contains_key(key) {
7892 handover_seat(key, agent, self.state.next_seat_seed())
7893 } else {
7894 SeatState::new(key, agent, self.state.seed)
7895 };
7896 self.state.seats.insert(key.to_owned(), fresh.clone());
7897 fresh
7898 }
7899
7900 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7906 match self.state.seats.get(key) {
7907 Some(s) if s.agent != spec.id => {
7908 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7909 }
7910 _ => spec,
7911 }
7912 }
7913
7914 fn view(&self, c: &Candidate) -> CandidateView {
7916 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7917 .unwrap_or_default();
7918 let (patch, _) = blind::sanitize_patch(
7919 &format!("candidate {} patch", c.label),
7920 &raw,
7921 &self.state.config.blind,
7922 );
7923 CandidateView {
7924 label: c.label,
7925 branch: c.branch.clone(),
7926 summary: c.summary.clone(),
7927 stat: c.stat.clone(),
7928 patch,
7929 }
7930 }
7931
7932 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7934 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7935 prompt::judge(
7936 "(see above)",
7937 &views,
7938 self.roles.judges.len(),
7939 base_short,
7940 "en",
7941 )
7942 }
7943
7944 fn vote_prompt_full(
7950 &self,
7951 j: usize,
7952 labels: &[char],
7953 language: &str,
7954 candidates: &[Candidate],
7955 base_short: &str,
7956 ) -> String {
7957 let mut text = format!(
7958 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7959 prompt::final_vote(labels, language),
7960 self.state.instruction,
7961 self.candidate_block(candidates, base_short)
7962 );
7963 if let Some(own) = self
7964 .state
7965 .judgements
7966 .get(j)
7967 .filter(|r| !r.ranking.is_empty())
7968 {
7969 let reasons = own
7970 .reasons
7971 .iter()
7972 .map(|(k, v)| format!("- {k}: {v}"))
7973 .collect::<Vec<_>>()
7974 .join("\n");
7975 text.push_str(&format!(
7976 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
7977 own.ranking.iter().collect::<String>(),
7978 if reasons.is_empty() {
7979 ""
7980 } else {
7981 ", because:\n"
7982 }
7983 ));
7984 }
7985 if !self.state.deliberation.is_empty() {
7986 text.push_str("\n# What was argued before this vote\n");
7987 for t in self.transcript(&[], j) {
7988 text.push_str(&format!(
7989 "\n## {}{}\n\n{}\n",
7990 t.who,
7991 if t.is_self { " (you)" } else { "" },
7992 t.body.trim()
7993 ));
7994 }
7995 }
7996 text
7997 }
7998
7999 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8006 let mut turns = Vec::new();
8007 for j in &self.state.judgements {
8008 if j.ranking.is_empty() {
8009 continue;
8010 }
8011 let reasons = j
8012 .reasons
8013 .iter()
8014 .map(|(k, v)| format!("- {k}: {v}"))
8015 .collect::<Vec<_>>()
8016 .join("\n");
8017 turns.push(Turn {
8018 who: format!("Judge {} (opening ranking)", j.judge),
8019 is_self: j.judge == self_idx + 1,
8020 body: format!(
8021 "Ranked {}{}{reasons}",
8022 j.ranking.iter().collect::<String>(),
8023 if reasons.is_empty() {
8024 ""
8025 } else {
8026 ", because:\n"
8027 }
8028 ),
8029 });
8030 }
8031 for t in self
8032 .state
8033 .deliberation
8034 .iter()
8035 .flat_map(|r| r.turns.iter())
8036 .chain(current)
8037 {
8038 turns.push(Turn {
8039 who: format!("Judge {}", t.judge),
8040 is_self: t.judge == self_idx + 1,
8041 body: t.body.clone(),
8042 });
8043 }
8044 turns
8045 }
8046}
8047
8048fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8050 agent::has_session(spec.kind, seat, sessions)
8051}
8052
8053fn next_untried_in_roster<'a>(
8074 roster: &'a [AgentSpec],
8075 start: usize,
8076 tried: &BTreeSet<String>,
8077) -> Option<&'a AgentSpec> {
8078 roster
8079 .get(start + 1..)?
8080 .iter()
8081 .find(|s| !tried.contains(&s.id))
8082}
8083
8084fn pick_successor<'a>(
8105 roster: &'a [AgentSpec],
8106 start: usize,
8107 tried: &BTreeSet<String>,
8108 carried: Option<&BTreeSet<String>>,
8109 others: &BTreeSet<String>,
8110) -> Option<&'a AgentSpec> {
8111 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8112 roster
8113 .get(start + 1..)
8114 .and_then(|tail| {
8115 tail.iter()
8116 .filter(free)
8117 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8118 })
8119 .or_else(|| {
8120 carried
8121 .is_some()
8122 .then(|| roster.iter().find(free))
8123 .flatten()
8124 })
8125 .or_else(|| match carried {
8126 Some(c) => next_for_seat(roster, start, tried, c),
8127 None => next_untried_in_roster(roster, start, tried),
8128 })
8129}
8130
8131fn next_for_seat<'a>(
8141 roster: &'a [AgentSpec],
8142 start: usize,
8143 round_tried: &BTreeSet<String>,
8144 carried_failed: &BTreeSet<String>,
8145) -> Option<&'a AgentSpec> {
8146 roster
8147 .get(start + 1..)?
8148 .iter()
8149 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8150 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8151}
8152
8153fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8159 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8160 return spec;
8161 };
8162 let ok = |id: &str| !h.failed.contains(id);
8163 if let Some(last) = h.last_ok.as_deref()
8164 && ok(last)
8165 && let Some(s) = roster.iter().find(|s| s.id == last)
8166 {
8167 return s.clone();
8168 }
8169 if ok(&spec.id) {
8170 return spec;
8171 }
8172 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8173 roster
8174 .get(from + 1..)
8175 .into_iter()
8176 .flatten()
8177 .chain(roster.iter())
8178 .find(|s| ok(&s.id))
8179 .cloned()
8180 .unwrap_or(spec)
8181}
8182
8183pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8187 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8188}
8189
8190const TIMED_OUT: &str = "timed out";
8193
8194impl FailClass {
8195 fn of(out: &AgentOutcome) -> Option<Self> {
8197 match out {
8198 AgentOutcome::Ok(_) => None,
8199 AgentOutcome::Quota(_) => Some(Self::Quota),
8200 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8201 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8202 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8203 AgentOutcome::ResourceBlocked(_) => None,
8206 }
8207 }
8208
8209 fn stem_word(&self) -> &'static str {
8211 match self {
8212 Self::Quota => "quota",
8213 _ => "handover",
8214 }
8215 }
8216}
8217
8218fn failure_signature(msg: &str) -> String {
8222 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8223 let mut out = Vec::new();
8224 for word in line.split_whitespace() {
8225 if word.contains('/') || word.contains('\\') {
8226 out.push("<path>".to_owned());
8227 continue;
8228 }
8229 let mut w = String::new();
8230 let mut in_digits = false;
8231 for c in word.chars() {
8232 if c.is_ascii_digit() {
8233 if !in_digits {
8234 w.push('#');
8235 }
8236 in_digits = true;
8237 } else {
8238 in_digits = false;
8239 w.push(c);
8240 }
8241 }
8242 out.push(w);
8243 }
8244 out.join(" ").chars().take(120).collect()
8245}
8246
8247fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8254 match cur {
8255 FailClass::Quota | FailClass::Timeout => true,
8256 FailClass::Other(_) => prev != Some(cur),
8257 }
8258}
8259
8260fn fail_reason(out: &AgentOutcome) -> String {
8262 match out {
8263 AgentOutcome::Ok(_) => String::new(),
8264 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8265 AgentOutcome::Dropped(o) => format!(
8266 "the CLI dropped the stream ({})",
8267 o.dropped
8268 .as_ref()
8269 .map(|d| d.why.as_str())
8270 .unwrap_or("it ended without delivering its answer")
8271 ),
8272 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8273 e.lines().next().unwrap_or("").chars().take(160).collect()
8274 }
8275 }
8276}
8277
8278pub(crate) fn record_handover(
8281 state: &mut RunState,
8282 node: &str,
8283 seat: &str,
8284 from: &str,
8285 to: &str,
8286 class: &FailClass,
8287 reason: &str,
8288) {
8289 let message = if *class == FailClass::Quota {
8290 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8291 } else {
8292 format!("{seat}: handed over {from} -> {to} ({reason})")
8293 };
8294 state.event(node, message);
8295 state.handovers.push(Handover {
8296 at: Timestamp::now(),
8297 node: node.to_owned(),
8298 seat: seat.to_owned(),
8299 from: from.to_owned(),
8300 to: to.to_owned(),
8301 reason: reason.to_owned(),
8302 });
8303}
8304
8305fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8319 commands.iter().any(|c| c.exit_code.is_none())
8320}
8321
8322fn verified_noop_claim(
8335 usable: bool,
8336 commands: &[agent::CommandEvidence],
8337 text: &str,
8338) -> Option<String> {
8339 (usable && !has_unconfirmed_command(commands))
8340 .then(|| verdict::verified_noop(text))
8341 .flatten()
8342}
8343
8344fn short(commit: &str) -> String {
8345 commit.chars().take(7).collect()
8346}
8347
8348fn make_executable(path: &Path) -> Result<()> {
8349 #[cfg(unix)]
8350 {
8351 use std::os::unix::fs::PermissionsExt as _;
8352 let mut perms = std::fs::metadata(path)?.permissions();
8353 perms.set_mode(0o755);
8354 std::fs::set_permissions(path, perms)?;
8355 }
8356 #[cfg(not(unix))]
8357 {
8358 let _ = path;
8359 }
8360 Ok(())
8361}
8362
8363struct WaveCtx<'a> {
8370 run: &'a str,
8373 node: &'a str,
8375 prompts: &'a Prompts,
8376 cache: Option<&'a Path>,
8378 round: Option<usize>,
8381 carry_seats: bool,
8385 pauses: Option<(&'a Pause, &'a Pause)>,
8388}
8389
8390async fn run_one(
8392 job: SeatJob,
8393 sem: Arc<Semaphore>,
8394 ctx: &WaveCtx<'_>,
8395 state: &mut RunState,
8396 attempt: usize,
8397) -> (SeatState, AgentOutcome) {
8398 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8399 .await
8400 .pop()
8401 .expect("one job in, one result out");
8402 (seat, out)
8403}
8404
8405async fn wave(
8411 jobs: Vec<SeatJob>,
8412 sem: Arc<Semaphore>,
8413 ctx: &WaveCtx<'_>,
8414 state: &mut RunState,
8415 attempt: usize,
8416) -> Vec<(usize, SeatState, AgentOutcome)> {
8417 let WaveCtx {
8418 run,
8419 node,
8420 prompts,
8421 cache,
8422 round,
8423 carry_seats: _,
8424 pauses,
8425 } = *ctx;
8426 for job in &jobs {
8427 state.seat_started(node, &job.seat.key, job.timeout, attempt);
8428 }
8429 if let Err(e) = state.save() {
8430 tracing::warn!("could not persist in-progress seats: {e:#}");
8435 }
8436 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8452 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8456 let cache_guard = if let Some(cache_dir) = cache {
8457 if jobs_had_a_writer {
8458 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8459 let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8460 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8461 {
8462 Ok(Some(g)) => Some(g),
8463 Ok(None) => {
8466 state.resource_block = Some(crate::run::ResourceBlock {
8467 node: node.to_owned(),
8468 kind: "shared build cache".to_owned(),
8469 detail: "the run is parking".to_owned(),
8470 at: Timestamp::now(),
8471 });
8472 return block_wave(
8473 jobs,
8474 state,
8475 node,
8476 attempt,
8477 "parked while waiting for the shared build cache",
8478 );
8479 }
8480 Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
8483 Err(e) => {
8484 let why = format!("{e:#}");
8488 state.resource_block = Some(crate::run::ResourceBlock {
8489 node: node.to_owned(),
8490 kind: "shared build cache".to_owned(),
8491 detail: why.clone(),
8492 at: Timestamp::now(),
8493 });
8494 state.event(
8495 "resource",
8496 format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
8497 );
8498 return block_wave(jobs, state, node, attempt, &why);
8499 }
8500 }
8501 } else {
8502 None
8503 }
8504 } else {
8505 None
8506 };
8507 state.seats_spawning(node, attempt);
8510 if let Err(e) = state.save() {
8511 tracing::warn!("could not persist the start of a wave: {e:#}");
8512 }
8513 let slot_limit = resource_limit
8517 + jobs
8518 .iter()
8519 .map(|j| j.timeout)
8520 .max()
8521 .unwrap_or_default()
8522 .saturating_mul(jobs.len() as u32);
8523 let mut set = tokio::task::JoinSet::new();
8524 let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
8527 let overlay = prompts.overlay(node);
8528 for (i, mut job) in jobs.into_iter().enumerate() {
8529 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
8530 if cache.is_some() {
8531 job.prompt.push('\n');
8532 job.prompt
8533 .push_str(&prompt::build_cache_note(node, job.allow_write));
8534 }
8535 let sem = Arc::clone(&sem);
8536 let run = run.to_owned();
8537 let node = node.to_owned();
8538 let attachments = if node == "implement" {
8541 state.attachments.clone()
8542 } else {
8543 Vec::new()
8544 };
8545 let cache = cache
8556 .filter(|_| job.allow_write && cache_guard.is_some())
8557 .map(Path::to_path_buf);
8558 let spawned_tx = spawned_tx.clone();
8559 set.spawn(async move {
8560 let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
8564 Ok(p) => p,
8565 Err(_) => {
8566 return (
8567 i,
8568 job.seat,
8569 AgentOutcome::ResourceBlocked(format!(
8570 "no parallel slot became free within {}s",
8571 slot_limit.as_secs()
8572 )),
8573 );
8574 }
8575 };
8576 spawned_tx.send(job.seat.key.clone()).ok();
8577 let mut seat = job.seat;
8578 let out = agent::invoke(
8579 &job.spec,
8580 &mut seat,
8581 &Invocation {
8582 cwd: &job.cwd,
8583 prompt: &job.prompt,
8584 timeout: job.timeout,
8585 allow_write: job.allow_write,
8586 unsandboxed: false,
8587 sessions: job.sessions,
8588 artifacts: &job.artifacts,
8589 stem: &job.stem,
8590 run: &run,
8591 node: &node,
8592 cache_dir: cache.as_deref(),
8593 attachments: &attachments,
8594 writable: &[],
8595 },
8596 )
8597 .await;
8598 let out = match out {
8599 Ok(o) if o.usable() => AgentOutcome::Ok(o),
8600 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
8601 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
8609 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
8610 Ok(o) => AgentOutcome::Failed(format!(
8611 "exited with {:?} and no usable output",
8612 o.exit_code
8613 )),
8614 Err(e) => AgentOutcome::Failed(e.to_string()),
8615 };
8616 (i, seat, out)
8617 });
8618 }
8619 drop(spawned_tx);
8620 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
8621 loop {
8622 let joined = tokio::select! {
8623 Some(key) = spawned_rx.recv() => {
8624 state.seat_spawned(&key);
8626 if let Err(e) = state.save() {
8627 tracing::warn!("could not persist a seat's start: {e:#}");
8628 }
8629 continue;
8630 }
8631 joined = set.join_next() => joined,
8632 };
8633 let Some(joined) = joined else {
8634 break;
8635 };
8636 let (i, seat, out) = match joined {
8637 Ok(v) => v,
8638 Err(e) => {
8642 tracing::error!("agent task panicked: {e}");
8643 continue;
8644 }
8645 };
8646 state.seat_finished(&seat.key);
8647 if let AgentOutcome::ResourceBlocked(why) = &out
8648 && state.resource_block.is_none()
8649 {
8650 state.resource_block = Some(crate::run::ResourceBlock {
8651 node: node.to_owned(),
8652 kind: "parallel slot".to_owned(),
8653 detail: why.clone(),
8654 at: Timestamp::now(),
8655 });
8656 state.event("resource", format!("{node}: no agent was started - {why}"));
8657 }
8658 record_jobs(state, node, round, &seat.key, &out);
8659 if let Err(e) = state.save() {
8660 tracing::warn!("could not persist a seat's completion: {e:#}");
8661 }
8662 if collected.len() <= i {
8663 collected.resize_with(i + 1, || None);
8664 }
8665 collected[i] = Some((i, seat, out));
8666 }
8667 if state
8673 .active
8674 .values()
8675 .any(|a| a.node == node && a.attempt == attempt)
8676 {
8677 state
8678 .active
8679 .retain(|_, a| !(a.node == node && a.attempt == attempt));
8680 if let Err(e) = state.save() {
8681 tracing::warn!("could not persist the end of a wave: {e:#}");
8682 }
8683 }
8684 if let Some(cache_dir) = cache
8691 && jobs_had_a_writer
8692 {
8693 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
8694 }
8695 if let Some(guard) = cache_guard {
8696 guard.release();
8697 }
8698 collected.into_iter().flatten().collect()
8699}
8700
8701fn record_jobs(
8712 state: &mut RunState,
8713 node: &str,
8714 round: Option<usize>,
8715 seat: &str,
8716 out: &AgentOutcome,
8717) {
8718 let commands: &[agent::CommandEvidence] = match out {
8719 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
8720 AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
8721 };
8722 let checked_at = Timestamp::now();
8723 for c in commands {
8724 state.jobs.push(JobRecord {
8725 node: node.to_owned(),
8726 round,
8727 seat: seat.to_owned(),
8728 id: c.id.clone(),
8729 description: c.description.clone(),
8730 checked_at,
8731 status: match c.exit_code {
8732 Some(0) => JobStatus::Completed,
8733 Some(_) => JobStatus::Failed,
8734 None => JobStatus::Unknown,
8735 },
8736 exit_code: c.exit_code,
8737 result_summary: c.result_summary.clone(),
8738 source: c.source.clone(),
8739 });
8740 }
8741}
8742
8743fn round_is_clean(
8764 blocking: usize,
8765 e2e_ok: bool,
8766 answered: usize,
8767 expected: usize,
8768 quota_missing: usize,
8769 policy: IncompleteReviewPolicy,
8770) -> bool {
8771 if blocking != 0 || !e2e_ok {
8772 return false;
8773 }
8774 if answered == expected || policy == IncompleteReviewPolicy::Warn {
8775 return true;
8776 }
8777 answered > 0 && expected - answered <= quota_missing
8778}
8779
8780fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
8804 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
8805 return Some(RunStatus::Gating);
8806 }
8807 let last = reviews.last()?;
8808 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
8809 if reviews.len() < max_rounds && !stagnant {
8810 return None;
8811 }
8812 if last.incomplete() && last.blocking == 0 {
8813 return Some(RunStatus::Blocked);
8814 }
8815 if last.e2e_status() == E2eStatus::ResourceBlocked {
8816 return None;
8817 }
8818 Some(if last.e2e.iter().all(CommandOutcome::ok) {
8819 RunStatus::Gating
8820 } else {
8821 RunStatus::Blocked
8822 })
8823}
8824
8825pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
8840 if nudged {
8841 (full / 4).max(Duration::from_secs(120)).min(full)
8842 } else {
8843 full
8844 }
8845}
8846
8847#[allow(clippy::too_many_arguments)]
8876async fn ask_json_wave<T>(
8877 jobs: Vec<SeatJob>,
8878 sem: Arc<Semaphore>,
8879 retries: usize,
8880 roster: &[AgentSpec],
8881 ctx: &WaveCtx<'_>,
8882 losses: &mut Vec<QuotaLoss>,
8883 state: &mut RunState,
8884 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8885) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8886where
8887 T: serde::de::DeserializeOwned + Send + 'static,
8888{
8889 ask_wave_with(
8890 jobs,
8891 sem,
8892 retries,
8893 roster,
8894 ctx,
8895 losses,
8896 state,
8897 &|text: &str| {
8898 let v = verdict::extract_json::<T>(text)?;
8899 validate(&v)?;
8900 Ok(v)
8901 },
8902 )
8903 .await
8904}
8905
8906#[allow(clippy::too_many_arguments)]
8915async fn ask_wave_with<T>(
8916 jobs: Vec<SeatJob>,
8917 sem: Arc<Semaphore>,
8918 retries: usize,
8919 roster: &[AgentSpec],
8920 ctx: &WaveCtx<'_>,
8921 losses: &mut Vec<QuotaLoss>,
8922 state: &mut RunState,
8923 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8924) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8925where
8926 T: Send + 'static,
8927{
8928 let n = jobs.len();
8929 let originals: Vec<SeatJob> = jobs;
8930 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8931 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8932 let mut nudges: Vec<usize> = vec![0; n];
8939 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8943 let mut tried: Vec<BTreeSet<String>> = specs
8944 .iter()
8945 .map(|s| BTreeSet::from([s.id.clone()]))
8946 .collect();
8947 let starts: Vec<usize> = specs
8948 .iter()
8949 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8950 .collect();
8951 let carry = ctx.carry_seats && !roster.is_empty();
8952 let carried: Vec<BTreeSet<String>> = originals
8955 .iter()
8956 .map(|j| {
8957 state
8958 .seat_history
8959 .get(&j.seat.key)
8960 .filter(|_| carry)
8961 .map(|h| h.failed.clone())
8962 .unwrap_or_default()
8963 })
8964 .collect();
8965 let mut prev: Vec<Option<FailClass>> = originals
8966 .iter()
8967 .map(|j| {
8968 state
8969 .seat_history
8970 .get(&j.seat.key)
8971 .filter(|_| carry)
8972 .and_then(|h| h.last_fail.clone())
8973 })
8974 .collect();
8975 let next_agent =
8976 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
8977 let others: BTreeSet<String> = specs
8978 .iter()
8979 .enumerate()
8980 .filter(|(j, _)| *j != i)
8981 .map(|(_, s)| s.id.clone())
8982 .collect();
8983 pick_successor(
8984 roster,
8985 starts[i],
8986 tried,
8987 carry.then(|| &carried[i]),
8988 &others,
8989 )
8990 .cloned()
8991 };
8992 let mut fresh: Vec<Option<String>> = vec![None; n];
8993 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
8994 let mut pending: Vec<usize> = (0..n).collect();
8995
8996 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9001 for round in 0..max_rounds {
9002 if pending.is_empty() {
9003 break;
9004 }
9005 let mut batch = Vec::with_capacity(pending.len());
9006 let mut renudged: Vec<&str> = Vec::new();
9007 for &i in &pending {
9008 let src = &originals[i];
9009 let full: &str = match &src.handover {
9014 Some(h) if specs[i].id != src.spec.id => h,
9015 _ => &src.prompt,
9016 };
9017 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9020 (
9021 full.to_owned(),
9022 src.timeout,
9023 format!("{}-{word}-{}", src.stem, specs[i].id),
9024 )
9025 } else if nudges[i] == 0 {
9026 (full.to_owned(), src.timeout, src.stem.clone())
9027 } else {
9028 renudged.push(src.seat.key.as_str());
9029 let why = done[i]
9030 .as_ref()
9031 .and_then(|r| r.as_ref().err().map(ToString::to_string))
9032 .unwrap_or_else(|| "no parsable answer".to_owned());
9033 let nudge = prompt::nudge(&why);
9034 let nudged = has_context(&specs[i], &seats[i], src.sessions);
9035 let prompt = if nudged {
9036 nudge
9037 } else {
9038 format!("{full}\n\n---\n\n{nudge}")
9039 };
9040 (
9041 prompt,
9042 retry_budget(src.timeout, nudged),
9043 format!("{}-retry{}", src.stem, nudges[i]),
9044 )
9045 };
9046 batch.push(SeatJob {
9047 spec: specs[i].clone(),
9048 seat: seats[i].clone(),
9049 cwd: src.cwd.clone(),
9050 prompt,
9051 timeout,
9052 allow_write: src.allow_write,
9053 sessions: src.sessions,
9054 artifacts: src.artifacts.clone(),
9055 stem,
9056 handover: None,
9057 });
9058 }
9059
9060 if !renudged.is_empty() {
9061 state.event(
9062 ctx.node,
9063 format!("retry {round}: re-asking {}", renudged.join(", ")),
9064 );
9065 }
9066 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9067 let mut still = Vec::new();
9068 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9069 seats[i] = seat;
9070 if let AgentOutcome::ResourceBlocked(why) = &out {
9073 done[i] = Some(Err(anyhow::anyhow!("{why}")));
9074 continue;
9075 }
9076 let class = FailClass::of(&out);
9077 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9080 && nudges[i] < retries
9081 && !roster.is_empty();
9082 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9083 let next = should_hand_over(prev[i].as_ref(), &cur)
9084 .then(|| next_agent(i, &tried[i], &specs))
9085 .flatten();
9086 if carry {
9087 let h = state
9088 .seat_history
9089 .entry(originals[i].seat.key.clone())
9090 .or_default();
9091 h.failed.insert(specs[i].id.clone());
9092 h.last_fail = Some(cur.clone());
9093 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9094 h.last_ok = None;
9095 }
9096 if let Err(e) = state.save() {
9099 tracing::warn!("could not persist a seat's failure history: {e:#}");
9100 }
9101 }
9102 if let Some(next) = next {
9103 record_handover(
9104 state,
9105 ctx.node,
9106 &originals[i].seat.key,
9107 &specs[i].id,
9108 &next.id,
9109 &cur,
9110 &fail_reason(&out),
9111 );
9112 tried[i].insert(next.id.clone());
9113 prev[i] = Some(cur.clone());
9114 seats[i] =
9115 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9116 specs[i] = next;
9117 fresh[i] = Some(cur.stem_word().to_owned());
9118 nudges[i] = 0;
9119 done[i] = Some(Err(anyhow::anyhow!(
9120 "handed over after: {}",
9121 fail_reason(&out)
9122 )));
9123 still.push(i);
9124 continue;
9125 }
9126 }
9127 if carry
9128 && !nudge_first
9129 && let Some(cur) = class.clone()
9130 {
9131 let h = state
9133 .seat_history
9134 .entry(originals[i].seat.key.clone())
9135 .or_default();
9136 h.failed.insert(specs[i].id.clone());
9137 h.last_fail = Some(cur);
9138 }
9139 let parsed = match out {
9140 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9141 AgentOutcome::Quota(o) => {
9142 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9143 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9144 }
9145 AgentOutcome::Dropped(o) => {
9149 let why = o
9150 .dropped
9151 .as_ref()
9152 .map(|d| d.why.as_str())
9153 .unwrap_or("the CLI ended the stream without delivering its answer");
9154 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9155 }
9156 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9157 Err(anyhow::anyhow!(e))
9158 }
9159 };
9160 let quota = class == Some(FailClass::Quota);
9161 let failed = parsed.is_err();
9162 done[i] = Some(parsed);
9163 if carry && !failed {
9164 let h = state
9165 .seat_history
9166 .entry(originals[i].seat.key.clone())
9167 .or_default();
9168 h.failed.remove(&specs[i].id);
9169 h.last_ok = Some(specs[i].id.clone());
9170 h.last_fail = None;
9171 }
9172 let agent_failure = class.is_some()
9180 && !nudge_first
9181 && !roster.is_empty()
9182 && next_agent(i, &tried[i], &specs).is_some();
9183 if failed && !quota && !agent_failure && nudges[i] < retries {
9184 nudges[i] += 1;
9185 still.push(i);
9186 }
9187 }
9188 pending = still;
9189 }
9190
9191 for (i, q) in last_quota.into_iter().enumerate() {
9194 if let Some(reset) = q {
9195 losses.push(QuotaLoss {
9196 seat: originals[i].seat.key.clone(),
9197 node: ctx.node.to_owned(),
9198 at: Timestamp::now(),
9199 reset,
9200 });
9201 }
9202 }
9203
9204 seats
9205 .into_iter()
9206 .zip(done)
9207 .zip(nudges)
9208 .map(|((seat, res), attempts)| {
9209 (
9210 seat,
9211 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9212 attempts,
9213 )
9214 })
9215 .collect()
9216}
9217
9218fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9220 results
9221 .iter()
9222 .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9223}
9224
9225fn block_wave(
9229 jobs: Vec<SeatJob>,
9230 state: &mut RunState,
9231 node: &str,
9232 attempt: usize,
9233 why: &str,
9234) -> Vec<(usize, SeatState, AgentOutcome)> {
9235 state.resource_wait = None;
9236 state
9237 .active
9238 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9239 if let Err(e) = state.save() {
9240 tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9241 }
9242 jobs.into_iter()
9243 .enumerate()
9244 .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9245 .collect()
9246}
9247
9248async fn acquire_cache_lease(
9261 state: &mut RunState,
9262 cache_dir: &Path,
9263 owner: &crate::cache::Owner,
9264 budget: Duration,
9265 context: &str,
9266 cancel: &(dyn Fn() -> bool + Sync),
9267) -> Result<Option<crate::cache::Guard>> {
9268 let home = crate::run::home();
9269 let started = Instant::now();
9270 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9271 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9272 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9273 Err(e) => {
9274 state.event(
9275 "verify",
9276 format!("{context}: could not check the shared build cache: {e:#}"),
9277 );
9278 if let Err(e2) = state.save() {
9279 tracing::warn!("could not persist a cache-check failure: {e2:#}");
9280 }
9281 return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9282 }
9283 };
9284 state.event(
9285 "verify",
9286 format!(
9287 "{context}: waiting for the shared build cache at {} ({})",
9288 cache_dir.display(),
9289 busy.describe()
9290 ),
9291 );
9292 state.resource_wait = Some(crate::run::ResourceWait {
9295 node: context.to_owned(),
9296 kind: "shared build cache".to_owned(),
9297 holder: busy.describe(),
9298 since: Timestamp::now(),
9299 limit_secs: budget.as_secs(),
9300 });
9301 if let Err(e) = state.save() {
9302 tracing::warn!("could not persist a cache wait: {e:#}");
9303 }
9304 let remaining = budget.saturating_sub(started.elapsed());
9305 let waited = crate::cache::wait_for_unless(
9306 &home,
9307 cache_dir,
9308 owner,
9309 remaining,
9310 Duration::from_secs(5),
9311 cancel,
9312 )
9313 .await;
9314 state.resource_wait = None;
9315 match waited {
9316 Ok(g) => Ok(g),
9317 Err(e) => {
9318 state.event("verify", format!("{context}: {e:#}"));
9319 if let Err(e2) = state.save() {
9320 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9321 }
9322 Err(e)
9323 }
9324 }
9325}
9326
9327#[derive(Debug)]
9332struct CacheCheckError(String);
9333
9334impl std::fmt::Display for CacheCheckError {
9335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9336 f.write_str(&self.0)
9337 }
9338}
9339
9340impl std::error::Error for CacheCheckError {}
9341
9342#[allow(clippy::too_many_arguments)]
9363async fn with_cache_lease<'s, F, Fut>(
9364 state: &'s mut RunState,
9365 cache_dir: Option<&Path>,
9366 node: &str,
9367 seat: &str,
9368 worktree: &Path,
9369 head: &str,
9370 budget: Duration,
9371 context: &str,
9372 body: F,
9373) -> (Vec<CommandOutcome>, bool)
9374where
9375 F: FnOnce(&'s mut RunState, Duration) -> Fut,
9376 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9377{
9378 let Some(cache_dir) = cache_dir else {
9379 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9380 return (outcomes, retried);
9381 };
9382 let home = crate::run::home();
9383 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9384 let started = Instant::now();
9385 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9386 .await
9387 .and_then(|g| {
9388 g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9389 }) {
9390 Ok(g) => g,
9391 Err(e) => {
9392 return (
9393 vec![CommandOutcome {
9394 command: "(waiting for the shared build cache)".to_owned(),
9395 code: None,
9396 output_tail: e.to_string(),
9397 duration_ms: started.elapsed().as_millis() as u64,
9398 resource_blocked: true,
9399 }],
9400 false,
9401 );
9402 }
9403 };
9404 let identity = crate::cache::Identity::new(worktree, head);
9405 if let Err(e) = crate::cache::ensure_fresh(
9406 &home,
9407 cache_dir,
9408 &identity,
9409 &state.config.verify.cargo_manifests(),
9410 ) {
9411 state.event(
9419 "verify",
9420 format!(
9421 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9422 worktree.display(),
9423 short(head)
9424 ),
9425 );
9426 guard.release();
9427 return (
9428 vec![CommandOutcome {
9429 command: "(confirming the shared build cache is fresh)".to_owned(),
9430 code: None,
9431 output_tail: e.to_string(),
9432 duration_ms: started.elapsed().as_millis() as u64,
9433 resource_blocked: true,
9434 }],
9435 false,
9436 );
9437 }
9438 let remaining = budget.saturating_sub(started.elapsed());
9439 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9440 if !timed_out_pids.is_empty() {
9445 wait_for_timed_out_children_to_die(&timed_out_pids).await;
9446 }
9447 guard.release();
9448 (outcomes, retried)
9449}
9450
9451async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9463 wait_for_pids_with(
9464 pids,
9465 crate::proc::pid_alive,
9466 LEASE_RELEASE_POLL,
9467 LEASE_RELEASE_MAX_WAIT,
9468 )
9469 .await;
9470}
9471
9472async fn wait_for_pids_with<F: Fn(u32) -> bool>(
9478 pids: &[u32],
9479 alive: F,
9480 poll: Duration,
9481 max_wait: Duration,
9482) {
9483 let deadline = Instant::now() + max_wait;
9484 loop {
9485 if pids.iter().all(|&pid| !alive(pid)) {
9486 return;
9487 }
9488 if Instant::now() >= deadline {
9489 return;
9490 }
9491 tokio::time::sleep(poll).await;
9492 }
9493}
9494
9495fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
9502 outcomes.iter().any(|o| o.resource_blocked)
9503}
9504
9505enum GateFix {
9507 Retry,
9509 Stop,
9512 Defer,
9515}
9516
9517fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
9525 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
9526 red.peek().is_some()
9527 && red.all(|o| {
9528 !o.resource_blocked
9529 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
9530 && !o.output_tail.trim().is_empty()
9531 })
9532}
9533
9534fn e2e_outcome_label(o: &CommandOutcome) -> String {
9538 if o.ok() {
9539 return "pass".to_owned();
9540 }
9541 let reason = if o.build_failed() {
9542 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
9543 } else {
9544 format!("FAIL ({:?})", o.code)
9545 };
9546 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
9547}
9548
9549async fn run_e2e_with_retry(
9557 state: &mut RunState,
9558 shell: &[String],
9559 commands: &[String],
9560 worktree: &Path,
9561 timeout: Duration,
9562 context: &str,
9563) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
9564 let (mut e2e, mut timed_out_pids) = run_commands(
9565 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
9566 )
9567 .await;
9568 for o in &e2e {
9569 state.event(
9570 "verify",
9571 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
9572 );
9573 }
9574 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
9578 if verify_retried {
9579 state.event(
9580 "verify",
9581 format!(
9582 "{context}: verify could not build/link, not a test result — retrying once \
9583 before concluding"
9584 ),
9585 );
9586 let retried = run_commands(
9587 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
9588 )
9589 .await;
9590 e2e = retried.0;
9591 timed_out_pids.extend(retried.1);
9594 for o in &e2e {
9595 state.event(
9596 "verify",
9597 format!(
9598 "{context}: retry `{}` -> {}",
9599 o.command,
9600 e2e_outcome_label(o)
9601 ),
9602 );
9603 }
9604 }
9605 (e2e, verify_retried, timed_out_pids)
9606}
9607
9608#[allow(clippy::too_many_arguments)]
9624async fn run_commands(
9625 state: &mut RunState,
9626 node: &str,
9627 task: &str,
9628 attempt: usize,
9629 shell: &[String],
9630 commands: &[String],
9631 cwd: &Path,
9632 timeout: Duration,
9633) -> (Vec<CommandOutcome>, Vec<u32>) {
9634 if commands.is_empty() {
9635 return (Vec::new(), Vec::new());
9640 }
9641 let mut out = Vec::new();
9642 let mut timed_out_pids = Vec::new();
9643 let total = commands.len();
9644 for (idx, command) in commands.iter().enumerate() {
9645 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
9646 if let Err(e) = state.save() {
9647 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
9648 }
9649 let started = Instant::now();
9650 let mut cmd = tokio::process::Command::new(&shell[0]);
9651 cmd.quiet();
9652 cmd.args(&shell[1..])
9653 .arg(command)
9654 .current_dir(cwd)
9655 .stdin(std::process::Stdio::null())
9656 .stdout(std::process::Stdio::piped())
9657 .stderr(std::process::Stdio::piped())
9658 .kill_on_drop(true);
9659 let spawned = cmd.spawn();
9660 let (code, body) = match spawned {
9661 Ok(child) => {
9662 let pid = child.id();
9667 match tokio::time::timeout(timeout, child.wait_with_output()).await {
9668 Ok(Ok(o)) => {
9669 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
9670 body.push_str(&String::from_utf8_lossy(&o.stderr));
9671 (o.status.code(), body)
9672 }
9673 Ok(Err(e)) => (None, format!("failed to run: {e}")),
9674 Err(_) => {
9675 if let Some(pid) = pid {
9676 timed_out_pids.push(pid);
9677 }
9678 (None, format!("timed out after {}s", timeout.as_secs()))
9679 }
9680 }
9681 }
9682 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
9683 };
9684 out.push(CommandOutcome {
9685 command: command.clone(),
9686 code,
9687 output_tail: tail(&body, OUTPUT_TAIL),
9688 duration_ms: started.elapsed().as_millis() as u64,
9689 resource_blocked: false,
9690 });
9691 }
9692 state.task_finished(task);
9693 if let Err(e) = state.save() {
9694 tracing::warn!("could not persist the end of {task}: {e:#}");
9695 }
9696 (out, timed_out_pids)
9697}
9698
9699fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
9711 let repo = repo.display();
9712 match style {
9713 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
9714 MergeStyle::Squash => {
9715 let subject = message
9718 .lines()
9719 .next()
9720 .unwrap_or(branch)
9721 .replace(['\\', '"', '$', '`'], "");
9722 format!(
9723 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
9724 )
9725 }
9726 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
9727 }
9728}
9729
9730const PR_TITLE_MAX: usize = 240;
9741
9742fn english_title(text: &str, max: usize) -> Option<String> {
9753 let line = queue::first_line(text)?;
9754 let chars: Vec<char> = line.chars().collect();
9755 let (kept, mark) = if chars.len() <= max {
9756 (line.to_owned(), "")
9757 } else if let Some(end) = sentence_end(&chars, max) {
9758 (chars[..end].iter().collect::<String>(), "")
9759 } else {
9760 let room = max.saturating_sub(3);
9761 let cut = if chars[room].is_whitespace() {
9764 room
9765 } else {
9766 chars[..room]
9767 .iter()
9768 .rposition(|c| c.is_whitespace())
9769 .unwrap_or(room)
9770 };
9771 let head: String = chars[..cut].iter().collect();
9772 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
9773 (head.to_owned(), "...")
9774 };
9775 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
9776 return None;
9777 }
9778 Some(format!("{kept}{mark}"))
9779}
9780
9781fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
9785 const MIN: usize = 20;
9786 for i in MIN..max.min(chars.len()) {
9787 if !matches!(chars[i], '.' | '!' | '?') {
9788 continue;
9789 }
9790 let Some(&next) = chars.get(i + 1) else {
9791 continue;
9792 };
9793 if !next.is_whitespace() {
9794 continue;
9795 }
9796 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
9797 if after.is_some_and(|c| c.is_ascii_lowercase()) {
9798 continue;
9799 }
9800 let word: String = chars[..i]
9801 .iter()
9802 .rev()
9803 .take_while(|c| !c.is_whitespace())
9804 .collect::<Vec<_>>()
9805 .into_iter()
9806 .rev()
9807 .collect();
9808 let word = word.to_ascii_lowercase();
9809 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
9810 continue;
9811 }
9812 let end = chars[..i]
9813 .iter()
9814 .rposition(|c| !c.is_whitespace())
9815 .map_or(i, |p| p + 1);
9816 return Some(end);
9817 }
9818 None
9819}
9820
9821struct PrMessage {
9826 title: String,
9827 body: String,
9828}
9829
9830impl PrMessage {
9831 fn commit_message(&self) -> String {
9835 format!("{}\n\n{}", self.title, self.body)
9836 }
9837}
9838
9839fn title_marker(line: &str) -> Option<&str> {
9841 let line = line.trim();
9842 let head = line.get(..6)?;
9843 head.eq_ignore_ascii_case("title:")
9844 .then(|| line[6..].trim())
9845}
9846
9847fn summary_title(summary: &str) -> Option<String> {
9852 let first = summary.lines().find(|l| !l.trim().is_empty())?;
9853 let raw = title_marker(first)?;
9854 if raw.is_empty() {
9855 return None;
9856 }
9857 let title = queue::title_from(raw, PR_TITLE_MAX);
9858 let lower = title.to_ascii_lowercase();
9859 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9860 return None;
9861 }
9862 Some(title)
9863}
9864
9865const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
9867
9868const EXISTING_BRANCH: &str = "(existing branch)";
9870
9871fn is_review_run(state: &RunState) -> bool {
9875 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
9876}
9877
9878fn review_title(state: &RunState) -> Option<String> {
9883 english_subject(state.reviewed_commits.as_ref()?.first()?)
9884}
9885
9886fn english_subject(raw: &str) -> Option<String> {
9889 let raw = raw.trim();
9890 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
9891 return None;
9892 }
9893 let title = queue::title_from(raw, PR_TITLE_MAX);
9894 let lower = title.to_ascii_lowercase();
9895 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9896 return None;
9897 }
9898 Some(title)
9899}
9900
9901#[derive(Debug, Clone, PartialEq, Eq)]
9904struct BranchFacts {
9905 commits: Vec<(String, String)>,
9907 stat: String,
9909}
9910
9911const STAT_FILE_LINES: usize = 25;
9913const COMMITS_MAX_CHARS: usize = 20_000;
9915
9916async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
9919 let commits = git::commit_log(repo, base, branch).await.ok()?;
9920 if commits.is_empty() {
9921 return None;
9922 }
9923 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
9924 let lines: Vec<&str> = stat.lines().collect();
9925 let stat = if lines.len() > STAT_FILE_LINES + 1 {
9926 let omitted = lines.len() - 1 - STAT_FILE_LINES;
9927 let more = format!(" ... {omitted} more file(s)");
9928 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
9929 kept.push(&more);
9930 kept.push(lines[lines.len() - 1]);
9931 kept.join("\n")
9932 } else {
9933 lines.join("\n")
9934 };
9935 Some(BranchFacts { commits, stat })
9936}
9937
9938fn markdown_safe(text: &str) -> String {
9941 text.replace("</details>", "</details>")
9942 .replace("\x60\x60\x60", "~~~")
9943}
9944
9945fn neutral_title(state: &RunState, winner: char) -> String {
9946 format!(
9947 "chore: land candidate {} of run {}",
9948 winner.to_ascii_uppercase(),
9949 state.id
9950 )
9951}
9952
9953pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
9959 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
9960 ""
9961 } else {
9962 pr_title
9963 }
9964}
9965
9966pub fn landing_subject_source(state: &RunState) -> String {
9970 if is_review_run(state) {
9971 let winner = state.candidates.first().map_or('A', |c| c.label);
9972 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
9973 }
9974 state.instruction.clone()
9975}
9976
9977fn summary_without_title(summary: &str) -> String {
9980 let mut lines = summary.trim().lines().peekable();
9981 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
9982 lines.next();
9983 }
9984 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
9985}
9986
9987#[cfg(test)]
10001fn pr_message(state: &RunState, winner: char) -> PrMessage {
10002 pr_message_with(state, winner, None)
10003}
10004
10005#[cfg(test)]
10008fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10009 let raw = pr_message_raw(
10010 state,
10011 winner,
10012 facts,
10013 crate::pr_outcome::FinalHead::Unreported,
10014 ChangeNote::NotAsked,
10015 );
10016 let id = crate::scrub::Identity::current();
10017 PrMessage {
10018 title: crate::scrub::scrub(&raw.title, &id),
10019 body: crate::scrub::scrub(&raw.body, &id),
10020 }
10021}
10022
10023#[derive(Debug, Clone, Copy)]
10025enum ChangeNote<'a> {
10026 NotAsked,
10030 Summary(&'a crate::run::ChangeSummary),
10032 Unavailable(&'a str),
10034}
10035
10036fn pr_message_raw(
10037 state: &RunState,
10038 winner: char,
10039 facts: Option<&BranchFacts>,
10040 head: crate::pr_outcome::FinalHead<'_>,
10041 change: ChangeNote<'_>,
10042) -> PrMessage {
10043 let summary = state
10044 .candidates
10045 .iter()
10046 .find(|c| c.label == winner)
10047 .map(|c| c.summary.as_str())
10048 .unwrap_or_default();
10049 let review = is_review_run(state);
10052 let generated_title = match change {
10053 ChangeNote::Summary(c) if review && !c.title.trim().is_empty() => Some(c.title.clone()),
10054 _ => None,
10055 };
10056 let title = if let Some(t) = generated_title {
10057 t
10058 } else if review {
10059 facts
10060 .and_then(|f| english_subject(&f.commits.first()?.0))
10061 .or_else(|| review_title(state))
10062 .or_else(|| {
10063 state
10064 .candidates
10065 .iter()
10066 .find(|c| c.label == winner)
10067 .filter(|c| !c.branch.starts_with("magi/"))
10068 .and_then(|c| english_subject(&c.branch))
10069 })
10070 .unwrap_or_else(|| neutral_title(state, winner))
10071 } else {
10072 summary_title(summary).unwrap_or_else(|| {
10073 english_title(&state.instruction, PR_TITLE_MAX)
10074 .unwrap_or_else(|| neutral_title(state, winner))
10075 })
10076 };
10077
10078 let mut body = String::new();
10079 let what = summary_without_title(summary);
10080 if review {
10081 match change {
10082 ChangeNote::Summary(c) => {
10083 body.push_str("## Summary\n\n");
10084 body.push_str(c.body.trim());
10085 body.push_str("\n\n");
10086 }
10087 ChangeNote::Unavailable(why) => body.push_str(&format!(
10088 "## Summary\n\nNo description of what this change does could be generated ({why}). \
10089 The commit list and diff stat below only list what the branch carries; they do not \
10090 explain it.\n\n"
10091 )),
10092 ChangeNote::NotAsked => {}
10093 }
10094 }
10095 if !what.is_empty() {
10096 body.push_str("## Summary\n\n");
10097 body.push_str(&what);
10098 body.push_str("\n\n");
10099 }
10100
10101 body.push_str(&crate::pr_outcome::review_fixes(state, head).0);
10105 body.push_str(&crate::pr_outcome::final_verification(state, head));
10106 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10107
10108 let open = state.open_findings();
10109 if !open.is_empty() {
10110 body.push_str("## Open review findings\n\n");
10111 for f in &open {
10112 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
10113 }
10114 body.push('\n');
10115 }
10116
10117 if let Some(fix) = fix
10118 && !fix.rejected.is_empty()
10119 {
10120 body.push_str("## Declined by the fixer\n\n");
10121 for r in &fix.rejected {
10122 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
10123 }
10124 body.push('\n');
10125 }
10126
10127 if review {
10128 body.push_str("## Commits under review\n\n");
10130 if let Some(facts) = facts {
10131 let mut left = COMMITS_MAX_CHARS;
10132 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10133 let mut entry = format!("- {}\n", markdown_safe(subject));
10134 for l in markdown_safe(text).lines() {
10135 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10136 }
10137 if left == 0 {
10138 body.push_str(&format!(
10139 "- ... {} more commit(s)\n",
10140 facts.commits.len() - i
10141 ));
10142 break;
10143 }
10144 if entry.len() > left {
10145 let mut end = left;
10148 while !entry.is_char_boundary(end) {
10149 end -= 1;
10150 }
10151 entry.truncate(end);
10152 entry.push_str("\n ... (truncated)\n");
10153 left = 0;
10154 } else {
10155 left -= entry.len();
10156 }
10157 body.push_str(&entry);
10158 }
10159 if !facts.stat.trim().is_empty() {
10160 body.push_str(&format!(
10161 "\n## Diff stat\n\n```\n{}\n```\n",
10162 markdown_safe(facts.stat.trim())
10163 ));
10164 }
10165 } else {
10166 match &state.reviewed_commits {
10167 Some(subjects) => {
10168 for s in subjects {
10169 body.push_str(&format!("- {}\n", s.trim()));
10170 }
10171 }
10172 None => {
10173 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10176 body.push_str(rest.trim());
10177 body.push('\n');
10178 }
10179 }
10180 }
10181 } else {
10182 let task = state.instruction.trim();
10183 let task = if task.is_empty() {
10184 "(empty task)"
10185 } else {
10186 task
10187 };
10188 body.push_str(&format!(
10189 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10190 task.replace("</details>", "</details>")
10191 ));
10192 }
10193
10194 body.push_str(&format!(
10195 "\n---\nmagi:run/{} magi:candidate-{}\n",
10196 state.id,
10197 winner.to_ascii_lowercase()
10198 ));
10199
10200 PrMessage { title, body }
10201}
10202
10203fn seeded_instruction(state: &RunState) -> String {
10207 match refs::describe(&state.seeds) {
10208 Some(facts) => format!(
10209 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10210 Candidates start from the unmerged branch named above, when there \
10211 is one, and carry any unmerged commit named by sha as a \
10212 cherry-pick. Check that this is what the task meant before \
10213 building on it.",
10214 state.instruction
10215 ),
10216 None => state.instruction.clone(),
10217 }
10218}
10219
10220async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10224 let base = &state.base_branch;
10225 let mut against = base.clone();
10226 if mode == MergeMode::Pr {
10227 let remote = &state.config.merge.remote;
10231 let tracking = format!("{remote}/{base}");
10232 let fetched = git::fetch(repo, remote, base).await;
10233 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10234 against = tracking;
10235 } else {
10236 return false;
10237 }
10238 }
10239 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10240}
10241
10242fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10245 let mut detail = format!(
10246 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10247 nothing to open a pull request for"
10248 );
10249 match refs::describe(&state.seeds) {
10250 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10251 None => detail.push_str(
10252 "\nThe task names no existing branch or commit; if it means to land work \
10253 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10254 ),
10255 }
10256 detail
10257}
10258
10259#[derive(Debug, PartialEq, Eq)]
10262enum PrPlan {
10263 Create,
10264 Adopt { url: String, title: String },
10265 Stop(String),
10266}
10267
10268fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10271 match found {
10272 Ok(land::OpenPr::None) => PrPlan::Create,
10273 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10274 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10275 "several open pull requests exist for this branch, not picking one: {}",
10276 urls.join(" ")
10277 )),
10278 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10279 }
10280}
10281
10282pub(crate) async fn gh_pr_create(
10284 cwd: &Path,
10285 base: &str,
10286 head: &str,
10287 title: &str,
10288 body: &str,
10289) -> Result<String> {
10290 let out = tokio::process::Command::new("gh")
10291 .args([
10292 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10293 ])
10294 .current_dir(cwd)
10295 .quiet()
10296 .stdin(std::process::Stdio::null())
10297 .output()
10298 .await
10299 .context("spawn gh")?;
10300 if out.status.success() {
10301 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10302 } else {
10303 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10304 }
10305}
10306
10307pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10316 let repo = state.repo.clone();
10317 let root = state.worktree_root();
10318 let winner = state.tally.as_ref().map(|t| t.winner);
10319 let mut removed = Vec::new();
10320
10321 for i in 0..state.candidates.len() {
10322 let c = state.candidates[i].clone();
10323 let is_winner = Some(c.label) == winner;
10324 if is_winner && !drop_winner {
10325 continue;
10326 }
10327 if c.worktree.exists() {
10328 git::worktree_remove(&repo, &c.worktree).await.ok();
10329 removed.push(c.worktree.to_string_lossy().into_owned());
10330 }
10331 let handed_over = state.released_branches.contains(&c.branch);
10334 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10335 git::branch_delete(&repo, &c.branch).await.ok();
10336 removed.push(c.branch.clone());
10337 }
10338 state.candidates[i].folded = true;
10339 }
10340
10341 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10342 let path = name.path();
10343 let keep = !drop_winner
10344 && winner.is_some_and(|w| {
10345 path.file_name()
10346 .is_some_and(|n| n == format!("cand-{w}").as_str())
10347 });
10348 if keep {
10349 continue;
10350 }
10351 git::worktree_remove(&repo, &path).await.ok();
10352 removed.push(path.to_string_lossy().into_owned());
10353 }
10354
10355 remove_if_empty(&root);
10364
10365 if state.enabled_worktree_config && drop_winner {
10366 git::release_worktree_config(&repo).await.ok();
10370 state.enabled_worktree_config = false;
10371 }
10372 state.save_under(home)?;
10373 Ok(removed)
10374}
10375
10376fn remove_if_empty(dir: &Path) {
10387 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10388 std::fs::remove_dir(dir).ok();
10389 }
10390}
10391
10392pub fn worst_open(state: &RunState) -> Option<Severity> {
10394 state
10395 .reviews
10396 .last()?
10397 .reviews
10398 .iter()
10399 .flat_map(|r| r.findings.iter())
10400 .map(|f| f.severity)
10401 .max()
10402}
10403
10404async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10408 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10409 git::acquire_worktree_config(repo).await?;
10410 state.enabled_worktree_config = true;
10411 }
10412 Ok(())
10413}
10414
10415#[cfg(test)]
10416mod tests {
10417 #[test]
10418 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
10419 let leaked = vec!["chore(deps): update a crate".to_owned()];
10420 let own = "fix(daemon): apply a chosen action";
10421 assert!(should_retitle("", own, &leaked));
10422 assert!(should_retitle(
10423 &format!("{REVIEW_PROMPT_OPENING} `x`"),
10424 own,
10425 &leaked
10426 ));
10427 assert!(should_retitle(
10428 "chore: land candidate A of run 1",
10429 own,
10430 &leaked
10431 ));
10432 assert!(should_retitle(
10433 "magi: candidate A (uncommitted work)",
10434 own,
10435 &leaked
10436 ));
10437 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
10438 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
10439 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
10440 }
10441
10442 #[test]
10443 fn pr_merge_plan_creates_adopts_or_stops() {
10444 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
10445 assert_eq!(
10446 pr_merge_plan(Ok(land::OpenPr::One {
10447 url: "u".into(),
10448 title: "t".into()
10449 })),
10450 PrPlan::Adopt {
10451 url: "u".into(),
10452 title: "t".into()
10453 }
10454 );
10455 let PrPlan::Stop(many) =
10456 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
10457 else {
10458 panic!("many must stop");
10459 };
10460 assert!(many.contains('a') && many.contains('b'));
10461 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
10462 panic!("a failed lookup must stop");
10463 };
10464 assert!(err.contains("bad token"));
10465 }
10466
10467 use super::*;
10468 use crate::run::GateStatus;
10469 use std::collections::BTreeMap;
10470 use std::time::Duration;
10471
10472 fn conductor() -> AgentSpec {
10473 AgentSpec {
10474 id: "conductor".to_owned(),
10475 kind: crate::config::AgentKind::Command,
10476 model: None,
10477 command: vec!["true".to_owned()],
10478 extra_args: Vec::new(),
10479 env: BTreeMap::new(),
10480 prompt_delivery: None,
10481 }
10482 }
10483
10484 fn spec(id: &str) -> AgentSpec {
10485 AgentSpec {
10486 id: id.to_owned(),
10487 kind: crate::config::AgentKind::Command,
10488 model: None,
10489 command: vec!["true".to_owned()],
10490 extra_args: Vec::new(),
10491 env: BTreeMap::new(),
10492 prompt_delivery: None,
10493 }
10494 }
10495
10496 fn ids(xs: &[&str]) -> BTreeSet<String> {
10497 xs.iter().map(|s| (*s).to_owned()).collect()
10498 }
10499
10500 #[test]
10501 fn pick_successor_skips_an_agent_another_seat_holds() {
10502 let roster = [spec("a"), spec("b"), spec("c")];
10504 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10505 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10506 }
10507
10508 #[test]
10509 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
10510 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
10512 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10513 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10514 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
10515 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
10516 }
10517
10518 #[test]
10519 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
10520 let roster = [spec("a"), spec("b")];
10521 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10522 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10523 let carried = ids(&["b"]);
10524 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
10525 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10526 }
10527
10528 #[test]
10529 fn pick_successor_returns_none_without_any_untried_successor() {
10530 let roster = [spec("a"), spec("b")];
10531 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
10532 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
10533 let carried = ids(&["a"]);
10534 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
10535 }
10536
10537 #[test]
10538 fn pick_successor_rescue_avoids_another_seats_occupant() {
10539 let roster = [spec("a"), spec("b"), spec("c")];
10541 let carried = ids(&["b", "c"]);
10542 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
10543 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10544 }
10545
10546 #[test]
10547 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
10548 let roster = [spec("a"), spec("b"), spec("c")];
10549 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
10550 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10551 }
10552
10553 #[test]
10554 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
10555 let roster = [spec("a"), spec("b"), spec("c")];
10556 let failed = ids(&["a", "b", "c"]);
10557 let mut tried = ids(&["b"]);
10559 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10560 assert_eq!(first.id, "a");
10561 tried.insert(first.id.clone());
10562 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10563 assert_eq!(second.id, "c");
10564 tried.insert(second.id.clone());
10565 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
10566 }
10567
10568 #[test]
10569 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
10570 let roster = [spec("a"), spec("b")];
10571 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
10572 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10573 }
10574
10575 #[test]
10576 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
10577 let roster = [spec("a"), spec("b"), spec("c")];
10578 let h = SeatHistory {
10579 failed: ids(&["a"]),
10580 last_ok: Some("b".to_owned()),
10581 last_fail: None,
10582 };
10583 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
10584 let gone = SeatHistory {
10586 last_ok: Some("zzz".to_owned()),
10587 ..h.clone()
10588 };
10589 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
10590 let failed = SeatHistory {
10591 failed: ids(&["a", "b"]),
10592 last_ok: Some("b".to_owned()),
10593 last_fail: None,
10594 };
10595 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
10596 }
10597
10598 #[test]
10599 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
10600 let first = SeatState::new("review-1", "alpha", 7);
10601 let next = handover_seat("review-1", "gamma", 7);
10602 assert_ne!(first.claude_session, next.claude_session);
10603 }
10604
10605 #[test]
10606 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
10607 let mut state = state_with_summary("x", "y");
10608 let a = handover_seat("review-1", "beta", state.next_seat_seed());
10609 let b = handover_seat("review-1", "beta", state.next_seat_seed());
10610 assert_ne!(a.claude_session, b.claude_session);
10611 }
10612
10613 #[test]
10614 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
10615 let roster = [spec("a"), spec("b")];
10616 let h = SeatHistory {
10617 failed: ids(&["a", "b"]),
10618 ..SeatHistory::default()
10619 };
10620 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
10621 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
10622 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
10623 }
10624
10625 #[test]
10632 fn failure_signature_ignores_numbers_and_paths() {
10633 assert_eq!(
10634 failure_signature("exited with Some(2) and no usable output"),
10635 failure_signature("exited with Some(137) and no usable output")
10636 );
10637 assert_eq!(
10638 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
10639 failure_signature("cannot open /var/x.txt: denied")
10640 );
10641 assert_ne!(failure_signature("boom"), failure_signature("bang"));
10642 }
10643
10644 #[test]
10645 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
10646 let other = FailClass::Other("x".into());
10647 assert!(should_hand_over(None, &FailClass::Quota));
10648 assert!(should_hand_over(Some(&other), &FailClass::Quota));
10649 assert!(should_hand_over(
10650 Some(&FailClass::Timeout),
10651 &FailClass::Timeout
10652 ));
10653 assert!(should_hand_over(None, &other));
10654 assert!(!should_hand_over(Some(&other), &other));
10655 assert!(should_hand_over(
10656 Some(&other),
10657 &FailClass::Other("y".into())
10658 ));
10659 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
10661 assert!(should_hand_over(Some(&FailClass::Quota), &other));
10662 }
10663
10664 #[test]
10665 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
10666 let a = SeatState::new("judge-1", "alpha", 7);
10667 let b = handover_seat("judge-1", "beta", 7);
10668 assert_ne!(a.claude_session, b.claude_session);
10669 assert_eq!(b.turns, 0);
10670 }
10671
10672 #[test]
10673 fn a_timeout_is_classified_apart_from_other_failures() {
10674 assert_eq!(
10675 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
10676 Some(FailClass::Timeout)
10677 );
10678 assert!(matches!(
10679 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
10680 Some(FailClass::Other(_))
10681 ));
10682 }
10683
10684 #[test]
10685 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
10686 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10687 let tried = BTreeSet::from(["beta".to_owned()]);
10688 let next = next_untried_in_roster(&roster, 1, &tried);
10691 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
10692 }
10693
10694 #[test]
10695 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
10696 let roster = vec![spec("alpha"), spec("beta")];
10697 let tried = BTreeSet::from(["beta".to_owned()]);
10698 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10702 }
10703
10704 #[test]
10705 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
10706 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10707 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
10708 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10712 }
10713
10714 #[test]
10715 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
10716 let roster = vec![spec("a"), spec("a"), spec("b")];
10717 let tried = BTreeSet::from(["a".to_owned()]);
10718 let next = next_untried_in_roster(&roster, 0, &tried);
10719 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10720 }
10721
10722 #[test]
10723 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
10724 let roster = vec![spec("a"), spec("b")];
10725 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
10726 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
10727 }
10728
10729 #[test]
10730 fn remove_if_empty_only_ever_takes_a_bare_directory() {
10731 let dir = tempfile::tempdir().unwrap();
10732 let bay = dir.path().join("ffff");
10733
10734 remove_if_empty(&bay);
10736 assert!(!bay.exists());
10737
10738 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
10741 remove_if_empty(&bay);
10742 assert!(bay.exists(), "non-empty directory must survive");
10743
10744 std::fs::remove_dir(bay.join("cand-A")).unwrap();
10746 remove_if_empty(&bay);
10747 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
10748 }
10749
10750 #[test]
10759 fn a_full_panel_that_found_nothing_is_clean() {
10760 assert!(round_is_clean(
10761 0,
10762 true,
10763 2,
10764 2,
10765 0,
10766 IncompleteReviewPolicy::Block
10767 ));
10768 }
10769
10770 #[test]
10771 fn a_missing_seat_is_never_clean_under_the_default_policy() {
10772 assert!(!round_is_clean(
10773 0,
10774 true,
10775 1,
10776 2,
10777 0,
10778 IncompleteReviewPolicy::Block
10779 ));
10780 }
10781
10782 #[test]
10783 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
10784 assert!(!round_is_clean(
10785 1,
10786 true,
10787 1,
10788 2,
10789 0,
10790 IncompleteReviewPolicy::Warn
10791 ));
10792 }
10793
10794 #[test]
10795 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
10796 assert!(round_is_clean(
10797 0,
10798 true,
10799 1,
10800 2,
10801 0,
10802 IncompleteReviewPolicy::Warn
10803 ));
10804 }
10805
10806 #[test]
10807 fn a_full_panel_with_an_open_finding_is_not_clean() {
10808 assert!(!round_is_clean(
10809 1,
10810 true,
10811 2,
10812 2,
10813 0,
10814 IncompleteReviewPolicy::Block
10815 ));
10816 }
10817
10818 #[test]
10819 fn a_full_panel_with_a_red_e2e_is_not_clean() {
10820 assert!(!round_is_clean(
10821 0,
10822 false,
10823 2,
10824 2,
10825 0,
10826 IncompleteReviewPolicy::Block
10827 ));
10828 }
10829
10830 #[test]
10837 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
10838 assert!(round_is_clean(
10841 0,
10842 true,
10843 1,
10844 2,
10845 1,
10846 IncompleteReviewPolicy::Block
10847 ));
10848 }
10849
10850 #[test]
10851 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
10852 assert!(!round_is_clean(
10855 0,
10856 true,
10857 1,
10858 2,
10859 0,
10860 IncompleteReviewPolicy::Block
10861 ));
10862 }
10863
10864 #[test]
10865 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
10866 assert!(!round_is_clean(
10867 1,
10868 true,
10869 1,
10870 2,
10871 1,
10872 IncompleteReviewPolicy::Block
10873 ));
10874 assert!(!round_is_clean(
10875 0,
10876 false,
10877 1,
10878 2,
10879 1,
10880 IncompleteReviewPolicy::Block
10881 ));
10882 }
10883
10884 #[test]
10885 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
10886 assert!(!round_is_clean(
10890 0,
10891 true,
10892 0,
10893 2,
10894 2,
10895 IncompleteReviewPolicy::Block
10896 ));
10897 }
10898
10899 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
10900 CommandOutcome {
10901 command: "test".to_owned(),
10902 code,
10903 output_tail: String::new(),
10904 duration_ms: 0,
10905 resource_blocked,
10906 }
10907 }
10908
10909 #[test]
10910 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
10911 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
10912 assert!(
10913 !verify_inconclusive(&[outcome(Some(1), false)]),
10914 "an ordinary failure is still evidence about the patch"
10915 );
10916 assert!(verify_inconclusive(&[outcome(None, true)]));
10917 assert!(
10918 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
10919 "one inconclusive outcome taints the whole batch"
10920 );
10921 assert!(!verify_inconclusive(&[]));
10922 }
10923
10924 #[tokio::test]
10925 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
10926 let calls = std::sync::atomic::AtomicUsize::new(0);
10930 let started = Instant::now();
10931 wait_for_pids_with(
10932 &[123],
10933 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
10934 Duration::from_millis(5),
10935 Duration::from_secs(5),
10936 )
10937 .await;
10938 assert!(
10939 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
10940 "must keep checking rather than deciding on the first answer"
10941 );
10942 assert!(
10943 started.elapsed() < Duration::from_secs(1),
10944 "must return the moment it is confirmed dead, not wait out the ceiling"
10945 );
10946 }
10947
10948 #[tokio::test]
10949 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
10950 let started = Instant::now();
10951 wait_for_pids_with(
10952 &[123],
10953 |_| true, Duration::from_millis(5),
10955 Duration::from_millis(30),
10956 )
10957 .await;
10958 let elapsed = started.elapsed();
10959 assert!(
10960 elapsed >= Duration::from_millis(30),
10961 "must not give up before its own ceiling: {elapsed:?}"
10962 );
10963 assert!(
10964 elapsed < Duration::from_secs(1),
10965 "must not wait past its own ceiling either: {elapsed:?}"
10966 );
10967 }
10968
10969 #[tokio::test]
10970 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
10971 let started = Instant::now();
10972 wait_for_pids_with(
10973 &[],
10974 |_| true,
10975 Duration::from_secs(5),
10976 Duration::from_secs(5),
10977 )
10978 .await;
10979 assert!(
10980 started.elapsed() < Duration::from_millis(200),
10981 "an empty pid list has nothing to confirm"
10982 );
10983 }
10984
10985 fn review_round(
10991 clean: bool,
10992 blocking: usize,
10993 answered: usize,
10994 expected: usize,
10995 progressed: bool,
10996 e2e_ok: bool,
10997 ) -> ReviewRound {
10998 ReviewRound {
10999 round: 1,
11000 head: "h".to_owned(),
11001 verified_head: None,
11002 verified_at: None,
11003 reviews: Vec::new(),
11004 e2e: vec![CommandOutcome {
11005 command: "test".to_owned(),
11006 code: Some(if e2e_ok { 0 } else { 1 }),
11007 output_tail: String::new(),
11008 duration_ms: 0,
11009 resource_blocked: false,
11010 }],
11011 verify_retried: false,
11012 e2e_deferred: false,
11013 e2e_defer_reason: None,
11014 fix: None,
11015 blocking,
11016 answered,
11017 expected,
11018 clean,
11019 progressed,
11020 vote_split: false,
11021 reconsideration: Vec::new(),
11022 verdict: None,
11023 }
11024 }
11025
11026 #[test]
11027 fn review_conclusion_is_none_when_nothing_has_run() {
11028 assert_eq!(review_conclusion(&[], 3), None);
11029 }
11030
11031 #[test]
11032 fn review_conclusion_is_none_while_rounds_remain() {
11033 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11034 assert_eq!(review_conclusion(&rounds, 3), None);
11035 }
11036
11037 #[test]
11038 fn review_conclusion_is_gating_once_a_round_is_clean() {
11039 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11040 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11041 }
11042
11043 #[test]
11044 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11045 let rounds = vec![
11046 review_round(false, 1, 2, 2, true, true),
11047 review_round(false, 1, 2, 2, true, true),
11048 ];
11049 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11050 }
11051
11052 #[test]
11053 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11054 let rounds = vec![
11055 review_round(false, 1, 2, 2, true, true),
11056 review_round(false, 1, 2, 2, true, false),
11057 ];
11058 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11059 }
11060
11061 #[test]
11062 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11063 let mut blocked = review_round(false, 1, 2, 2, true, false);
11070 blocked.e2e[0].resource_blocked = true;
11071 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11072 assert_eq!(review_conclusion(&rounds, 2), None);
11073 }
11074
11075 #[test]
11076 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11077 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11079 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11080 }
11081
11082 #[test]
11083 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11084 let rounds = vec![
11085 review_round(false, 1, 2, 2, false, true),
11086 review_round(false, 1, 2, 2, false, true),
11087 ];
11088 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11089 }
11090
11091 fn secs(n: u64) -> Duration {
11092 Duration::from_secs(n)
11093 }
11094
11095 fn init_repo(dir: &Path) {
11098 let run = |args: &[&str]| {
11099 let out = std::process::Command::new("git")
11100 .args(args)
11101 .current_dir(dir)
11102 .quiet()
11103 .output()
11104 .expect("spawn git");
11105 assert!(
11106 out.status.success(),
11107 "git {args:?} failed: {}",
11108 String::from_utf8_lossy(&out.stderr)
11109 );
11110 };
11111 run(&["init", "-b", "main"]);
11112 run(&["config", "user.name", "magi test"]);
11113 run(&["config", "user.email", "magi@example.com"]);
11114 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11115 run(&["add", "-A"]);
11116 run(&["commit", "-m", "init"]);
11117 }
11118
11119 fn ask_test_home() {
11127 crate::run::pin_test_home();
11128 }
11129
11130 fn runner_at(status: RunStatus) -> Runner {
11133 let mut state = RunState::new(
11134 PathBuf::from("/nonexistent/repo"),
11135 "main".to_owned(),
11136 "deadbeef".to_owned(),
11137 "task".to_owned(),
11138 Config::default(),
11139 );
11140 state.status = status;
11141 Runner {
11142 state,
11143 roles: ResolvedRoles {
11144 implementers: Vec::new(),
11145 judges: Vec::new(),
11146 reviewers: Vec::new(),
11147 fixer: None,
11148 conductor: conductor(),
11149 implementer_roster: Vec::new(),
11150 judge_roster: Vec::new(),
11151 reviewer_roster: Vec::new(),
11152 },
11153 sem: Arc::new(Semaphore::new(1)),
11154 pause: Pause::new(),
11155 interrupt: Pause::new(),
11156 }
11157 }
11158
11159 #[test]
11163 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
11164 crate::run::pin_test_home();
11165 let mut runner = runner_at(RunStatus::Implementing);
11166 let interrupt = Pause::new();
11167 runner.watch_interrupt(interrupt.clone());
11168
11169 interrupt.park_because("task a1b2 asked to run first");
11170
11171 assert!(runner.park_here().expect("park_here"));
11172 assert!(runner.state.parked);
11173 let last = runner.state.events.last().expect("a park event");
11174 assert_eq!(last.node, "park");
11175 assert!(
11176 last.message.contains("task a1b2 asked to run first"),
11177 "expected the interrupt reason in {:?}",
11178 last.message
11179 );
11180 }
11181
11182 #[test]
11185 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
11186 crate::run::pin_test_home();
11187 let mut runner = runner_at(RunStatus::Implementing);
11188 runner.state.begin_pass();
11189 let shutdown = Pause::new();
11190 runner.on_pause(shutdown.clone());
11191 let upgrade = ParkCause::Upgrade {
11192 from: "0.121.0".to_owned(),
11193 to: Some("0.121.1".to_owned()),
11194 };
11195 shutdown.park_for(upgrade.clone());
11196 shutdown.park_for(ParkCause::Operator);
11197
11198 assert!(runner.park_here().expect("park_here"));
11199 assert_eq!(
11200 runner.state.passes[0].end,
11201 Some(crate::run::PassEnd::Parked { cause: upgrade })
11202 );
11203 let last = runner.state.events.last().expect("a park event");
11204 assert!(last.message.contains("0.121.0"), "{}", last.message);
11205 }
11206
11207 #[test]
11215 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
11216 crate::run::pin_test_home();
11217 let mut runner = runner_at(RunStatus::Implementing);
11218 let shutdown = Pause::new();
11219 runner.on_pause(shutdown.clone());
11220 let interrupt = Pause::new();
11221 runner.watch_interrupt(interrupt.clone());
11222
11223 assert!(!runner.park_here().expect("park_here"));
11225 assert!(!runner.state.parked);
11226
11227 interrupt.park_because("test");
11229 assert!(!shutdown.parked());
11230 assert!(runner.park_here().expect("park_here"));
11231 }
11232
11233 #[tokio::test]
11247 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
11248 crate::run::pin_test_home();
11249 let mut runner = runner_at(RunStatus::Implementing);
11250 let interrupt = Pause::new();
11251 runner.watch_interrupt(interrupt.clone());
11252
11253 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
11254 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
11255
11256 let node = async move {
11260 started_tx.send(()).expect("send started");
11261 finish_rx.await.expect("recv finish");
11262 "node finished"
11263 };
11264
11265 let interrupter = async move {
11266 started_rx.await.expect("recv started");
11267 interrupt.park_because("higher-priority task waiting");
11269 tokio::task::yield_now().await;
11273 finish_tx.send(()).expect("send finish");
11274 };
11275
11276 let (node_result, ()) = tokio::join!(node, interrupter);
11277 assert_eq!(
11278 node_result, "node finished",
11279 "the in-flight call ran to completion"
11280 );
11281
11282 assert!(runner.park_here().expect("park_here"));
11285 assert!(runner.state.parked);
11286 }
11287
11288 #[test]
11294 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
11295 crate::run::pin_test_home();
11296 let mut runner = runner_at(RunStatus::Judging);
11297 runner.state.config.agents = vec![conductor()];
11301 runner.state.candidates = vec![Candidate {
11302 index: 0,
11303 label: 'A',
11304 agent: "alpha".to_owned(),
11305 branch: "magi/x/A".to_owned(),
11306 worktree: PathBuf::from("/nonexistent/worktree"),
11307 summary: "did the thing".to_owned(),
11308 stat: "1 file changed".to_owned(),
11309 files: 1,
11310 commits: 1,
11311 empty: false,
11312 failed: None,
11313 verified_noop: None,
11314 duration_ms: 1234,
11315 folded: false,
11316 }];
11317 let run_id = runner.state.id.clone();
11318
11319 let interrupt = Pause::new();
11320 runner.watch_interrupt(interrupt.clone());
11321 interrupt.park_because("task c3d4 asked to run first");
11322 assert!(runner.park_here().expect("park_here"));
11323
11324 let resumed = Runner::resume(&run_id).expect("resume");
11325 assert_eq!(resumed.state.candidates.len(), 1);
11326 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
11327 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
11328 assert_eq!(resumed.state.status, runner.state.status);
11329 assert!(
11330 resumed.state.parked,
11331 "still parked until `execute` actually walks the graph again"
11332 );
11333 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
11334 }
11335
11336 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
11338 let mut q = ask::Question::new(
11339 run.to_owned(),
11340 "implement".to_owned(),
11341 "impl-A".to_owned(),
11342 "Which storage backend should the cache use?".to_owned(),
11343 String::new(),
11344 vec!["SQLite".to_owned(), "Redis".to_owned()],
11345 );
11346 store.put(&mut q).unwrap();
11347 q
11348 }
11349
11350 #[test]
11351 fn a_failed_runs_open_question_is_abandoned() {
11352 ask_test_home();
11353 let store = ask::Questions::open();
11354 let mut runner = runner_at(RunStatus::Failed);
11355 let run = runner.state.id.clone();
11356 let q = ask_open_question(&store, &run);
11357
11358 runner.settle_questions();
11359
11360 let back = store.get(&q.id).unwrap();
11361 assert!(
11362 !back.status.open(),
11363 "the seat that asked died with the run; nobody is left to read an answer"
11364 );
11365 assert!(
11366 back.detail.contains(&run) && back.detail.contains("failed"),
11367 "the reason names what the run became, not just that it is gone: {}",
11368 back.detail
11369 );
11370 }
11371
11372 #[test]
11373 fn a_merged_runs_open_question_is_abandoned_too() {
11374 ask_test_home();
11375 let store = ask::Questions::open();
11376 for status in [RunStatus::Merged, RunStatus::Ready] {
11379 let mut runner = runner_at(status);
11380 let run = runner.state.id.clone();
11381 let q = ask_open_question(&store, &run);
11382
11383 runner.settle_questions();
11384
11385 let back = store.get(&q.id).unwrap();
11386 assert!(
11387 !back.status.open(),
11388 "{status:?} run's question must not outlive the run"
11389 );
11390 }
11391 }
11392
11393 #[test]
11394 fn a_still_resumable_runs_open_question_is_left_alone() {
11395 ask_test_home();
11396 let store = ask::Questions::open();
11397 for status in [RunStatus::Blocked, RunStatus::Stalled] {
11403 let mut runner = runner_at(status);
11404 let run = runner.state.id.clone();
11405 let q = ask_open_question(&store, &run);
11406
11407 runner.settle_questions();
11408
11409 let back = store.get(&q.id).unwrap();
11410 assert!(
11411 back.status.open(),
11412 "{status:?} is still alive; the question must still be waiting"
11413 );
11414 }
11415 }
11416
11417 #[test]
11418 fn settle_questions_never_touches_an_already_answered_question() {
11419 ask_test_home();
11420 let store = ask::Questions::open();
11421 let mut runner = runner_at(RunStatus::Failed);
11422 let run = runner.state.id.clone();
11423 let mut q = ask_open_question(&store, &run);
11424 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
11425 .unwrap();
11426 store.put(&mut q).unwrap();
11427
11428 runner.settle_questions();
11433 runner.settle_questions();
11434
11435 let back = store.get(&q.id).unwrap();
11436 assert_eq!(
11437 back.status,
11438 ask::QuestionStatus::Answered,
11439 "a real answer is a decision on record, never overwritten by a sweep"
11440 );
11441 }
11442
11443 #[tokio::test]
11454 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
11455 crate::run::pin_test_home();
11456 let tmp = tempfile::tempdir().expect("tempdir");
11457 let repo = tmp.path().join("repo");
11458 std::fs::create_dir_all(&repo).unwrap();
11459 init_repo(&repo);
11460
11461 let mut config = Config::default();
11462 config.graph.worktree_root = Some(tmp.path().join("wt"));
11463
11464 let mut state = RunState::new(
11465 repo.clone(),
11466 "main".to_owned(),
11467 "deadbeef".to_owned(),
11468 "task".to_owned(),
11469 config,
11470 );
11471 let root = state.worktree_root();
11472 let wt_a = root.join("cand-A");
11473 let wt_b = root.join("cand-B");
11474 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
11475 .await
11476 .expect("worktree A");
11477 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
11478 .await
11479 .expect("worktree B");
11480
11481 state.candidates = vec![
11482 Candidate {
11483 index: 0,
11484 label: 'A',
11485 agent: "alpha".to_owned(),
11486 branch: "magi/x/A".to_owned(),
11487 worktree: wt_a.clone(),
11488 summary: String::new(),
11489 stat: String::new(),
11490 files: 0,
11491 commits: 0,
11492 empty: false,
11493 failed: None,
11494 verified_noop: None,
11495 duration_ms: 0,
11496 folded: false,
11497 },
11498 Candidate {
11499 index: 1,
11500 label: 'B',
11501 agent: "beta".to_owned(),
11502 branch: "magi/x/B".to_owned(),
11503 worktree: wt_b.clone(),
11504 summary: String::new(),
11505 stat: String::new(),
11506 files: 0,
11507 commits: 0,
11508 empty: false,
11509 failed: None,
11510 verified_noop: None,
11511 duration_ms: 0,
11512 folded: false,
11513 },
11514 ];
11515 state.tally = Some(Tally {
11516 first_choice: BTreeMap::from([('A', 1)]),
11517 borda: BTreeMap::new(),
11518 winner: 'A',
11519 rankings: 1,
11520 unanimous_initial: true,
11521 deliberated: false,
11522 changed_votes: 0,
11523 unanimous_final: true,
11524 tie_break: None,
11525 judges: 1,
11526 present: 1,
11527 quorum: 1,
11528 met_quorum: true,
11529 uncontested: None,
11530 });
11531 state.status = RunStatus::Ready;
11532
11533 fold_run(&mut state, false, &crate::run::home())
11534 .await
11535 .expect("fold_run");
11536
11537 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
11538 assert!(
11539 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11540 "the unmerged winner's branch survives"
11541 );
11542 assert!(
11543 !state.candidates[0].folded,
11544 "the winner is not marked folded"
11545 );
11546
11547 assert!(!wt_b.exists(), "the loser's worktree is removed");
11548 assert!(
11549 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
11550 "the loser's branch is removed"
11551 );
11552 assert!(state.candidates[1].folded, "the loser is marked folded");
11553 }
11554
11555 #[tokio::test]
11558 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
11559 let tmp = tempfile::tempdir().expect("tempdir");
11560 let repo = tmp.path().join("repo");
11561 std::fs::create_dir_all(&repo).unwrap();
11562 init_repo(&repo);
11563 let home = tmp.path().join("home");
11564
11565 let mut config = Config::default();
11566 config.graph.worktree_root = Some(tmp.path().join("wt"));
11567 let mut state = RunState::new(
11568 repo.clone(),
11569 "main".to_owned(),
11570 "deadbeef".to_owned(),
11571 "task".to_owned(),
11572 config,
11573 );
11574 git::git(&repo, &["branch", "magi/x/A", "main"])
11576 .await
11577 .expect("branch");
11578 state.candidates = vec![Candidate {
11579 index: 0,
11580 label: 'A',
11581 agent: "alpha".to_owned(),
11582 branch: "magi/x/A".to_owned(),
11583 worktree: state.worktree_root().join("cand-A"),
11584 summary: String::new(),
11585 stat: String::new(),
11586 files: 0,
11587 commits: 0,
11588 empty: false,
11589 failed: None,
11590 verified_noop: None,
11591 duration_ms: 0,
11592 folded: true,
11593 }];
11594 state.released_to = Some("20260901-000000-new1".to_owned());
11595 state.released_branches = vec!["magi/x/A".to_owned()];
11596
11597 fold_run(&mut state, true, &home).await.expect("fold_run");
11598
11599 assert!(
11600 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11601 "the handed-over branch survives a fold"
11602 );
11603 }
11604
11605 #[tokio::test]
11609 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
11610 let tmp = tempfile::tempdir().expect("tempdir");
11611 let repo = tmp.path().join("repo");
11612 std::fs::create_dir_all(&repo).unwrap();
11613 init_repo(&repo);
11614 let run = |args: &[&str]| {
11615 let out = std::process::Command::new("git")
11616 .quiet()
11617 .args(args)
11618 .current_dir(&repo)
11619 .output()
11620 .expect("spawn git");
11621 assert!(out.status.success(), "git {args:?}");
11622 };
11623 run(&["branch", "magi/x/A"]);
11624 run(&["checkout", "-q", "-b", "magi/x/B"]);
11625 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
11626 run(&["add", "-A"]);
11627 run(&["commit", "-q", "-m", "work"]);
11628 run(&["checkout", "-q", "main"]);
11629
11630 let probe = RunState::new(
11634 repo.clone(),
11635 "main".to_owned(),
11636 "deadbeef".to_owned(),
11637 "task".to_owned(),
11638 Config::default(),
11639 );
11640 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
11641 let bare = tmp.path().join("origin.git");
11642 let out = std::process::Command::new("git")
11643 .quiet()
11644 .args(["init", "-q", "--bare"])
11645 .arg(&bare)
11646 .output()
11647 .expect("spawn git");
11648 assert!(out.status.success(), "git init --bare");
11649 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
11650 run(&["push", "-q", "origin", "main"]);
11651
11652 let mut state = RunState::new(
11653 repo.clone(),
11654 "main".to_owned(),
11655 "deadbeef".to_owned(),
11656 "task".to_owned(),
11657 Config::default(),
11658 );
11659 state.seeds = vec![refs::Seed {
11660 token: "magi/27b2/A".to_owned(),
11661 kind: refs::SeedKind::Unresolved,
11662 sha: String::new(),
11663 branch: true,
11664 detail: "no branch or commit named magi/27b2/A".to_owned(),
11665 }];
11666
11667 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
11668 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
11669 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
11670 let detail = empty_candidate_detail(&state, "main");
11671 assert!(detail.starts_with("empty candidate"), "{detail}");
11672 assert!(detail.contains("magi/27b2/A"), "{detail}");
11673 }
11674
11675 #[tokio::test]
11684 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
11685 let tmp = tempfile::tempdir().expect("tempdir");
11686 let repo = tmp.path().join("repo");
11687 std::fs::create_dir_all(&repo).unwrap();
11688 init_repo(&repo);
11689
11690 let mut config = Config::default();
11691 config.merge.mode = MergeMode::Local;
11692
11693 let mut state = RunState::new(
11694 repo.clone(),
11695 "main".to_owned(),
11696 "deadbeef".to_owned(),
11697 "task".to_owned(),
11698 config,
11699 );
11700 state.candidates = vec![Candidate {
11701 index: 0,
11702 label: 'A',
11703 agent: "alpha".to_owned(),
11704 branch: "does-not-exist".to_owned(),
11705 worktree: repo.clone(),
11706 summary: String::new(),
11707 stat: String::new(),
11708 files: 0,
11709 commits: 0,
11710 empty: false,
11711 failed: None,
11712 verified_noop: None,
11713 duration_ms: 0,
11714 folded: false,
11715 }];
11716 state.tally = Some(Tally {
11717 first_choice: BTreeMap::from([('A', 1)]),
11718 borda: BTreeMap::new(),
11719 winner: 'A',
11720 rankings: 1,
11721 unanimous_initial: true,
11722 deliberated: false,
11723 changed_votes: 0,
11724 unanimous_final: true,
11725 tie_break: None,
11726 judges: 0,
11727 present: 0,
11728 quorum: 0,
11729 met_quorum: true,
11730 uncontested: Some("only candidate A produced a change".to_owned()),
11731 });
11732 state.reviews = vec![ReviewRound {
11733 round: 1,
11734 head: "deadbeef".to_owned(),
11735 verified_head: None,
11736 verified_at: None,
11737 reviews: Vec::new(),
11738 e2e: Vec::new(),
11739 fix: None,
11740 blocking: 0,
11741 answered: 0,
11742 expected: 0,
11743 clean: true,
11744 verify_retried: false,
11745 e2e_deferred: false,
11746 e2e_defer_reason: None,
11747 progressed: false,
11748 vote_split: false,
11749 reconsideration: Vec::new(),
11750 verdict: None,
11751 }];
11752 state.gate = vec![CommandOutcome {
11753 command: "test".to_owned(),
11754 code: Some(0),
11755 output_tail: String::new(),
11756 duration_ms: 0,
11757 resource_blocked: false,
11758 }];
11759 state.gate_ran = true;
11760 state.status = RunStatus::Ready;
11765 state.merge = Some(MergeOutcome {
11766 mode: MergeMode::Local,
11767 ok: false,
11768 detail: "already concluded".to_owned(),
11769 empty: false,
11770 });
11771
11772 let mut runner = Runner {
11773 state,
11774 roles: ResolvedRoles {
11775 implementers: Vec::new(),
11776 judges: Vec::new(),
11777 reviewers: Vec::new(),
11778 fixer: None,
11779 conductor: conductor(),
11780 implementer_roster: Vec::new(),
11781 judge_roster: Vec::new(),
11782 reviewer_roster: Vec::new(),
11783 },
11784 sem: Arc::new(Semaphore::new(1)),
11785 pause: Pause::new(),
11786 interrupt: Pause::new(),
11787 };
11788
11789 runner.merge().await.expect("merge");
11790
11791 assert_eq!(
11792 runner.state.status,
11793 RunStatus::Ready,
11794 "a concluded run's status must not change on reentry"
11795 );
11796 assert_eq!(
11797 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11798 Some("already concluded"),
11799 "merge must not run again once the node already recorded an outcome"
11800 );
11801 }
11802
11803 #[tokio::test]
11812 async fn merge_refuses_a_gate_that_has_not_actually_run() {
11813 let tmp = tempfile::tempdir().expect("tempdir");
11814 let repo = tmp.path().join("repo");
11815 std::fs::create_dir_all(&repo).unwrap();
11816 init_repo(&repo);
11817
11818 let mut config = Config::default();
11819 config.merge.mode = MergeMode::Local;
11820
11821 let mut state = RunState::new(
11822 repo.clone(),
11823 "main".to_owned(),
11824 "deadbeef".to_owned(),
11825 "task".to_owned(),
11826 config,
11827 );
11828 state.candidates = vec![Candidate {
11829 index: 0,
11830 label: 'A',
11831 agent: "alpha".to_owned(),
11832 branch: "does-not-exist".to_owned(),
11833 worktree: repo.clone(),
11834 summary: String::new(),
11835 stat: String::new(),
11836 files: 0,
11837 commits: 0,
11838 empty: false,
11839 failed: None,
11840 verified_noop: None,
11841 duration_ms: 0,
11842 folded: false,
11843 }];
11844 state.tally = Some(Tally {
11845 first_choice: BTreeMap::from([('A', 1)]),
11846 borda: BTreeMap::new(),
11847 winner: 'A',
11848 rankings: 1,
11849 unanimous_initial: true,
11850 deliberated: false,
11851 changed_votes: 0,
11852 unanimous_final: true,
11853 tie_break: None,
11854 judges: 0,
11855 present: 0,
11856 quorum: 0,
11857 met_quorum: true,
11858 uncontested: Some("only candidate A produced a change".to_owned()),
11859 });
11860 state.reviews = vec![ReviewRound {
11861 round: 1,
11862 head: "deadbeef".to_owned(),
11863 verified_head: None,
11864 verified_at: None,
11865 reviews: Vec::new(),
11866 e2e: Vec::new(),
11867 fix: None,
11868 blocking: 0,
11869 answered: 0,
11870 expected: 0,
11871 clean: true,
11872 verify_retried: false,
11873 e2e_deferred: false,
11874 e2e_defer_reason: None,
11875 progressed: false,
11876 vote_split: false,
11877 reconsideration: Vec::new(),
11878 verdict: None,
11879 }];
11880 state.gate = Vec::new();
11882 state.gate_ran = false;
11883 state.status = RunStatus::Gating;
11884
11885 let mut runner = Runner {
11886 state,
11887 roles: ResolvedRoles {
11888 implementers: Vec::new(),
11889 judges: Vec::new(),
11890 reviewers: Vec::new(),
11891 fixer: None,
11892 conductor: conductor(),
11893 implementer_roster: Vec::new(),
11894 judge_roster: Vec::new(),
11895 reviewer_roster: Vec::new(),
11896 },
11897 sem: Arc::new(Semaphore::new(1)),
11898 pause: Pause::new(),
11899 interrupt: Pause::new(),
11900 };
11901
11902 runner.merge().await.expect("merge");
11903
11904 assert!(
11905 runner.state.merge.is_none(),
11906 "an empty gate must never be read as a passing one: {:?}",
11907 runner.state.merge
11908 );
11909 }
11910
11911 #[tokio::test]
11918 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
11919 let tmp = tempfile::tempdir().expect("tempdir");
11920 let repo = tmp.path().join("repo");
11921 std::fs::create_dir_all(&repo).unwrap();
11922 init_repo(&repo);
11923
11924 let config = Config::default();
11926
11927 let mut state = RunState::new(
11928 repo.clone(),
11929 "main".to_owned(),
11930 "deadbeef".to_owned(),
11931 "task".to_owned(),
11932 config,
11933 );
11934 state.candidates = vec![Candidate {
11935 index: 0,
11936 label: 'A',
11937 agent: "alpha".to_owned(),
11938 branch: "does-not-exist".to_owned(),
11939 worktree: repo.clone(),
11940 summary: String::new(),
11941 stat: String::new(),
11942 files: 0,
11943 commits: 0,
11944 empty: false,
11945 failed: None,
11946 verified_noop: None,
11947 duration_ms: 0,
11948 folded: false,
11949 }];
11950 state.tally = Some(Tally {
11951 first_choice: BTreeMap::from([('A', 1)]),
11952 borda: BTreeMap::new(),
11953 winner: 'A',
11954 rankings: 1,
11955 unanimous_initial: true,
11956 deliberated: false,
11957 changed_votes: 0,
11958 unanimous_final: true,
11959 tie_break: None,
11960 judges: 0,
11961 present: 0,
11962 quorum: 0,
11963 met_quorum: true,
11964 uncontested: Some("only candidate A produced a change".to_owned()),
11965 });
11966 state.reviews = vec![ReviewRound {
11967 round: 1,
11968 head: "deadbeef".to_owned(),
11969 verified_head: None,
11970 verified_at: None,
11971 reviews: Vec::new(),
11972 e2e: Vec::new(),
11973 fix: None,
11974 blocking: 0,
11975 answered: 0,
11976 expected: 0,
11977 clean: true,
11978 verify_retried: false,
11979 e2e_deferred: false,
11980 e2e_defer_reason: None,
11981 progressed: false,
11982 vote_split: false,
11983 reconsideration: Vec::new(),
11984 verdict: None,
11985 }];
11986
11987 let mut runner = Runner {
11988 state,
11989 roles: ResolvedRoles {
11990 implementers: Vec::new(),
11991 judges: Vec::new(),
11992 reviewers: Vec::new(),
11993 fixer: None,
11994 conductor: conductor(),
11995 implementer_roster: Vec::new(),
11996 judge_roster: Vec::new(),
11997 reviewer_roster: Vec::new(),
11998 },
11999 sem: Arc::new(Semaphore::new(1)),
12000 pause: Pause::new(),
12001 interrupt: Pause::new(),
12002 };
12003
12004 runner.gate().await.expect("gate");
12005 assert!(
12006 runner.state.gate_ran,
12007 "zero configured commands is still a real attempt, not an unrun gate"
12008 );
12009 assert!(runner.state.gate.is_empty());
12010 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12011 assert_ne!(
12012 runner.state.status,
12013 RunStatus::Blocked,
12014 "a gate with nothing to check must not read as failed"
12015 );
12016
12017 runner.merge().await.expect("merge");
12018 assert_eq!(
12019 runner.state.status,
12020 RunStatus::Ready,
12021 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12022 );
12023 }
12024
12025 #[tokio::test]
12036 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12037 crate::run::pin_test_home();
12038 let home = crate::run::home();
12039
12040 let tmp = tempfile::tempdir().expect("tempdir");
12041 let repo = tmp.path().join("repo");
12042 std::fs::create_dir_all(&repo).unwrap();
12043 init_repo(&repo);
12044 let cache_dir = tmp.path().join("target");
12047
12048 let mut config = Config::default();
12049 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12050 config.graph.timeout_verify = Some(2);
12053
12054 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12055 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12056 .expect("no io error acquiring directly")
12057 {
12058 crate::cache::AcquireOutcome::Acquired(g) => g,
12059 crate::cache::AcquireOutcome::Busy(b) => {
12060 panic!("expected the direct acquire to win the lease first: {b:?}")
12061 }
12062 };
12063
12064 let mut state = RunState::new(
12065 repo.clone(),
12066 "main".to_owned(),
12067 "deadbeef".to_owned(),
12068 "task".to_owned(),
12069 config,
12070 );
12071 state.candidates = vec![Candidate {
12072 index: 0,
12073 label: 'A',
12074 agent: "alpha".to_owned(),
12075 branch: "does-not-exist".to_owned(),
12076 worktree: repo.clone(),
12077 summary: String::new(),
12078 stat: String::new(),
12079 files: 0,
12080 commits: 0,
12081 empty: false,
12082 failed: None,
12083 verified_noop: None,
12084 duration_ms: 0,
12085 folded: false,
12086 }];
12087 state.tally = Some(Tally {
12088 first_choice: BTreeMap::from([('A', 1)]),
12089 borda: BTreeMap::new(),
12090 winner: 'A',
12091 rankings: 1,
12092 unanimous_initial: true,
12093 deliberated: false,
12094 changed_votes: 0,
12095 unanimous_final: true,
12096 tie_break: None,
12097 judges: 0,
12098 present: 0,
12099 quorum: 0,
12100 met_quorum: true,
12101 uncontested: Some("only candidate A produced a change".to_owned()),
12102 });
12103 state.reviews = vec![ReviewRound {
12104 round: 1,
12105 head: "deadbeef".to_owned(),
12106 verified_head: None,
12107 verified_at: None,
12108 reviews: Vec::new(),
12109 e2e: Vec::new(),
12110 fix: None,
12111 blocking: 0,
12112 answered: 0,
12113 expected: 0,
12114 clean: true,
12115 verify_retried: false,
12116 e2e_deferred: false,
12117 e2e_defer_reason: None,
12118 progressed: false,
12119 vote_split: false,
12120 reconsideration: Vec::new(),
12121 verdict: None,
12122 }];
12123
12124 let mut runner = Runner {
12125 state,
12126 roles: ResolvedRoles {
12127 implementers: Vec::new(),
12128 judges: Vec::new(),
12129 reviewers: Vec::new(),
12130 fixer: None,
12131 conductor: conductor(),
12132 implementer_roster: Vec::new(),
12133 judge_roster: Vec::new(),
12134 reviewer_roster: Vec::new(),
12135 },
12136 sem: Arc::new(Semaphore::new(1)),
12137 pause: Pause::new(),
12138 interrupt: Pause::new(),
12139 };
12140
12141 let started = std::time::Instant::now();
12142 runner.gate().await.expect("gate");
12143 assert!(
12144 started.elapsed() < Duration::from_secs(1),
12145 "a gate with nothing to run must never wait on a lease it never needed"
12146 );
12147 assert!(
12148 runner.state.gate_ran,
12149 "zero commands is still a real, immediate attempt"
12150 );
12151 assert!(runner.state.gate.is_empty());
12152 assert_ne!(
12153 runner.state.status,
12154 RunStatus::Blocked,
12155 "must not read as resource-blocked on a lease it never asked for"
12156 );
12157 }
12158
12159 #[tokio::test]
12169 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
12170 crate::run::pin_test_home();
12171
12172 let tmp = tempfile::tempdir().expect("tempdir");
12173 let repo = tmp.path().join("repo");
12174 std::fs::create_dir_all(&repo).unwrap();
12175 init_repo(&repo);
12176
12177 let mut config = Config::default();
12178 config.verify.gate = vec![
12179 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
12180 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
12181 .to_owned(),
12182 ];
12183
12184 let mut state = RunState::new(
12185 repo.clone(),
12186 "main".to_owned(),
12187 "deadbeef".to_owned(),
12188 "task".to_owned(),
12189 config,
12190 );
12191 let run_id = state.id.clone();
12192 state.candidates = vec![Candidate {
12193 index: 0,
12194 label: 'A',
12195 agent: "alpha".to_owned(),
12196 branch: "does-not-exist".to_owned(),
12197 worktree: repo.clone(),
12198 summary: String::new(),
12199 stat: String::new(),
12200 files: 0,
12201 commits: 0,
12202 empty: false,
12203 failed: None,
12204 verified_noop: None,
12205 duration_ms: 0,
12206 folded: false,
12207 }];
12208 state.tally = Some(Tally {
12209 first_choice: BTreeMap::from([('A', 1)]),
12210 borda: BTreeMap::new(),
12211 winner: 'A',
12212 rankings: 1,
12213 unanimous_initial: true,
12214 deliberated: false,
12215 changed_votes: 0,
12216 unanimous_final: true,
12217 tie_break: None,
12218 judges: 0,
12219 present: 0,
12220 quorum: 0,
12221 met_quorum: true,
12222 uncontested: Some("only candidate A produced a change".to_owned()),
12223 });
12224 state.reviews = vec![ReviewRound {
12225 round: 1,
12226 head: "deadbeef".to_owned(),
12227 verified_head: None,
12228 verified_at: None,
12229 reviews: Vec::new(),
12230 e2e: Vec::new(),
12231 fix: None,
12232 blocking: 0,
12233 answered: 0,
12234 expected: 0,
12235 clean: true,
12236 verify_retried: false,
12237 e2e_deferred: false,
12238 e2e_defer_reason: None,
12239 progressed: false,
12240 vote_split: false,
12241 reconsideration: Vec::new(),
12242 verdict: None,
12243 }];
12244
12245 let mut runner = Runner {
12246 state,
12247 roles: ResolvedRoles {
12248 implementers: Vec::new(),
12249 judges: Vec::new(),
12250 reviewers: Vec::new(),
12251 fixer: None,
12252 conductor: conductor(),
12253 implementer_roster: Vec::new(),
12254 judge_roster: Vec::new(),
12255 reviewer_roster: Vec::new(),
12256 },
12257 sem: Arc::new(Semaphore::new(1)),
12258 pause: Pause::new(),
12259 interrupt: Pause::new(),
12260 };
12261
12262 let started_marker = repo.join("started.marker");
12263 let release_marker = repo.join("release.marker");
12264 let poller = tokio::spawn(async move {
12265 for _ in 0..100 {
12270 if started_marker.exists()
12271 && let Ok(s) = crate::run::RunState::load(&run_id)
12272 && let Some(a) = s.active.get("gate")
12273 {
12274 std::fs::write(&release_marker, b"go").expect("release marker");
12275 return Some(a.clone());
12276 }
12277 tokio::time::sleep(Duration::from_millis(50)).await;
12278 }
12279 None
12280 });
12281
12282 runner.gate().await.expect("gate");
12283 let captured = poller.await.expect("poller task");
12284 let captured = captured.expect(
12285 "the poller never saw a `gate` task entry in run.json while the command was \
12286 still blocked on its own release marker",
12287 );
12288
12289 assert_eq!(captured.task.as_deref(), Some("gate"));
12290 assert_eq!(captured.node, "gate");
12291 assert_eq!(captured.index, Some(1));
12292 assert_eq!(captured.total, Some(1));
12293 assert!(
12294 captured
12295 .command
12296 .as_deref()
12297 .is_some_and(|c| c.contains("started.marker")),
12298 "{captured:?}"
12299 );
12300
12301 assert!(
12302 runner.state.active.is_empty(),
12303 "the entry must be cleared once the command actually finished: {:?}",
12304 runner.state.active
12305 );
12306 assert!(runner.state.gate_ran);
12307 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
12308 }
12309
12310 #[tokio::test]
12313 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
12314 use crate::verdict::{Finding, ReviewVote, Severity};
12315 crate::run::pin_test_home();
12316 let tmp = tempfile::tempdir().expect("tempdir");
12317 let repo = tmp.path().join("repo");
12318 std::fs::create_dir_all(&repo).unwrap();
12319 init_repo(&repo);
12320
12321 for (severity, vote, expect) in [
12322 (Severity::Major, ReviewVote::Reject, true),
12323 (Severity::Minor, ReviewVote::Reject, false),
12324 (Severity::Major, ReviewVote::Approve, false),
12325 ] {
12326 let mut round = review_round(false, 1, 1, 1, false, true);
12327 round.reviews = vec![ReviewRecord {
12328 reviewer: 1,
12329 agent: "alpha".to_owned(),
12330 summary: String::new(),
12331 findings: vec![Finding {
12332 id: "R1-1-1".to_owned(),
12333 severity,
12334 file: None,
12335 line: None,
12336 title: "t".to_owned(),
12337 detail: String::new(),
12338 }],
12339 vote: Some(vote),
12340 failed: None,
12341 duration_ms: 0,
12342 attempts: 0,
12343 }];
12344 let mut state = RunState::new(
12345 repo.clone(),
12346 "main".to_owned(),
12347 "deadbeef".to_owned(),
12348 "task".to_owned(),
12349 Config::default(),
12350 );
12351 state.reviews = vec![round];
12352 let mut runner = Runner {
12353 state,
12354 roles: ResolvedRoles {
12355 implementers: Vec::new(),
12356 judges: Vec::new(),
12357 reviewers: Vec::new(),
12358 fixer: None,
12359 conductor: conductor(),
12360 implementer_roster: Vec::new(),
12361 judge_roster: Vec::new(),
12362 reviewer_roster: Vec::new(),
12363 },
12364 sem: Arc::new(Semaphore::new(1)),
12365 pause: Pause::new(),
12366 interrupt: Pause::new(),
12367 };
12368 let shell = runner.state.config.shell();
12369 runner
12370 .stop_reviewing("round budget spent", &shell, &repo)
12371 .await
12372 .expect("stop_reviewing");
12373 assert_eq!(runner.state.status, RunStatus::Gating);
12374 assert_eq!(
12375 runner.state.contested_handoff.is_some(),
12376 expect,
12377 "{severity:?} + {vote:?}"
12378 );
12379 }
12380 }
12381
12382 #[tokio::test]
12395 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
12396 crate::run::pin_test_home();
12397 let home = crate::run::home();
12398
12399 let tmp = tempfile::tempdir().expect("tempdir");
12400 let repo = tmp.path().join("repo");
12401 std::fs::create_dir_all(&repo).unwrap();
12402 init_repo(&repo);
12403 let head = crate::git::rev_parse(&repo, "HEAD")
12404 .await
12405 .expect("rev-parse");
12406 let cache_dir = tmp.path().join("target");
12409
12410 let mut config = Config::default();
12411 config.verify.e2e = vec![format!(
12412 "CARGO_TARGET_DIR='{}' test -f README.md",
12413 cache_dir.display()
12414 )];
12415 config.graph.review_rounds = 1;
12416 config.graph.timeout_verify = Some(2);
12419
12420 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12421 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12422 .expect("no io error acquiring directly")
12423 {
12424 crate::cache::AcquireOutcome::Acquired(g) => g,
12425 crate::cache::AcquireOutcome::Busy(b) => {
12426 panic!("expected the direct acquire to win the lease first: {b:?}")
12427 }
12428 };
12429
12430 let mut state = RunState::new(
12431 repo.clone(),
12432 "main".to_owned(),
12433 head.clone(),
12434 "task".to_owned(),
12435 config,
12436 );
12437 state.candidates = vec![Candidate {
12438 index: 0,
12439 label: 'A',
12440 agent: "alpha".to_owned(),
12441 branch: "does-not-exist".to_owned(),
12442 worktree: repo.clone(),
12443 summary: String::new(),
12444 stat: String::new(),
12445 files: 0,
12446 commits: 0,
12447 empty: false,
12448 failed: None,
12449 verified_noop: None,
12450 duration_ms: 0,
12451 folded: false,
12452 }];
12453 state.tally = Some(Tally {
12454 first_choice: BTreeMap::from([('A', 1)]),
12455 borda: BTreeMap::new(),
12456 winner: 'A',
12457 rankings: 1,
12458 unanimous_initial: true,
12459 deliberated: false,
12460 changed_votes: 0,
12461 unanimous_final: true,
12462 tie_break: None,
12463 judges: 0,
12464 present: 0,
12465 quorum: 0,
12466 met_quorum: true,
12467 uncontested: Some("only candidate A produced a change".to_owned()),
12468 });
12469 state.reviews = vec![ReviewRound {
12473 round: 1,
12474 head: head.clone(),
12475 verified_head: None,
12476 verified_at: None,
12477 reviews: Vec::new(),
12478 e2e: Vec::new(),
12479 fix: None,
12480 blocking: 1,
12481 answered: 1,
12482 expected: 1,
12483 clean: false,
12484 verify_retried: false,
12485 e2e_deferred: true,
12486 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
12487 progressed: false,
12488 vote_split: false,
12489 reconsideration: Vec::new(),
12490 verdict: None,
12491 }];
12492
12493 let mut runner = Runner {
12494 state,
12495 roles: ResolvedRoles {
12496 implementers: Vec::new(),
12497 judges: Vec::new(),
12498 reviewers: Vec::new(),
12499 fixer: None,
12500 conductor: conductor(),
12501 implementer_roster: Vec::new(),
12502 judge_roster: Vec::new(),
12503 reviewer_roster: Vec::new(),
12504 },
12505 sem: Arc::new(Semaphore::new(1)),
12506 pause: Pause::new(),
12507 interrupt: Pause::new(),
12508 };
12509
12510 let shell = runner.state.config.shell();
12511 runner
12512 .stop_reviewing("round budget spent", &shell, &repo)
12513 .await
12514 .expect("stop_reviewing");
12515
12516 let last = runner.state.reviews.last().expect("round record");
12517 assert_eq!(
12518 last.e2e_status(),
12519 E2eStatus::ResourceBlocked,
12520 "the shared cache is still held; the attempt must read as blocked, not deferred or \
12521 failed: {last:?}"
12522 );
12523 assert_eq!(
12524 last.verified_head.as_deref(),
12525 Some(head.as_str()),
12526 "which commit this attempt targeted is known even though nothing finished checking \
12527 it"
12528 );
12529 let first_attempt_at = last
12530 .verified_at
12531 .expect("when this attempt ran is known too");
12532 assert_ne!(
12533 runner.state.status,
12534 RunStatus::Blocked,
12535 "contention is evidence about the machine, not the patch — it must not settle the \
12536 run as blocked: {:?}",
12537 runner.state.status
12538 );
12539 assert!(
12540 !runner
12541 .state
12542 .events
12543 .iter()
12544 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
12545 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
12546 runner.state.events
12547 );
12548
12549 runner
12554 .stop_reviewing("round budget spent", &shell, &repo)
12555 .await
12556 .expect("stop_reviewing retry");
12557 assert_eq!(
12558 runner.state.reviews.len(),
12559 1,
12560 "no new round was started: {:?}",
12561 runner.state.reviews
12562 );
12563 let last = runner.state.reviews.last().expect("round record");
12564 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
12565 assert!(
12566 last.verified_at.expect("still known") > first_attempt_at,
12567 "a second reentry must be a fresh attempt, not a stale copy of the first"
12568 );
12569 assert_ne!(runner.state.status, RunStatus::Blocked);
12570
12571 held.release();
12572 }
12573
12574 #[tokio::test]
12586 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
12587 crate::run::pin_test_home();
12588 let home = crate::run::home();
12589
12590 let tmp = tempfile::tempdir().expect("tempdir");
12591 let repo = tmp.path().join("repo");
12592 std::fs::create_dir_all(&repo).unwrap();
12593 init_repo(&repo);
12594 let head = crate::git::rev_parse(&repo, "HEAD")
12595 .await
12596 .expect("rev-parse");
12597 let cache_dir = tmp.path().join("target");
12598
12599 let mut config = Config::default();
12600 config.verify.e2e = vec![format!(
12601 "CARGO_TARGET_DIR='{}' test -f README.md",
12602 cache_dir.display()
12603 )];
12604 config.graph.review_rounds = 1;
12605 config.graph.timeout_verify = Some(2);
12606
12607 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12608 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12609 .expect("no io error acquiring directly")
12610 {
12611 crate::cache::AcquireOutcome::Acquired(g) => g,
12612 crate::cache::AcquireOutcome::Busy(b) => {
12613 panic!("expected the direct acquire to win the lease first: {b:?}")
12614 }
12615 };
12616
12617 let mut state = RunState::new(
12618 repo.clone(),
12619 "main".to_owned(),
12620 head.clone(),
12621 "task".to_owned(),
12622 config,
12623 );
12624 state.candidates = vec![Candidate {
12625 index: 0,
12626 label: 'A',
12627 agent: "alpha".to_owned(),
12628 branch: "does-not-exist".to_owned(),
12629 worktree: repo.clone(),
12630 summary: String::new(),
12631 stat: String::new(),
12632 files: 0,
12633 commits: 0,
12634 empty: false,
12635 failed: None,
12636 verified_noop: None,
12637 duration_ms: 0,
12638 folded: false,
12639 }];
12640 state.tally = Some(Tally {
12641 first_choice: BTreeMap::from([('A', 1)]),
12642 borda: BTreeMap::new(),
12643 winner: 'A',
12644 rankings: 1,
12645 unanimous_initial: true,
12646 deliberated: false,
12647 changed_votes: 0,
12648 unanimous_final: true,
12649 tie_break: None,
12650 judges: 0,
12651 present: 0,
12652 quorum: 0,
12653 met_quorum: true,
12654 uncontested: Some("only candidate A produced a change".to_owned()),
12655 });
12656 state.reviews = vec![ReviewRound {
12660 round: 1,
12661 head: head.clone(),
12662 verified_head: Some(head.clone()),
12663 verified_at: Some(jiff::Timestamp::now()),
12664 reviews: Vec::new(),
12665 e2e: vec![CommandOutcome {
12666 command: format!(
12667 "CARGO_TARGET_DIR='{}' test -f README.md",
12668 cache_dir.display()
12669 ),
12670 code: None,
12671 output_tail: "waiting for the shared build cache".to_owned(),
12672 duration_ms: 0,
12673 resource_blocked: true,
12674 }],
12675 fix: None,
12676 blocking: 1,
12677 answered: 1,
12678 expected: 1,
12679 clean: false,
12680 verify_retried: false,
12681 e2e_deferred: false,
12682 e2e_defer_reason: None,
12683 progressed: false,
12684 vote_split: false,
12685 reconsideration: Vec::new(),
12686 verdict: None,
12687 }];
12688
12689 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
12690 let mut runner = Runner {
12691 state,
12692 roles: ResolvedRoles {
12693 implementers: Vec::new(),
12694 judges: Vec::new(),
12695 reviewers: Vec::new(),
12696 fixer: None,
12697 conductor: conductor(),
12698 implementer_roster: Vec::new(),
12699 judge_roster: Vec::new(),
12700 reviewer_roster: Vec::new(),
12701 },
12702 sem: Arc::new(Semaphore::new(1)),
12703 pause: Pause::new(),
12704 interrupt: Pause::new(),
12705 };
12706
12707 runner.review_loop().await.expect("review_loop");
12712
12713 assert_eq!(
12714 runner.state.reviews.len(),
12715 1,
12716 "no new round was started on top of the unresolved one: {:?}",
12717 runner.state.reviews
12718 );
12719 let last = &runner.state.reviews[0];
12720 assert_eq!(
12721 last.e2e_status(),
12722 E2eStatus::ResourceBlocked,
12723 "still contended: {last:?}"
12724 );
12725 assert!(
12726 last.verified_at.expect("still known") > first_attempt_at,
12727 "review_loop must have actually retried the check, not left it exactly as found"
12728 );
12729 assert_ne!(
12730 runner.state.status,
12731 RunStatus::Blocked,
12732 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
12733 runner.state.status
12734 );
12735
12736 held.release();
12737 }
12738
12739 #[tokio::test]
12740 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
12741 crate::run::pin_test_home();
12742 let tmp = tempfile::tempdir().expect("tempdir");
12743 let repo = tmp.path().join("repo");
12744 std::fs::create_dir_all(&repo).unwrap();
12745 init_repo(&repo);
12746
12747 let mut config = Config::default();
12748 config.merge.mode = MergeMode::Pr;
12749 config.graph.land = true;
12750 config.graph.land_approval = false;
12751
12752 let mut state = RunState::new(
12753 repo.clone(),
12754 "main".to_owned(),
12755 "deadbeef".to_owned(),
12756 "task".to_owned(),
12757 config,
12758 );
12759 state.candidates = vec![Candidate {
12760 index: 0,
12761 label: 'A',
12762 agent: "alpha".to_owned(),
12763 branch: "does-not-exist".to_owned(),
12764 worktree: repo.clone(),
12765 summary: String::new(),
12766 stat: String::new(),
12767 files: 0,
12768 commits: 0,
12769 empty: false,
12770 failed: None,
12771 verified_noop: None,
12772 duration_ms: 0,
12773 folded: false,
12774 }];
12775 state.tally = Some(Tally {
12776 first_choice: BTreeMap::from([('A', 1)]),
12777 borda: BTreeMap::new(),
12778 winner: 'A',
12779 rankings: 1,
12780 unanimous_initial: true,
12781 deliberated: false,
12782 changed_votes: 0,
12783 unanimous_final: true,
12784 tie_break: None,
12785 judges: 0,
12786 present: 0,
12787 quorum: 0,
12788 met_quorum: true,
12789 uncontested: Some("only candidate A produced a change".to_owned()),
12790 });
12791 state.reviews = vec![ReviewRound {
12792 round: 1,
12793 head: "deadbeef".to_owned(),
12794 verified_head: None,
12795 verified_at: None,
12796 reviews: Vec::new(),
12797 e2e: Vec::new(),
12798 fix: None,
12799 blocking: 0,
12800 answered: 0,
12801 expected: 0,
12802 clean: true,
12803 verify_retried: false,
12804 e2e_deferred: false,
12805 e2e_defer_reason: None,
12806 progressed: false,
12807 vote_split: false,
12808 reconsideration: Vec::new(),
12809 verdict: None,
12810 }];
12811 state.gate = vec![CommandOutcome {
12812 command: "test".to_owned(),
12813 code: Some(0),
12814 output_tail: String::new(),
12815 duration_ms: 0,
12816 resource_blocked: false,
12817 }];
12818 state.gate_ran = true;
12819 state.status = RunStatus::Landing;
12823 state.merge = Some(MergeOutcome {
12824 mode: MergeMode::Pr,
12825 ok: true,
12826 detail: "https://example.invalid/x/y/pull/1".to_owned(),
12827 empty: false,
12828 });
12829
12830 ask_test_home();
12834 state.save().expect("save landing run");
12837 let store = ask::Questions::open();
12838 let q = ask_open_question(&store, &state.id);
12839
12840 let mut runner = Runner {
12841 state,
12842 roles: ResolvedRoles {
12843 implementers: Vec::new(),
12844 judges: Vec::new(),
12845 reviewers: Vec::new(),
12846 fixer: None,
12847 conductor: conductor(),
12848 implementer_roster: Vec::new(),
12849 judge_roster: Vec::new(),
12850 reviewer_roster: Vec::new(),
12851 },
12852 sem: Arc::new(Semaphore::new(1)),
12853 pause: Pause::new(),
12854 interrupt: Pause::new(),
12855 };
12856
12857 runner.execute().await.expect("execute");
12862
12863 assert_eq!(
12864 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12865 Some("https://example.invalid/x/y/pull/1"),
12866 "reentry must not push again or open a second pull request over the \
12867 one `land` is already watching"
12868 );
12869 assert_ne!(
12870 runner.state.status,
12871 RunStatus::Landing,
12872 "land could not actually reach the fake pull request, so it must \
12873 have given up rather than left the run silently parked forever"
12874 );
12875 assert_eq!(runner.state.status, RunStatus::Blocked);
12879 assert!(
12880 store.get(&q.id).unwrap().status.open(),
12881 "Blocked is still alive; settle_questions must have been a no-op here"
12882 );
12883 }
12884
12885 fn state_with_round(round: ReviewRound) -> RunState {
12886 let mut s = RunState::new(
12887 PathBuf::from("/repo"),
12888 "main".to_owned(),
12889 "abc1234".to_owned(),
12890 "add retries".to_owned(),
12891 Config::default(),
12892 );
12893 s.reviews = vec![round];
12894 s
12895 }
12896
12897 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
12898 crate::verdict::Finding {
12899 id: id.to_owned(),
12900 severity,
12901 file: None,
12902 line: None,
12903 title: title.to_owned(),
12904 detail: String::new(),
12905 }
12906 }
12907
12908 #[test]
12909 fn pr_body_names_open_findings_and_declined_ones() {
12910 let round = ReviewRound {
12911 round: 2,
12912 head: "deadbee".to_owned(),
12913 verified_head: None,
12914 verified_at: None,
12915 reviews: vec![ReviewRecord {
12916 attempts: 0,
12917 reviewer: 1,
12918 agent: "alpha".to_owned(),
12919 summary: String::new(),
12920 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
12921 vote: None,
12922 failed: None,
12923 duration_ms: 0,
12924 }],
12925 e2e: vec![CommandOutcome {
12926 command: "cargo test".to_owned(),
12927 code: Some(0),
12928 output_tail: String::new(),
12929 duration_ms: 0,
12930 resource_blocked: false,
12931 }],
12932 verify_retried: false,
12933 e2e_deferred: false,
12934 e2e_defer_reason: None,
12935 fix: Some(FixRecord {
12936 agent: "alpha".to_owned(),
12937 addressed: Vec::new(),
12938 rejected: vec![crate::verdict::Rejection {
12939 id: "R1-1-1".to_owned(),
12940 why: "not reachable from any caller".to_owned(),
12941 }],
12942 notes: String::new(),
12943 committed: true,
12944 failed: None,
12945 duration_ms: 0,
12946 continuation: None,
12947 }),
12948 blocking: 0,
12949 answered: 1,
12950 expected: 1,
12951 clean: false,
12952 progressed: true,
12953 vote_split: false,
12954 reconsideration: Vec::new(),
12955 verdict: None,
12956 };
12957 let state = state_with_round(round);
12958 let body = pr_message(&state, 'A').body;
12959
12960 assert!(body.contains("add retries"), "the task must still be there");
12961 assert!(body.contains("R2-1-1"), "{body}");
12962 assert!(body.contains("unused import"), "{body}");
12963 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
12964 assert!(
12965 body.contains("not reachable from any caller"),
12966 "the reason it was declined: {body}"
12967 );
12968 }
12969
12970 #[test]
12971 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
12972 let round = ReviewRound {
12973 round: 1,
12974 head: "deadbee".to_owned(),
12975 verified_head: None,
12976 verified_at: None,
12977 reviews: vec![ReviewRecord {
12978 attempts: 0,
12979 reviewer: 1,
12980 agent: "alpha".to_owned(),
12981 summary: String::new(),
12982 findings: Vec::new(),
12983 vote: None,
12984 failed: None,
12985 duration_ms: 0,
12986 }],
12987 e2e: Vec::new(),
12988 verify_retried: false,
12989 e2e_deferred: false,
12990 e2e_defer_reason: None,
12991 fix: None,
12992 blocking: 0,
12993 answered: 1,
12994 expected: 1,
12995 clean: true,
12996 progressed: false,
12997 vote_split: false,
12998 reconsideration: Vec::new(),
12999 verdict: None,
13000 };
13001 let state = state_with_round(round);
13002 let body = pr_message(&state, 'A').body;
13003 assert!(!body.contains("Open review findings"), "{body}");
13004 assert!(!body.contains("Declined"), "{body}");
13005 }
13006
13007 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13008 let mut state = RunState::new(
13009 PathBuf::from("/repo"),
13010 "main".to_owned(),
13011 "abc1234".to_owned(),
13012 instruction.to_owned(),
13013 Config::default(),
13014 );
13015 state.candidates.push(Candidate {
13016 index: 0,
13017 label: 'A',
13018 agent: "alpha".to_owned(),
13019 branch: "magi/x/A".to_owned(),
13020 worktree: PathBuf::from("/wt"),
13021 summary: summary.to_owned(),
13022 stat: String::new(),
13023 files: 1,
13024 commits: 1,
13025 empty: false,
13026 failed: None,
13027 verified_noop: None,
13028 folded: false,
13029 duration_ms: 0,
13030 });
13031 state
13032 }
13033
13034 fn review_state(subjects: &[&str]) -> RunState {
13035 let mut state = state_with_summary(
13036 "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",
13037 "",
13038 );
13039 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13040 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13041 state
13042 }
13043
13044 #[tokio::test]
13047 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13048 ask_test_home();
13049 let tmp = tempfile::tempdir().unwrap();
13050 let repo = tmp.path().join("repo");
13051 std::fs::create_dir_all(&repo).unwrap();
13052 init_repo(&repo);
13053 let origin = tmp.path().join("origin.git");
13054 let g = |dir: &Path, args: &[&str]| {
13055 let out = std::process::Command::new("git")
13056 .args(args)
13057 .current_dir(dir)
13058 .quiet()
13059 .output()
13060 .expect("spawn git");
13061 assert!(
13062 out.status.success(),
13063 "git {args:?}: {}",
13064 String::from_utf8_lossy(&out.stderr)
13065 );
13066 };
13067 g(
13068 tmp.path(),
13069 &[
13070 "clone",
13071 "--bare",
13072 "-q",
13073 repo.to_str().unwrap(),
13074 origin.to_str().unwrap(),
13075 ],
13076 );
13077 g(
13078 &repo,
13079 &["remote", "add", "origin", origin.to_str().unwrap()],
13080 );
13081 let c1 = git::rev_parse(&repo, "main").await.unwrap();
13082
13083 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
13085 g(&repo, &["add", "-A"]);
13086 g(
13087 &repo,
13088 &["commit", "-q", "-m", "chore(deps): update a crate"],
13089 );
13090 g(&repo, &["push", "-q", "origin", "main"]);
13091 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
13092 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13093 g(&repo, &["add", "-A"]);
13094 g(
13095 &repo,
13096 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
13097 );
13098 g(&repo, &["checkout", "-q", "main"]);
13099
13100 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
13101 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
13102 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
13104 assert_eq!(fallback, c1);
13105
13106 let mut state = review_state(&[
13107 "chore(deps): update a crate",
13108 "fix(daemon): apply a chosen action",
13109 ]);
13110 state.repo = repo.clone();
13111 state.base_branch = "main".to_owned();
13112 state.base_commit = c1;
13113 refresh_reviewed_commits(&mut state, "feat/own").await;
13114 assert_eq!(
13115 state.reviewed_commits,
13116 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
13117 );
13118 assert_eq!(
13119 review_title(&state).as_deref(),
13120 Some("fix(daemon): apply a chosen action")
13121 );
13122 assert_eq!(
13123 leaked_subjects(&state, "feat/own").await,
13124 Some(vec!["chore(deps): update a crate".to_owned()])
13125 );
13126 state.config.merge.remote = "nowhere".to_owned();
13129 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
13130 }
13131
13132 struct SummaryFixture {
13136 _tmp: tempfile::TempDir,
13137 repo: PathBuf,
13138 reply: PathBuf,
13139 runner: Runner,
13140 }
13141
13142 fn summary_fixture() -> SummaryFixture {
13143 ask_test_home();
13144 let tmp = tempfile::tempdir().unwrap();
13145 let repo = tmp.path().join("repo");
13146 std::fs::create_dir_all(&repo).unwrap();
13147 init_repo(&repo);
13148 let origin = tmp.path().join("origin.git");
13149 let g = |args: &[&str]| {
13150 let out = std::process::Command::new("git")
13151 .args(args)
13152 .current_dir(&repo)
13153 .quiet()
13154 .output()
13155 .expect("spawn git");
13156 assert!(
13157 out.status.success(),
13158 "git {args:?}: {}",
13159 String::from_utf8_lossy(&out.stderr)
13160 );
13161 };
13162 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
13163 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
13164 g(&["push", "-q", "origin", "main"]);
13165 g(&["checkout", "-q", "-b", "feat/own"]);
13166 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13167 g(&["add", "-A"]);
13168 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
13169 let reply = tmp.path().join("reply.txt");
13170 std::fs::write(
13171 &reply,
13172 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
13173 )
13174 .unwrap();
13175 let mut runner = runner_at(RunStatus::Gating);
13176 let mut state = review_state(&["feat(own): add the own file"]);
13177 state.repo = repo.clone();
13178 state.base_branch = "main".to_owned();
13179 state.candidates[0].branch = "feat/own".to_owned();
13180 state.candidates[0].worktree = repo.clone();
13181 let mut author = spec("alpha");
13182 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
13183 author
13184 .env
13185 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
13186 state.config.agents = vec![author];
13187 runner.state = state;
13188 SummaryFixture {
13189 _tmp: tmp,
13190 repo,
13191 reply,
13192 runner,
13193 }
13194 }
13195
13196 async fn posted(f: &mut SummaryFixture) -> PrMessage {
13197 let winner = f.runner.state.candidates[0].clone();
13198 f.runner
13199 .guarded_pr_message(&winner, None, true)
13200 .await
13201 .unwrap()
13202 .expect("an English description never parks")
13203 }
13204
13205 #[tokio::test]
13206 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
13207 let mut f = summary_fixture();
13208 let m = posted(&mut f).await;
13209 assert_eq!(m.title, "feat(own): add the own file");
13210 assert!(
13211 m.body
13212 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
13213 "{}",
13214 m.body
13215 );
13216 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
13217 assert!(m.body.contains("## Commits under review"), "{}", m.body);
13218 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13219 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13220 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
13221 let kept = f.runner.state.change_summary.as_ref().unwrap();
13222 assert_eq!(
13223 (kept.head.as_str(), kept.base.as_str()),
13224 (head.as_str(), base.as_str())
13225 );
13226 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
13227 }
13228
13229 #[tokio::test]
13230 async fn review_fixes_come_after_the_whole_change_summary() {
13231 let mut f = summary_fixture();
13232 f.runner.state.reviews = vec![
13233 round_with_notes(1, Some("handled the PATH case")),
13234 round_with_notes(2, None),
13235 ];
13236 let m = posted(&mut f).await;
13237 let summary = m.body.find("## Summary").expect("summary");
13238 let fixes = m.body.find("## Review fixes").expect("review fixes");
13239 assert!(summary < fixes, "{}", m.body);
13240 assert!(m.body.contains("handled the PATH case"), "{}", m.body);
13241 assert!(m.body.contains("## Final verification"), "{}", m.body);
13242 }
13243
13244 #[tokio::test]
13245 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
13246 let mut f = summary_fixture();
13247 let first = posted(&mut f).await;
13248 let first_head = f.runner.state.change_summary.clone().unwrap().head;
13249 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
13251 let again = posted(&mut f).await;
13252 assert_eq!((again.title, again.body), (first.title, first.body));
13253
13254 let g = |args: &[&str]| {
13255 let out = std::process::Command::new("git")
13256 .args(args)
13257 .current_dir(&f.repo)
13258 .quiet()
13259 .output()
13260 .expect("spawn git");
13261 assert!(out.status.success(), "git {args:?}");
13262 };
13263 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
13264 g(&["add", "-A"]);
13265 g(&["commit", "-q", "-m", "fix(own): handle more"]);
13266 let second = posted(&mut f).await;
13267 let kept = f.runner.state.change_summary.clone().unwrap();
13268 assert_ne!(kept.head, first_head);
13269 assert!(second.body.contains("Not read yet."), "{}", second.body);
13270 assert!(!second.body.contains("so the first summary is read"));
13271 assert_eq!(second.title, "feat(own): other");
13272 }
13273
13274 #[tokio::test]
13275 async fn an_unavailable_summary_is_stated_and_never_invented() {
13276 let mut f = summary_fixture();
13277 std::fs::write(&f.reply, "no title line here").unwrap();
13278 let m = posted(&mut f).await;
13279 assert!(
13280 m.body
13281 .contains("No description of what this change does could be generated"),
13282 "{}",
13283 m.body
13284 );
13285 assert!(m.body.contains("do not \nexplain it") || m.body.contains("do not explain it"));
13286 assert!(f.runner.state.change_summary.is_none());
13287 assert!(m.body.contains("## Commits under review"), "{}", m.body);
13288 let winner = f.runner.state.candidates[0].clone();
13290 let preview = f
13291 .runner
13292 .guarded_pr_message(&winner, None, false)
13293 .await
13294 .unwrap()
13295 .unwrap();
13296 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
13297 }
13298
13299 #[test]
13300 fn pr_message_review_single_commit_uses_its_subject() {
13301 let state = review_state(&["feat(nats): per-role user"]);
13302 let m = pr_message(&state, 'A');
13303 assert_eq!(m.title, "feat(nats): per-role user");
13304 assert!(!m.body.contains("Review the work already"), "{}", m.body);
13305 assert!(m.body.contains("## Commits under review"), "{}", m.body);
13306 }
13307
13308 #[test]
13309 fn pr_message_review_multi_commit_takes_the_oldest() {
13310 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
13311 let m = pr_message(&state, 'A');
13312 assert_eq!(m.title, "feat: the change");
13313 for s in ["feat: the change", "fix: typo", "fix: again"] {
13314 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
13315 }
13316 }
13317
13318 #[test]
13319 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
13320 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
13321 let mut state = review_state(&[first, "fix: later fixup"]);
13322 state.candidates[0].branch = "機能/ログイン".to_owned();
13323 let m = pr_message(&state, 'A');
13324 assert!(
13325 m.title.starts_with("chore: land candidate A of run"),
13326 "{}",
13327 m.title
13328 );
13329 }
13330 }
13331
13332 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
13333 BranchFacts {
13334 commits: commits
13335 .iter()
13336 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
13337 .collect(),
13338 stat: stat.to_owned(),
13339 }
13340 }
13341
13342 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
13343 let mut r = review_round(true, 0, 1, 1, true, true);
13344 r.round = round;
13345 r.fix = notes.map(|n| FixRecord {
13346 agent: "fixer".to_owned(),
13347 addressed: Vec::new(),
13348 rejected: Vec::new(),
13349 notes: n.to_owned(),
13350 committed: true,
13351 failed: None,
13352 duration_ms: 0,
13353 continuation: None,
13354 });
13355 r
13356 }
13357
13358 #[test]
13359 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
13360 let state = review_state(&["ignored"]);
13361 let f = facts(
13362 &[
13363 (
13364 "fix(login): resolve PATH on macOS",
13365 "Login shells skip rc files.",
13366 ),
13367 ("fix: address review", ""),
13368 ],
13369 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
13370 );
13371 let m = pr_message_with(&state, 'A', Some(&f));
13372 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
13373 assert!(
13374 m.body
13375 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
13376 "{}",
13377 m.body
13378 );
13379 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
13380 assert!(m.body.contains("## Diff stat"), "{}", m.body);
13381 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
13382 for banned in [
13383 "Review the work already",
13384 "no task statement",
13385 "Original task",
13386 ] {
13387 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
13388 }
13389 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
13390 }
13391
13392 #[test]
13393 fn pr_message_review_truncates_a_huge_first_commit_body() {
13394 let state = review_state(&["ignored"]);
13395 let f = facts(
13396 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
13397 "s",
13398 );
13399 let m = pr_message_with(&state, 'A', Some(&f));
13400 assert!(m.body.len() < 30_000, "{}", m.body.len());
13401 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
13402 assert!(
13403 m.body.contains("- ... 1 more commit(s)"),
13404 "{}",
13405 m.body.len()
13406 );
13407 assert!(m.body.ends_with("magi:candidate-a\n"));
13408 }
13409
13410 #[test]
13411 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
13412 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
13413 assert_eq!(m.title, "feat: x");
13414 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
13415 assert!(!m.body.contains("Diff stat"), "{}", m.body);
13416 assert!(!m.body.contains("no task statement"), "{}", m.body);
13417 }
13418
13419 #[test]
13420 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
13421 let mut state = review_state(&["日本語の件名"]);
13422 state.candidates[0].branch = "fix/macos-login-path".to_owned();
13423 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
13424 state.candidates[0].branch = "機能/ログイン".to_owned();
13425 assert!(
13426 pr_message(&state, 'A')
13427 .title
13428 .starts_with("chore: land candidate A")
13429 );
13430 }
13431
13432 #[test]
13433 fn pr_message_review_fixes_survive_a_clean_final_round() {
13434 let mut state = review_state(&["feat: x"]);
13435 state.reviews = vec![
13436 round_with_notes(1, Some("handled the PATH case")),
13437 round_with_notes(2, None),
13438 ];
13439 let body = pr_message(&state, 'A').body;
13440 assert!(
13441 body.contains("## Review fixes\n\nhandled the PATH case\n"),
13442 "{body}"
13443 );
13444 assert!(!body.contains("### Round"), "{body}");
13445
13446 state.reviews = vec![
13447 round_with_notes(1, Some("first fix")),
13448 round_with_notes(2, Some("")),
13449 round_with_notes(3, Some("second fix")),
13450 round_with_notes(4, None),
13451 ];
13452 let body = pr_message(&state, 'A').body;
13453 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
13454 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
13455 assert!(!body.contains("### Round 2"), "{body}");
13456 }
13457
13458 const FINAL: &str = "abcdef1234567890";
13459
13460 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
13461 let mut r = round_with_notes(round, None);
13462 r.verified_head = verified.map(str::to_owned);
13463 r
13464 }
13465
13466 fn ok_gate(state: &mut RunState, head: Option<&str>) {
13467 state.gate = vec![CommandOutcome {
13468 command: "gate".to_owned(),
13469 code: Some(0),
13470 output_tail: String::new(),
13471 duration_ms: 0,
13472 resource_blocked: false,
13473 }];
13474 state.gate_ran = true;
13475 state.gate_head = head.map(str::to_owned);
13476 }
13477
13478 fn body_at(state: &RunState) -> String {
13479 pr_message_raw(
13480 state,
13481 'A',
13482 None,
13483 crate::pr_outcome::FinalHead::At(FINAL),
13484 ChangeNote::NotAsked,
13485 )
13486 .body
13487 }
13488
13489 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
13490
13491 #[test]
13492 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
13493 let mut state = review_state(&["feat: x"]);
13494 state.reviews = vec![
13495 round_with_notes(1, Some(SANDBOXED)),
13496 passing_round(2, Some(FINAL)),
13497 ];
13498 ok_gate(&mut state, Some(FINAL));
13499 let body = body_at(&state);
13500 assert!(body.contains("Handled the PATH case."), "{body}");
13501 assert!(!body.contains("could not commit"), "{body}");
13502 assert!(!body.contains("could not run"), "{body}");
13503 assert!(
13504 body.contains("The fix is committed on the branch"),
13505 "{body}"
13506 );
13507 assert!(body.contains("2 statement(s)"), "{body}");
13508 assert!(
13509 body.contains("Tests (`verify.e2e`): passed on the final commit"),
13510 "{body}"
13511 );
13512 assert!(
13513 body.contains("Gate (`verify.gate`): passed on the final commit"),
13514 "{body}"
13515 );
13516 assert!(
13518 state.reviews[0]
13519 .fix
13520 .as_ref()
13521 .unwrap()
13522 .notes
13523 .contains("could not commit")
13524 );
13525 }
13526
13527 #[test]
13528 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
13529 let mut state = review_state(&["feat: x"]);
13530 let mut r1 = round_with_notes(1, Some(SANDBOXED));
13531 r1.fix.as_mut().unwrap().committed = false;
13532 let mut r2 = passing_round(2, Some(FINAL));
13533 r2.e2e[0].code = Some(1);
13534 state.reviews = vec![r1, r2];
13535 let body = body_at(&state);
13536 assert!(body.contains("could not commit"), "{body}");
13537 assert!(body.contains("could not run"), "{body}");
13538 assert!(body.contains("This round produced no commit"), "{body}");
13539 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
13540 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
13541 assert!(!body.contains("passed"), "{body}");
13542 }
13543
13544 #[test]
13545 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
13546 let mut state = review_state(&["feat: x"]);
13547 state.reviews = vec![
13548 round_with_notes(1, Some(SANDBOXED)),
13549 passing_round(2, Some("0123456789abcdef")),
13550 ];
13551 ok_gate(&mut state, Some("0123456789abcdef"));
13552 let body = body_at(&state);
13553 assert!(body.contains("HTTP tests could not run"), "{body}");
13554 assert!(!body.contains("could not commit"), "{body}");
13556 assert!(
13557 body.contains("passed on an earlier commit (`0123456`)"),
13558 "{body}"
13559 );
13560 assert!(!body.contains("passed on the final commit"), "{body}");
13561
13562 ok_gate(&mut state, None);
13564 let body = body_at(&state);
13565 assert!(
13566 body.contains("passed on a commit that was not recorded"),
13567 "{body}"
13568 );
13569 }
13570
13571 #[test]
13572 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
13573 let state = state_with_summary(
13574 "add retries to the client",
13575 "TITLE: feat: retries\n\nDid it.",
13576 );
13577 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
13578 assert_eq!(m.title, "feat: retries");
13579 assert!(
13580 m.body.contains("<summary>Original task</summary>"),
13581 "{}",
13582 m.body
13583 );
13584 assert!(!m.body.contains("Commits under review"), "{}", m.body);
13585 assert!(
13586 m.body
13587 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
13588 "{}",
13589 m.body
13590 );
13591 }
13592
13593 #[test]
13594 fn pr_message_review_bounds_a_long_english_subject() {
13595 let long = format!("feat: {}", "word ".repeat(100));
13596 let m = pr_message(&review_state(&[&long]), 'A');
13597 assert!(m.title.starts_with("feat: word"), "{}", m.title);
13598 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13599 }
13600
13601 #[test]
13602 fn pr_message_implementation_run_is_unchanged_by_review_support() {
13603 let state = state_with_summary("add retries\n\ndetails", "- did some things");
13604 let m = pr_message(&state, 'A');
13605 assert_eq!(m.title, "add retries");
13606 assert!(m.body.contains("<summary>Original task</summary>"));
13607 assert!(!m.body.contains("Commits under review"));
13608 assert_eq!(landing_subject_source(&state), state.instruction);
13609 }
13610
13611 #[test]
13612 fn review_run_squash_subject_is_the_change_not_the_prompt() {
13613 let state = review_state(&["feat: the change", "fix: typo"]);
13614 let source = landing_subject_source(&state);
13615 assert_eq!(land::merge_subject("", &source), "feat: the change");
13616 assert_eq!(
13617 land::merge_subject("magi: candidate A (uncommitted work)", &source),
13618 "feat: the change"
13619 );
13620 assert_eq!(
13622 land::merge_subject("feat: renamed by hand", &source),
13623 "feat: renamed by hand"
13624 );
13625 let blank = review_state(&["日本語"]);
13626 assert!(
13627 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
13628 );
13629 }
13630
13631 #[test]
13632 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
13633 let state = review_state(&["feat: the change"]);
13634 let source = landing_subject_source(&state);
13635 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
13636 assert_eq!(
13637 land::merge_subject(landing_title(&state, old), &source),
13638 "feat: the change"
13639 );
13640 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
13641 let task = state_with_summary("add retries", "");
13642 assert_eq!(landing_title(&task, old), old);
13643 }
13644
13645 #[test]
13646 fn pr_message_describes_the_change_not_the_task() {
13647 let state = state_with_summary(
13648 "今回やってほしいこと: results projector を直す",
13649 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
13650 );
13651 let m = pr_message(&state, 'A');
13652 assert_eq!(m.title, "fix(web): batch the runs list reads");
13653 assert!(
13654 m.body.starts_with("## Summary\n\n- reads run.json once"),
13655 "{}",
13656 m.body
13657 );
13658 assert!(!m.body.contains("TITLE:"), "{}", m.body);
13659 let task_at = m.body.find("今回やってほしいこと").unwrap();
13660 let details_at = m.body.find("<details>").unwrap();
13661 assert!(
13662 details_at < task_at,
13663 "the task lives inside <details>: {}",
13664 m.body
13665 );
13666 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
13667 assert!(m.body.contains("magi:candidate-a"));
13668 }
13669
13670 #[test]
13671 fn pr_message_falls_back_to_the_task_without_a_title_line() {
13672 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
13673 let m = pr_message(&state, 'A');
13674 assert_eq!(m.title, "add retries");
13675 assert!(
13676 m.body.contains("## Summary\n\n- did some things"),
13677 "{}",
13678 m.body
13679 );
13680
13681 let none = RunState::new(
13682 PathBuf::from("/repo"),
13683 "main".to_owned(),
13684 "abc1234".to_owned(),
13685 "add retries".to_owned(),
13686 Config::default(),
13687 );
13688 let m = pr_message(&none, 'A');
13689 assert_eq!(m.title, "add retries");
13690 assert!(!m.body.contains("## Summary"), "{}", m.body);
13691 }
13692
13693 #[test]
13694 fn pr_message_refuses_the_candidate_commit_subject() {
13695 for bad in [
13696 "TITLE: magi: candidate A (uncommitted work)",
13697 "TITLE: chore: stuff (uncommitted work)",
13698 "TITLE: ",
13699 ] {
13700 let state = state_with_summary("add retries", bad);
13701 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
13702 }
13703 }
13704
13705 #[test]
13706 fn pr_message_bounds_a_very_long_task_and_title() {
13707 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
13708 let state = state_with_summary(&long, "- nothing");
13709 let m = pr_message(&state, 'A');
13710 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13711 assert!(!m.title.contains('\n'));
13712
13713 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
13714 let m = pr_message(&state, 'A');
13715 assert!(m.title.starts_with("feat: "));
13716 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13717 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
13718 }
13719
13720 fn long_title_of(instruction: &str) -> String {
13721 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
13722 }
13723
13724 #[test]
13725 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
13726 let first = "Make the landing path keep a readable title for long tasks";
13727 let line = format!(
13728 "{first}. {}",
13729 "And then keep going with more words ".repeat(20)
13730 );
13731 let t = long_title_of(&line);
13732 assert_eq!(t, first);
13733 assert!(!t.starts_with("chore: land"));
13734 }
13735
13736 #[test]
13737 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
13738 let line = "word ".repeat(200);
13739 let t = long_title_of(&line);
13740 assert!(t.ends_with("word..."), "{t}");
13741 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
13742 }
13743
13744 #[test]
13745 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
13746 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
13747 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
13748 }
13749 }
13750
13751 #[test]
13752 fn pr_message_title_limit_is_exact() {
13753 let at = "a".repeat(PR_TITLE_MAX);
13754 assert_eq!(long_title_of(&at), at);
13755 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
13756 assert!(over.ends_with("..."), "{over}");
13757 assert_eq!(over.chars().count(), PR_TITLE_MAX);
13758 }
13759
13760 #[test]
13761 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
13762 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
13763 let t = long_title_of(&line);
13764 assert!(t.ends_with("..."), "{t}");
13765 assert!(t.is_ascii(), "{t}");
13766 }
13767
13768 #[test]
13769 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
13770 let line = format!(
13771 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
13772 "plus more filler words ".repeat(20)
13773 );
13774 let t = long_title_of(&line);
13775 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
13776 }
13777
13778 #[test]
13779 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
13780 let line = format!("\n\n# {}", "title words ".repeat(40));
13781 let state = state_with_summary(&line, "- nothing");
13782 let m = pr_message(&state, 'A');
13783 assert!(m.title.starts_with("title words"), "{}", m.title);
13784 assert_eq!(
13785 land::merge_subject(&m.title, &landing_subject_source(&state)),
13786 m.title
13787 );
13788 assert_eq!(
13790 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
13791 "feat: renamed by hand"
13792 );
13793 }
13794
13795 #[tokio::test]
13796 async fn github_text_rewrite_is_bounded_and_falls_back() {
13797 crate::run::pin_test_home();
13798 for (reply, accepted) in [
13799 (
13800 "TITLE: fix: retries\nAdd retries for failed requests.",
13801 true,
13802 ),
13803 (
13804 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
13805 false,
13806 ),
13807 (
13808 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
13809 true,
13810 ),
13811 (
13812 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
13813 true,
13814 ),
13815 (
13816 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
13817 true,
13818 ),
13819 (
13820 "TITLE: fix: retries\n日本語の説明です。 token=secret",
13821 false,
13822 ),
13823 ("TITLE: fix: retries\n", false),
13824 ] {
13825 let dir = tempfile::tempdir().unwrap();
13826 let mut runner = runner_at(RunStatus::Gating);
13827 runner.state = state_with_summary(
13828 "add retries",
13829 "TITLE: fix: retries\n日本語の説明を書きます。",
13830 );
13831 runner.state.repo = dir.path().to_owned();
13832 runner.state.candidates[0].worktree = dir.path().to_owned();
13833 let mut author = spec("alpha");
13834 author.command = vec![
13835 "sh".into(),
13836 "-c".into(),
13837 "printf '%s' \"$REWRITE_REPLY\"".into(),
13838 ];
13839 author.env.insert("REWRITE_REPLY".into(), reply.into());
13840 runner.state.config.agents = vec![author];
13841 let winner = runner.state.candidates[0].clone();
13842 let first = runner
13843 .guarded_pr_message(&winner, None, true)
13844 .await
13845 .unwrap();
13846 let message = if accepted {
13847 first.expect("a passing rewrite never asks")
13848 } else {
13849 assert!(first.is_none());
13851 assert!(runner.state.parked);
13852 let store = ask::Questions::open();
13853 let asked: Vec<_> = store
13854 .list()
13855 .into_iter()
13856 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
13857 .collect();
13858 assert_eq!(asked.len(), 1);
13859 assert!(!asked[0].detail.contains("secret"));
13860 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
13861 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
13862 assert_eq!(
13863 asked[0]
13864 .choices
13865 .contains(&crate::github_text::USE_REWRITE.to_owned()),
13866 rewrite.is_some()
13867 );
13868 store
13871 .update(&asked[0].id, |q| {
13872 q.abandon("test");
13873 Ok(())
13874 })
13875 .unwrap();
13876 let again = runner
13877 .guarded_pr_message(&winner, None, true)
13878 .await
13879 .unwrap()
13880 .expect("an abandoned question falls back");
13881 let n = store
13882 .list()
13883 .into_iter()
13884 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
13885 .count();
13886 assert_eq!(n, 1, "asked once per rejected text");
13887 again
13888 };
13889 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
13890 assert!(!message.body.contains("secret"));
13891 assert!(!message.body.contains("prefix"));
13892 assert!(!message.title.contains("secret"));
13893 assert_eq!(
13894 message.body.contains("Add retries for failed requests."),
13895 accepted
13896 );
13897 assert_eq!(
13898 runner.state.seats["github-text-rewrite"].turns, 1,
13899 "only one rewrite invocation"
13900 );
13901 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
13902 if accepted {
13903 assert!(runner.state.github_text.is_none());
13904 assert!(!runner.state.parked);
13905 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
13906 assert!(
13907 message
13908 .body
13909 .contains(&format!("magi:run/{}", runner.state.id))
13910 );
13911 }
13912 if !accepted {
13913 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
13914 assert!(
13915 message
13916 .body
13917 .contains(&format!("magi:run/{}", runner.state.id))
13918 );
13919 }
13920 }
13921 }
13922
13923 fn scripted(id: &str, reply: &str) -> AgentSpec {
13924 let mut a = spec(id);
13925 a.command = vec![
13926 "sh".into(),
13927 "-c".into(),
13928 "printf '%s' \"$REWRITE_REPLY\"".into(),
13929 ];
13930 a.env.insert("REWRITE_REPLY".into(), reply.into());
13931 a
13932 }
13933
13934 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
13935 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
13936
13937 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
13938 let mut runner = runner_at(RunStatus::Gating);
13939 runner.state = state_with_summary("add retries", JA_SUMMARY);
13940 if review_only {
13941 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13942 }
13943 runner.state.repo = dir.to_owned();
13944 runner.state.candidates[0].worktree = dir.to_owned();
13945 runner.state.config.agents = agents;
13946 runner
13947 }
13948
13949 fn gate_questions(runner: &Runner) -> usize {
13950 ask::Questions::open()
13951 .list()
13952 .into_iter()
13953 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
13954 .count()
13955 }
13956
13957 #[tokio::test]
13958 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
13959 crate::run::pin_test_home();
13960 let dir = tempfile::tempdir().unwrap();
13961 let mut runner = rewrite_runner(
13962 dir.path(),
13963 true,
13964 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
13965 );
13966 runner.state.config.roles.language_judge =
13967 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
13968 let winner = runner.state.candidates[0].clone();
13969 let msg = runner
13970 .guarded_pr_message(&winner, None, true)
13971 .await
13972 .unwrap()
13973 .expect("an English rewrite posts without asking");
13974 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
13975 assert!(msg.body.contains("<details>kept</details>"));
13976 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
13977 assert_eq!(gate_questions(&runner), 0);
13978 assert!(runner.state.github_text.is_none());
13979 assert!(!runner.state.seats.contains_key("impl-A"));
13980 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
13981 }
13982
13983 #[tokio::test]
13984 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
13985 crate::run::pin_test_home();
13986 let dir = tempfile::tempdir().unwrap();
13987 let mut runner = rewrite_runner(
13988 dir.path(),
13989 false,
13990 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
13991 );
13992 runner.state.config.roles.language_judge =
13993 Some(crate::config::AgentChoice::One("beta".into()));
13994 runner.state.seats.insert(
13995 "impl-A".into(),
13996 SeatState::new("impl-A", "alpha", runner.state.seed),
13997 );
13998 let winner = runner.state.candidates[0].clone();
13999 let msg = runner
14000 .guarded_pr_message(&winner, None, true)
14001 .await
14002 .unwrap()
14003 .expect("rewritten");
14004 assert!(msg.body.contains("<details>kept</details>"));
14005 assert_eq!(runner.state.seats["impl-A"].turns, 0);
14006 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14007 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
14008 }
14009
14010 #[tokio::test]
14011 async fn rewrite_reuses_a_resumable_implementer_session() {
14012 crate::run::pin_test_home();
14013 let dir = tempfile::tempdir().unwrap();
14014 let mut runner = rewrite_runner(
14015 dir.path(),
14016 false,
14017 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
14018 );
14019 runner.state.config.roles.language_judge =
14020 Some(crate::config::AgentChoice::One("beta".into()));
14021 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
14022 seat.turns = 1;
14023 runner.state.seats.insert("impl-A".into(), seat);
14024 let winner = runner.state.candidates[0].clone();
14025 runner
14026 .guarded_pr_message(&winner, None, true)
14027 .await
14028 .unwrap()
14029 .expect("rewritten");
14030 assert_eq!(runner.state.seats["impl-A"].turns, 2);
14031 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
14032 }
14033
14034 #[tokio::test]
14035 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
14036 crate::run::pin_test_home();
14037 let dir = tempfile::tempdir().unwrap();
14038 let mut broken = spec("bad");
14039 broken.command = vec!["false".into()];
14040 let mut runner = rewrite_runner(
14041 dir.path(),
14042 true,
14043 vec![
14044 broken,
14045 scripted("noformat", "Add retries."),
14046 scripted("good", EN_REWRITE),
14047 ],
14048 );
14049 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
14050 "bad".into(),
14051 "noformat".into(),
14052 "good".into(),
14053 ]));
14054 let winner = runner.state.candidates[0].clone();
14055 let msg = runner
14056 .guarded_pr_message(&winner, None, true)
14057 .await
14058 .unwrap()
14059 .expect("the third agent rewrites");
14060 assert!(msg.body.contains("`src/net.rs`"));
14061 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
14062 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
14063 }
14064
14065 #[tokio::test]
14066 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
14067 crate::run::pin_test_home();
14068 for (agents, role, saved) in [
14070 (
14071 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
14072 Some("beta"),
14073 true,
14074 ),
14075 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
14076 (Vec::new(), None, false),
14077 ] {
14078 let dir = tempfile::tempdir().unwrap();
14079 let mut runner = rewrite_runner(dir.path(), true, agents);
14080 runner.state.config.roles.language_judge =
14081 role.map(|r| crate::config::AgentChoice::One(r.into()));
14082 let winner = runner.state.candidates[0].clone();
14083 let first = runner
14084 .guarded_pr_message(&winner, None, true)
14085 .await
14086 .unwrap();
14087 assert!(first.is_none(), "withheld text parks the run");
14088 assert!(runner.state.parked);
14089 assert_eq!(gate_questions(&runner), 1);
14090 let g = runner.state.github_text.as_ref().unwrap();
14091 assert_eq!(g.rewrite.is_some(), saved);
14092 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
14093 }
14094 }
14095
14096 #[tokio::test]
14097 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
14098 crate::run::pin_test_home();
14099 let dir = tempfile::tempdir().unwrap();
14100 let mut runner = runner_at(RunStatus::Gating);
14101 runner.state =
14102 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
14103 runner.state.repo = dir.path().to_owned();
14104 runner.state.candidates[0].worktree = dir.path().to_owned();
14105 let mut author = spec("alpha");
14106 author.command = vec![
14107 "sh".into(),
14108 "-c".into(),
14109 "printf '%s' \"$REWRITE_REPLY\"".into(),
14110 ];
14111 author.env.insert(
14112 "REWRITE_REPLY".into(),
14113 format!(
14114 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
14115 "English sentence. ".repeat(600)
14116 ),
14117 );
14118 runner.state.config.agents = vec![author];
14119 let winner = runner.state.candidates[0].clone();
14120 assert!(
14121 runner
14122 .guarded_pr_message(&winner, None, true)
14123 .await
14124 .unwrap()
14125 .is_none()
14126 );
14127 let record = runner.state.github_text.clone().unwrap();
14128 let rewrite = record.rewrite.clone().unwrap();
14129 assert_eq!(rewrite.failed, ["body-language"]);
14130 assert!(
14131 rewrite
14132 .body
14133 .ends_with(&format!("magi:run/{}", runner.state.id))
14134 );
14135 let store = ask::Questions::open();
14136 let q = store.get(record.question.as_ref().unwrap()).unwrap();
14137 assert!(
14138 q.choices
14139 .contains(&crate::github_text::USE_REWRITE.to_owned())
14140 );
14141 for label in [
14142 "ORIGINAL",
14143 "FALLBACK",
14144 "REWRITE",
14145 "Rules still failed: body-language",
14146 "truncated",
14147 ] {
14148 assert!(q.detail.contains(label), "{label}");
14149 }
14150 let artifact = std::fs::read_to_string(crate::run::artifact_path(
14151 &runner.state,
14152 &crate::github_text::artifact_name(&record.fingerprint),
14153 ))
14154 .unwrap();
14155 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
14156 runner.state =
14158 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
14159 store
14160 .update(&q.id, |q| {
14161 q.say("まだ日本語")?;
14162 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
14163 })
14164 .unwrap();
14165 assert!(
14166 runner
14167 .guarded_pr_message(&winner, None, true)
14168 .await
14169 .unwrap()
14170 .is_none()
14171 );
14172 let retry = runner.state.github_text.as_ref().unwrap();
14173 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
14174 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
14175 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
14176 store
14177 .update(&q.id, |q| {
14178 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14179 })
14180 .unwrap();
14181 let message = runner
14182 .guarded_pr_message(&winner, None, true)
14183 .await
14184 .unwrap()
14185 .unwrap();
14186 assert_eq!(message.title, rewrite.title);
14187 assert_eq!(message.body, rewrite.body);
14188 assert_eq!(
14189 runner.state.github_text.as_ref().unwrap().choice,
14190 Some(GithubTextChoice::Rewrite)
14191 );
14192 let again = runner
14193 .guarded_pr_message(&winner, None, true)
14194 .await
14195 .unwrap()
14196 .unwrap();
14197 assert_eq!(again.title, message.title);
14198 assert_eq!(again.body, message.body);
14199 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14200 assert!(
14201 !runner
14202 .state
14203 .events
14204 .iter()
14205 .any(|e| e.message.contains(&rewrite.title)
14206 || e.message.contains("English sentence."))
14207 );
14208 }
14209
14210 #[tokio::test]
14211 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
14212 crate::run::pin_test_home();
14213 let dir = tempfile::tempdir().unwrap();
14214 let mut runner = runner_at(RunStatus::Gating);
14215 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
14216 runner.state.repo = dir.path().to_owned();
14217 runner.state.candidates[0].worktree = dir.path().to_owned();
14218 let winner = runner.state.candidates[0].clone();
14219 assert!(
14220 runner
14221 .guarded_pr_message(&winner, None, true)
14222 .await
14223 .unwrap()
14224 .is_none()
14225 );
14226 let record = runner.state.github_text.as_ref().unwrap();
14227 assert!(record.rewrite.is_none());
14228 let mut old = serde_json::to_value(record).unwrap();
14230 old.as_object_mut().unwrap().remove("rewrite");
14231 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
14232 assert!(old.rewrite.is_none());
14233 let id = record.question.clone().unwrap();
14234 ask::Questions::open()
14235 .update(&id, |q| {
14236 q.choices.push(crate::github_text::USE_REWRITE.into());
14237 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14238 })
14239 .unwrap();
14240 let message = runner
14241 .guarded_pr_message(&winner, None, true)
14242 .await
14243 .unwrap()
14244 .unwrap();
14245 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14246 assert_eq!(
14247 runner.state.github_text.as_ref().unwrap().choice,
14248 Some(GithubTextChoice::Fallback)
14249 );
14250 }
14251
14252 #[tokio::test]
14253 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
14254 crate::run::pin_test_home();
14255 let dir = tempfile::tempdir().unwrap();
14256 let mut runner = runner_at(RunStatus::Gating);
14257 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
14258 runner.state.repo = dir.path().to_owned();
14259 runner.state.candidates[0].worktree = dir.path().to_owned();
14260 let winner = runner.state.candidates[0].clone();
14261 assert!(
14262 runner
14263 .guarded_pr_message(&winner, None, true)
14264 .await
14265 .unwrap()
14266 .is_none()
14267 );
14268 let store = ask::Questions::open();
14269 let find = |run: &str| {
14270 store
14271 .list()
14272 .into_iter()
14273 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
14274 .unwrap()
14275 };
14276 let run = runner.state.id.clone();
14277 let q = find(&run);
14278 let fp = runner
14279 .state
14280 .github_text
14281 .as_ref()
14282 .unwrap()
14283 .fingerprint
14284 .clone();
14285 let name = crate::github_text::artifact_name(&fp);
14286 let path = crate::run::artifact_path(&runner.state, &name);
14287 assert!(q.detail.contains(&path.display().to_string()));
14288 assert!(q.detail.contains("title-language: title"));
14289 let saved = std::fs::read_to_string(&path).expect("artifact written");
14290 assert!(saved.contains("日本語のタイトル"));
14291 assert!(q.cwd.is_none());
14292 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
14293 let say = |id: &str, text: &str, choice: &str| {
14295 store
14296 .update(id, |q| {
14297 q.thread.push(ask::Turn {
14298 who: ask::Who::Operator,
14299 body: text.to_owned(),
14300 at: jiff::Timestamp::now(),
14301 note: None,
14302 });
14303 q.answer(ask::Answer::Choice(choice.to_owned()))
14304 })
14305 .unwrap();
14306 };
14307 say(&q.id, "まだ日本語", "use my text");
14308 assert!(
14309 runner
14310 .guarded_pr_message(&winner, None, true)
14311 .await
14312 .unwrap()
14313 .is_none()
14314 );
14315 let second = find(&run);
14316 assert_ne!(second.id, q.id);
14317 assert!(second.detail.contains("did not pass"));
14318 assert!(second.detail.contains("日本語のタイトル"));
14321 assert!(!second.detail.contains("まだ日本語"));
14322 assert!(second.detail.contains(&path.display().to_string()));
14323 say(&second.id, "fix: retry failed requests", "use my text");
14324 let message = runner
14325 .guarded_pr_message(&winner, None, true)
14326 .await
14327 .unwrap()
14328 .expect("a vetted replacement is posted");
14329 assert_eq!(message.title, "fix: retry failed requests");
14330 assert_eq!(
14331 runner
14332 .state
14333 .github_text
14334 .as_ref()
14335 .unwrap()
14336 .chosen_title
14337 .as_deref(),
14338 Some("fix: retry failed requests")
14339 );
14340 assert!(runner.state.github_text.as_ref().unwrap().resolved);
14341 let again = runner
14343 .guarded_pr_message(&winner, None, true)
14344 .await
14345 .unwrap()
14346 .unwrap();
14347 assert_eq!(again.title, message.title);
14348 assert_eq!(
14349 store
14350 .list()
14351 .into_iter()
14352 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
14353 .count(),
14354 2
14355 );
14356 }
14357
14358 #[test]
14359 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
14360 let mut state = state_with_summary(
14364 "add retries",
14365 "TITLE: fix(web): batch reads\n- reads run.json once",
14366 );
14367 state.config.graph.language = "ja".to_owned();
14368 let m = pr_message(&state, 'A');
14369 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
14370 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
14371
14372 let task = "今回やってほしいこと: results projector を直す";
14375 let mut state = state_with_summary(task, "- no title line");
14376 state.config.graph.language = "ja".to_owned();
14377 let m = pr_message(&state, 'A');
14378 assert_eq!(
14379 m.title,
14380 format!("chore: land candidate A of run {}", state.id)
14381 );
14382 assert!(
14383 m.body.contains(&format!(
14384 "<summary>Original task</summary>\n\n{task}\n\n</details>"
14385 )),
14386 "{}",
14387 m.body
14388 );
14389 }
14390
14391 #[test]
14392 fn pr_message_scrubs_home_paths_and_addresses() {
14393 let state = state_with_summary(
14394 "fix it in /Users/someone/src/x",
14395 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
14396 );
14397 let m = pr_message(&state, 'A');
14398 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
14399 assert!(!m.body.contains(leak), "{}", m.body);
14400 }
14401 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
14402 }
14403
14404 #[test]
14405 fn pr_message_survives_a_task_that_closes_details() {
14406 let state = state_with_summary("a </details> b", "TITLE: fix: x");
14407 let m = pr_message(&state, 'A');
14408 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
14409 }
14410
14411 #[test]
14412 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
14413 let cmd = manual_merge_command(
14414 MergeStyle::Squash,
14415 Path::new("/repo"),
14416 "b",
14417 "fix: \"quoted\" $(x) `y`\n\nbody",
14418 );
14419 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
14420 }
14421
14422 #[test]
14423 fn manual_merge_command_matches_the_configured_style() {
14424 let repo = Path::new("/repo");
14425 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
14426
14427 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
14428 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
14429
14430 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
14431 assert_eq!(
14432 squash,
14433 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
14434 \"Merge magi run 0832 (candidate A)\""
14435 );
14436
14437 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
14438 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
14439 }
14440
14441 #[test]
14442 fn a_nudge_gets_a_quarter_of_the_budget() {
14443 assert_eq!(retry_budget(secs(1200), true), secs(300));
14445 assert_eq!(retry_budget(secs(3600), true), secs(900));
14446 }
14447
14448 #[test]
14449 fn a_resent_prompt_keeps_the_whole_budget() {
14450 assert_eq!(retry_budget(secs(1200), false), secs(1200));
14453 assert_eq!(retry_budget(secs(60), false), secs(60));
14454 }
14455
14456 #[test]
14457 fn the_floor_never_exceeds_the_original_budget() {
14458 assert_eq!(retry_budget(secs(60), true), secs(60));
14462 assert_eq!(retry_budget(secs(480), true), secs(120));
14463 assert_eq!(retry_budget(secs(0), true), secs(0));
14464 }
14465
14466 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
14467 agent::CommandEvidence {
14468 id: "item1".to_owned(),
14469 description: "cargo test".to_owned(),
14470 exit_code,
14471 result_summary: String::new(),
14472 source: "codex".to_owned(),
14473 }
14474 }
14475
14476 #[test]
14477 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
14478 assert!(!has_unconfirmed_command(&[]));
14482 }
14483
14484 #[test]
14485 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
14486 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
14490 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
14491 assert!(!has_unconfirmed_command(&[
14492 evidence(Some(0)),
14493 evidence(Some(101))
14494 ]));
14495 }
14496
14497 #[test]
14498 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
14499 assert!(has_unconfirmed_command(&[
14500 evidence(Some(0)),
14501 evidence(None)
14502 ]));
14503 }
14504
14505 #[test]
14506 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
14507 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14508 assert_eq!(
14509 verified_noop_claim(true, &[], text).as_deref(),
14510 Some("already fixed by b32cfc4, on main.")
14511 );
14512 }
14513
14514 #[test]
14515 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
14516 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14519 assert!(verified_noop_claim(false, &[], text).is_none());
14520 }
14521
14522 #[test]
14523 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
14524 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14525 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
14526 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
14528 }
14529
14530 #[test]
14531 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
14532 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
14533 }
14534
14535 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
14538 runner.state.candidates = shape
14539 .iter()
14540 .enumerate()
14541 .map(|(i, &(empty, verified))| Candidate {
14542 index: i,
14543 label: (b'A' + i as u8) as char,
14544 agent: "sonnet".to_owned(),
14545 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
14546 worktree: PathBuf::from(format!("/wt/{i}")),
14547 summary: String::new(),
14548 stat: String::new(),
14549 files: 0,
14550 commits: 0,
14551 empty,
14552 failed: None,
14553 verified_noop: verified.map(str::to_owned),
14554 duration_ms: 0,
14555 folded: false,
14556 })
14557 .collect();
14558 }
14559
14560 #[test]
14561 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
14562 ask_test_home();
14563 let mut runner = runner_at(RunStatus::Implementing);
14564 set_candidates(
14565 &mut runner,
14566 &[
14567 (true, Some("already on main at b32cfc4")),
14568 (true, Some("same fix, see the existing test")),
14569 ],
14570 );
14571
14572 runner
14573 .after_implement()
14574 .expect("a verified no-op is not an error");
14575
14576 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
14577 }
14578
14579 #[test]
14580 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
14581 ask_test_home();
14582 let mut runner = runner_at(RunStatus::Implementing);
14583 set_candidates(
14587 &mut runner,
14588 &[(true, Some("already on main at b32cfc4")), (true, None)],
14589 );
14590
14591 let err = runner
14592 .after_implement()
14593 .expect_err("an unverified empty candidate must still fail the run");
14594
14595 assert!(
14596 err.to_string().contains("no candidate produced a change"),
14597 "{err}"
14598 );
14599 assert_eq!(runner.state.status, RunStatus::Failed);
14600 }
14601
14602 #[test]
14603 fn after_implement_still_fails_an_ordinary_all_empty_run() {
14604 ask_test_home();
14605 let mut runner = runner_at(RunStatus::Implementing);
14606 set_candidates(&mut runner, &[(true, None), (true, None)]);
14607
14608 let err = runner
14609 .after_implement()
14610 .expect_err("no candidate declared anything; this is an ordinary failure");
14611
14612 assert!(
14613 err.to_string().contains("no candidate produced a change"),
14614 "{err}"
14615 );
14616 assert_eq!(runner.state.status, RunStatus::Failed);
14617 }
14618}
14619
14620#[derive(Debug)]
14623struct GraftRestoreFailed(String);
14624
14625impl std::fmt::Display for GraftRestoreFailed {
14626 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14627 write!(
14628 f,
14629 "could not restore the winner to its pre-graft commit: {}",
14630 self.0
14631 )
14632 }
14633}
14634
14635impl std::error::Error for GraftRestoreFailed {}
14636
14637#[cfg(test)]
14641mod resource_wait_tests {
14642 use super::*;
14643 use std::collections::BTreeMap;
14644
14645 fn sh(id: &str, script: &str) -> AgentSpec {
14646 AgentSpec {
14647 id: id.to_owned(),
14648 kind: crate::config::AgentKind::Command,
14649 model: None,
14650 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
14651 extra_args: Vec::new(),
14652 env: BTreeMap::new(),
14653 prompt_delivery: None,
14654 }
14655 }
14656
14657 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
14658 SeatJob {
14659 seat: SeatState::new("impl-A", &spec.id, 1),
14660 spec,
14661 cwd: dir.to_path_buf(),
14662 prompt: "do it".to_owned(),
14663 timeout,
14664 allow_write: true,
14665 sessions: false,
14666 artifacts: dir.join("art"),
14667 stem: "impl-A".to_owned(),
14668 handover: None,
14669 }
14670 }
14671
14672 fn state(resource_wait_secs: u64) -> RunState {
14673 let mut config = Config::default();
14674 config.graph.timeout_resource_wait = resource_wait_secs;
14675 RunState::new(
14676 PathBuf::from("/repo"),
14677 "main".to_owned(),
14678 "abc1234".to_owned(),
14679 "task".to_owned(),
14680 config,
14681 )
14682 }
14683
14684 fn hold(cache: &Path) -> crate::cache::Guard {
14685 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
14686 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
14687 crate::cache::AcquireOutcome::Acquired(g) => g,
14688 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
14689 }
14690 }
14691
14692 async fn run(
14693 jobs: Vec<SeatJob>,
14694 sem: Arc<Semaphore>,
14695 cache: Option<&Path>,
14696 st: &mut RunState,
14697 ) -> Vec<(usize, SeatState, AgentOutcome)> {
14698 let prompts = Prompts::default();
14699 let ctx = WaveCtx {
14700 run: "r",
14701 node: "implement",
14702 prompts: &prompts,
14703 cache,
14704 round: None,
14705 carry_seats: false,
14706 pauses: None,
14707 };
14708 wave(jobs, sem, &ctx, st, 0).await
14709 }
14710
14711 #[tokio::test]
14712 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
14713 crate::run::pin_test_home();
14714 let tmp = tempfile::tempdir().unwrap();
14715 let cache = tmp.path().join("target-a");
14716 let guard = hold(&cache);
14717 tokio::spawn(async move {
14719 tokio::time::sleep(Duration::from_millis(1500)).await;
14720 guard.release();
14721 });
14722 let mut st = state(60);
14723 let results = run(
14724 vec![job(
14725 sh("alpha", "echo ok"),
14726 tmp.path(),
14727 Duration::from_secs(1),
14728 )],
14729 Arc::new(Semaphore::new(1)),
14730 Some(&cache),
14731 &mut st,
14732 )
14733 .await;
14734 assert!(
14735 matches!(results[0].2, AgentOutcome::Ok(_)),
14736 "the wait must not be charged to the agent"
14737 );
14738 assert!(st.resource_block.is_none());
14739 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
14740 assert!(st.resource_wait.is_none() && st.active.is_empty());
14741 }
14742
14743 #[tokio::test]
14744 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
14745 crate::run::pin_test_home();
14746 let tmp = tempfile::tempdir().unwrap();
14747 let cache = tmp.path().join("target-b");
14748 let _held = hold(&cache);
14749 let mut st = state(1);
14750 let results = run(
14751 vec![job(
14752 sh("alpha", "echo ok"),
14753 tmp.path(),
14754 Duration::from_secs(1),
14755 )],
14756 Arc::new(Semaphore::new(1)),
14757 Some(&cache),
14758 &mut st,
14759 )
14760 .await;
14761 let (_, seat, out) = &results[0];
14762 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
14763 assert!(wave_blocked(&results));
14764 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
14765 assert_eq!(seat.turns, 0, "nothing was asked");
14766 assert!(
14767 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
14768 "no agent was spawned"
14769 );
14770 assert!(FailClass::of(out).is_none(), "not a failure class");
14771 let block = st.resource_block.as_ref().expect("recorded for the resume");
14772 assert_eq!(block.kind, "shared build cache");
14773 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
14774 assert!(st.active.is_empty());
14775 }
14776
14777 #[tokio::test]
14778 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
14779 crate::run::pin_test_home();
14780 let tmp = tempfile::tempdir().unwrap();
14781 let mut st = state(1);
14782 let results = run(
14783 vec![job(
14784 sh("alpha", "echo ok"),
14785 tmp.path(),
14786 Duration::from_secs(1),
14787 )],
14788 Arc::new(Semaphore::new(0)),
14789 None,
14790 &mut st,
14791 )
14792 .await;
14793 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
14794 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
14795 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
14796 }
14797
14798 #[tokio::test]
14799 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
14800 crate::run::pin_test_home();
14801 let tmp = tempfile::tempdir().unwrap();
14802 let mut st = state(60);
14803 let results = run(
14804 vec![job(
14805 sh("alpha", "sleep 30"),
14806 tmp.path(),
14807 Duration::from_secs(1),
14808 )],
14809 Arc::new(Semaphore::new(1)),
14810 None,
14811 &mut st,
14812 )
14813 .await;
14814 let out = &results[0].2;
14815 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
14816 let class = FailClass::of(out).expect("a failure");
14817 assert_eq!(class, FailClass::Timeout);
14818 assert!(should_hand_over(None, &class), "existing handover policy");
14819 assert!(st.resource_block.is_none());
14820 }
14821}