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 repair or `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 pub async fn recover_pr(
778 repo: &Path,
779 task: &str,
780 selection: crate::pr_recovery::Selection,
781 config: Config,
782 ) -> Result<Self> {
783 selection.check_task(task, &selection.instruction)?;
784 let base_branch =
785 git::merge_base_branch(repo, &config.merge.remote, config.merge.base.as_deref())
786 .await?;
787 let base_commit = resolve_base(repo, &base_branch, &config.merge.remote).await?;
788 selection
789 .verify(repo, &config.merge.remote, &base_branch, &selection.head)
790 .await?;
791 let roles = config.resolve_roles()?;
792 let max_parallel = config.graph.max_parallel.max(1);
793 let mut state = RunState::new(
794 repo.to_path_buf(),
795 base_branch,
796 base_commit.clone(),
797 selection.instruction.clone(),
798 config,
799 );
800 state.origin = Some(crate::run::Origin::queue(task));
801 state.pr_head = Some(selection.branch.clone());
802 state.pr_recovery = Some(selection.clone());
803 crate::pr_head::check_predecessors(&state, &selection.branch).await?;
804 Self::open_selected(repo, state, roles, max_parallel, base_commit, selection).await
805 }
806
807 async fn open_selected(
808 repo: &Path,
809 state: RunState,
810 roles: ResolvedRoles,
811 max_parallel: usize,
812 base_commit: String,
813 selection: crate::pr_recovery::Selection,
814 ) -> Result<Self> {
815 let mut state = state;
816 let branch = state.branch_for('A');
817 git::git(repo, &["branch", &branch, &selection.head]).await?;
818 state.event(
819 "recovery",
820 format!(
821 "selected public implementation {} from {}",
822 selection.head, selection.url
823 ),
824 );
825 let takeover = crate::handover::Takeover {
826 earlier: Vec::new(),
827 home: crate::run::home(),
828 choice: None,
829 task_instruction: Some(selection.instruction.clone()),
830 };
831 state.save()?;
832 let id = state.id.clone();
833 let opened = Self::open_review(
834 repo,
835 &branch,
836 state,
837 roles,
838 max_parallel,
839 base_commit,
840 &takeover,
841 )
842 .await;
843 match opened {
844 Ok(runner) => Ok(runner),
845 Err(e) => {
846 if let Ok(mut partial) = RunState::load(&id) {
847 partial.status = RunStatus::Blocked;
848 partial.driver_exited = true;
849 partial.event(
850 "recovery",
851 format!("startup failed; retained source and candidate {branch}: {e:#}"),
852 );
853 partial.save()?;
854 }
855 Err(crate::pr_recovery::StartupFailed {
856 run: id,
857 cause: format!("{e:#}"),
858 }
859 .into())
860 }
861 }
862 }
863
864 async fn inherit(
869 repo: &Path,
870 branch: &str,
871 base_commit: &str,
872 takeover: &crate::handover::Takeover,
873 ) -> Option<crate::recovery::RecoveryRecord> {
874 use crate::recovery::{Relation, assess};
875 let newest = takeover.earlier.last()?;
876 let mut chosen = None;
877 for id in takeover.earlier.iter().rev() {
878 if let Ok(old) = RunState::load_under(id, &takeover.home)
879 && !old.reviews.is_empty()
880 && (old.winner().is_some_and(|w| w.branch == branch)
881 || old.pr_head.as_deref() == Some(branch))
882 {
883 chosen = Some(old);
884 break;
885 }
886 }
887 let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
888 .await
889 .unwrap_or_default();
890 let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
891 Some(last) if last.head == tip => Relation::Same,
892 Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
893 _ => Relation::Diverged,
894 };
895 let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
896 Some(assess(from, chosen.as_ref(), base_commit, relation))
897 }
898
899 async fn open_review(
902 repo: &Path,
903 branch: &str,
904 mut state: RunState,
905 roles: ResolvedRoles,
906 max_parallel: usize,
907 base_commit: String,
908 takeover: &crate::handover::Takeover,
909 ) -> Result<Self> {
910 if state.pr_recovery.is_none() {
911 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
912 }
913 let start = review_base(
916 repo,
917 &state.config.merge.remote,
918 &state.base_branch,
919 &base_commit,
920 branch,
921 )
922 .await;
923 let log = git::log_oneline(repo, &start, branch)
924 .await
925 .unwrap_or_default();
926 let log_text = if log.trim().is_empty() {
927 "(no commit messages)"
928 } else {
929 log.trim()
930 };
931 let mut instruction = format!(
932 "Review the work already on branch `{branch}`. There is no task \
933 statement: what the change claims to do is whatever its commits \
934 say.\n\n{log_text}"
935 );
936 if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
940 if let Some(task) = takeover
941 .task_instruction
942 .as_deref()
943 .filter(|t| !t.trim().is_empty())
944 && rec.reviewed_head.is_some()
945 {
946 instruction = format!(
947 "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
948 task.trim()
949 );
950 rec.criteria_inherited = true;
951 }
952 state.event("recovery", rec.summary());
953 state.recovery = Some(rec);
954 }
955 if let Some(selected) = &state.pr_recovery {
956 instruction = selected.instruction.clone();
957 }
958 state.task_instruction = takeover.task_instruction.clone();
959 state.instruction = instruction;
960 state.reviewed_commits = Some(
961 git::subjects(repo, &start, branch)
962 .await
963 .unwrap_or_default(),
964 );
965
966 let worktree = state.worktree_root().join("under-review");
969 if let Some(parent) = worktree.parent() {
970 tokio::fs::create_dir_all(parent).await.ok();
971 }
972 let path = worktree.to_string_lossy().to_string();
973 git::git(repo, &["worktree", "add", &path, branch])
974 .await
975 .with_context(|| {
976 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
977 })?;
978
979 let setup = match ensure_setup_config(&mut state, repo).await {
980 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
981 Err(e) => Err(e),
982 };
983 if let Err(e) = setup {
984 git::worktree_remove(repo, &worktree).await.ok();
985 return Err(e);
986 }
987 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
988 .await
989 .unwrap_or(0);
990 if commits == 0 {
991 git::worktree_remove(repo, &worktree).await.ok();
992 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
993 }
994 let files = git::changed_files(&worktree, &base_commit, "HEAD")
995 .await
996 .map(|f| f.len())
997 .unwrap_or(0);
998 if files == 0
999 && let (Ok(head_tree), Ok(base_tree)) = (
1000 git::tree_of(&worktree, "HEAD").await,
1001 git::tree_of(&worktree, &base_commit).await,
1002 )
1003 && head_tree == base_tree
1004 {
1005 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
1006 git::worktree_remove(repo, &worktree).await.ok();
1007 bail!(
1008 "`{branch}` at {} has a tree identical to base {}; this usually means \
1009 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
1010 against `{}/{branch}`) rather than an empty change",
1011 short(&head),
1012 short(&base_commit),
1013 state.config.merge.remote
1014 );
1015 }
1016 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
1017 .await
1018 .unwrap_or_default();
1019
1020 state.candidates.push(Candidate {
1021 index: 0,
1022 label: 'A',
1023 agent: EXISTING_BRANCH.to_owned(),
1026 branch: branch.to_owned(),
1027 worktree,
1028 summary: String::new(),
1029 stat,
1030 files,
1031 commits,
1032 empty: false,
1033 failed: None,
1034 verified_noop: None,
1035 duration_ms: 0,
1036 folded: false,
1037 });
1038 state.tally = Some(Tally {
1039 first_choice: BTreeMap::from([('A', 0)]),
1040 borda: BTreeMap::new(),
1041 winner: 'A',
1042 rankings: 0,
1043 unanimous_initial: false,
1044 deliberated: false,
1045 changed_votes: 0,
1046 unanimous_final: false,
1047 tie_break: None,
1048 judges: 0,
1052 present: 0,
1053 quorum: 0,
1054 met_quorum: true,
1055 uncontested: Some("review-only run: nothing competed".to_owned()),
1056 });
1057 state.status = RunStatus::Reviewing;
1058 state.prep_complete = Some(true);
1059 state.event(
1060 "start",
1061 format!(
1062 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
1063 state.id
1064 ),
1065 );
1066 state.save()?;
1067 Ok(Self {
1068 state,
1069 roles,
1070 sem: Arc::new(Semaphore::new(max_parallel)),
1071 pause: Pause::new(),
1072 interrupt: Pause::new(),
1073 })
1074 }
1075
1076 pub fn resume(id: &str) -> Result<Self> {
1078 let state = RunState::load(id)?;
1079 if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1080 bail!(
1081 "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1082 );
1083 }
1084 if let Some(to) = &state.released_to {
1085 bail!(
1086 "run {} cannot be resumed: its worktree was released to run {}",
1087 state.short(),
1088 crate::run::short_of(to)
1089 );
1090 }
1091 let roles = state.config.resolve_roles()?;
1092 let max_parallel = state.config.graph.max_parallel.max(1);
1093 Ok(Self {
1094 state,
1095 roles,
1096 sem: Arc::new(Semaphore::new(max_parallel)),
1097 pause: Pause::new(),
1098 interrupt: Pause::new(),
1099 })
1100 }
1101
1102 pub async fn execute(&mut self) -> Result<()> {
1109 let _repair_claim = if !self.state.merge_repairs.is_empty()
1112 || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1113 {
1114 let claim = FixClaim::acquire(&self.state.dir())?;
1115 let disk = RunState::load(&self.state.id)?;
1116 let checkpoint = |s: &RunState| {
1117 (
1118 s.merge_repairs.len(),
1119 s.merge_repairs
1120 .last()
1121 .map(|r| (r.question.clone(), r.phase)),
1122 s.land_approval
1123 .as_ref()
1124 .map(|a| (a.question.clone(), a.head.clone())),
1125 )
1126 };
1127 if checkpoint(&disk) != checkpoint(&self.state) {
1128 bail!(
1129 "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1130 );
1131 }
1132 Some(claim)
1133 } else {
1134 None
1135 };
1136 let result = self.execute_graph().await;
1137 self.mark_driver_exited();
1138 let ended = if result.is_err() {
1139 Some(crate::notices::run_stopped(&self.state.id, &self.state))
1140 } else {
1141 crate::notices::run_ended(&self.state)
1142 };
1143 if let Some(notice) = ended {
1144 crate::notices::raise(notice);
1145 }
1146 result
1147 }
1148
1149 fn mark_driver_exited(&mut self) {
1158 self.state.driver_exited = true;
1159 let pid = std::process::id();
1160 let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1161 return;
1162 };
1163 let Ok(mut disk) = RunState::load(&self.state.id) else {
1164 return;
1165 };
1166 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1167 return;
1168 }
1169 disk.driver_exited = true;
1170 if let Err(e) = disk.save() {
1171 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1172 }
1173 }
1174
1175 fn claim_driver(&mut self) -> Result<()> {
1177 let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1178 let disk = RunState::load(&self.state.id)?;
1179 crate::pr_head::ensure_owner(&disk)?;
1180 self.state.parked = false;
1185 self.state.resource_wait = None;
1186 self.state.resource_block = None;
1187 self.state.clear_active();
1194 if let Some(to) = &disk.released_to {
1212 bail!(
1213 "run {} cannot continue: its worktree was released to run {}",
1214 self.state.short(),
1215 crate::run::short_of(to)
1216 );
1217 }
1218 let pid = std::process::id();
1219 self.state.driver_pid = Some(pid);
1220 self.state.driver_started_at = crate::proc::process_started_at(pid);
1221 self.state.driver_exited = false;
1222 self.state.begin_pass();
1223 self.state.save()?;
1224 Ok(())
1225 }
1226
1227 async fn execute_graph(&mut self) -> Result<()> {
1228 self.claim_driver()?;
1229 if self.state.merge_repairs.last().is_some_and(|r| {
1230 matches!(
1231 r.phase,
1232 crate::run::MergeRepairPhase::Requested
1233 | crate::run::MergeRepairPhase::Fixing
1234 | crate::run::MergeRepairPhase::Reviewing
1235 )
1236 }) {
1237 if self.repair_merge_review().await? {
1238 self.run_land().await?;
1239 }
1240 self.settle_questions();
1241 return Ok(());
1242 }
1243 if self.state.pr_publication_pending {
1246 self.merge().await?;
1247 if self.resource_halt()? {
1248 return Ok(());
1249 }
1250 self.state.save()?;
1251 self.settle_questions();
1252 return Ok(());
1253 }
1254 if self.state.status == RunStatus::Stalled {
1267 if self.recover_stall().await? {
1268 self.finish_after_tally().await?;
1269 } else {
1270 self.state.save()?;
1272 }
1273 return Ok(());
1274 }
1275 if self.state.status == RunStatus::Landing {
1285 self.run_land().await?;
1286 self.settle_questions();
1291 return Ok(());
1292 }
1293 if self.state.github_text.is_some()
1298 && self.state.merge.is_none()
1299 && self.state.status == RunStatus::Gating
1300 {
1301 self.merge().await?;
1302 if self.resource_halt()? {
1303 return Ok(());
1304 }
1305 self.state.save()?;
1306 return Ok(());
1307 }
1308 if let Err(e) = self.prep().await {
1309 self.state.status = RunStatus::Blocked;
1310 self.state
1311 .prep_failure
1312 .get_or_insert_with(|| format!("{e:#}"));
1313 self.state
1314 .event("prep", format!("preparation stopped: {e:#}"));
1315 self.state.save()?;
1316 return Err(e);
1317 }
1318 if self.park_here()? {
1319 return Ok(());
1320 }
1321 self.advise().await?;
1322 if self.park_here()? {
1323 return Ok(());
1324 }
1325 self.implement().await?;
1326 if self.park_here()? {
1327 return Ok(());
1328 }
1329 if self.state.status == RunStatus::VerifiedNoop {
1333 return Ok(());
1334 }
1335 self.judge().await?;
1336 if self.park_here()? {
1337 return Ok(());
1338 }
1339 self.deliberate().await?;
1340 if self.park_here()? {
1341 return Ok(());
1342 }
1343 self.vote().await?;
1344 if self.park_here()? {
1345 return Ok(());
1346 }
1347 self.tally()?;
1348 if self.state.status == RunStatus::Stalled {
1353 self.state.save()?;
1357 return Ok(());
1358 }
1359 self.finish_after_tally().await?;
1360 Ok(())
1361 }
1362
1363 fn stop_for_resource(&mut self) -> Result<()> {
1368 if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1370 {
1371 self.state.status = RunStatus::Blocked;
1372 }
1373 self.state.save()
1374 }
1375
1376 fn resource_halt(&mut self) -> Result<bool> {
1378 if self.state.resource_block.is_none() {
1379 return Ok(false);
1380 }
1381 self.stop_for_resource()?;
1382 Ok(true)
1383 }
1384
1385 fn park_here(&mut self) -> Result<bool> {
1392 let parking = self.pause.parked() || self.interrupt.parked();
1393 if !parking && self.state.resource_block.is_some() {
1394 self.stop_for_resource()?;
1395 return Ok(true);
1396 }
1397 if !self.pause.parked() && !self.interrupt.parked() {
1402 return Ok(false);
1403 }
1404 let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1405 let why = match cause.as_ref().map(ParkCause::label) {
1406 Some(reason) => format!(
1407 "parked after `{}` ({reason}) — resume to carry on from here",
1408 self.state.status.as_str()
1409 ),
1410 None => format!(
1411 "parked after `{}` — resume to carry on from here",
1412 self.state.status.as_str()
1413 ),
1414 };
1415 self.state.event("park", why);
1416 self.state
1417 .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1418 self.state.parked = true;
1419 self.state.save()?;
1420 Ok(true)
1421 }
1422
1423 pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1425 if self.state.prep_failure.is_none() {
1426 return Ok(());
1427 }
1428 let failed_at = self
1429 .state
1430 .events
1431 .iter()
1432 .rev()
1433 .find(|event| event.node == "prep")
1434 .map_or(self.state.updated_at, |event| event.at);
1435 anyhow::ensure!(
1436 approval_at > failed_at,
1437 "preparation retry requires a new explicit release after the failure"
1438 );
1439 self.state.prep_failure = None;
1440 self.state.prep_complete = Some(false);
1441 self.state
1442 .event("prep-retry", "operator approved retry of preparation");
1443 Ok(())
1444 }
1445
1446 pub fn on_pause(&mut self, pause: Pause) {
1448 self.pause = pause;
1449 }
1450
1451 pub fn watch_interrupt(&mut self, pause: Pause) {
1457 self.interrupt = pause;
1458 }
1459
1460 fn settle_questions(&mut self) {
1480 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1481 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1482 }
1483 }
1484
1485 async fn finish_after_tally(&mut self) -> Result<()> {
1488 self.graft().await?;
1491 if self.resource_halt()? {
1492 return Ok(());
1493 }
1494 self.fold_losers().await?;
1495 self.sync_to_base().await?;
1500 if self.state.status == RunStatus::AlreadyInBase {
1501 return Ok(());
1502 }
1503 self.review_loop().await?;
1504 if self.resource_halt()? {
1505 return Ok(());
1506 }
1507 self.sync_to_base().await?;
1508 if self.state.status == RunStatus::AlreadyInBase {
1509 return Ok(());
1510 }
1511 self.gate().await?;
1512 self.merge().await?;
1513 if self.resource_halt()? {
1514 return Ok(());
1515 }
1516 self.state.save()?;
1517 Ok(())
1518 }
1519
1520 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1527 let result = match ensure_setup_config(&mut self.state, repo).await {
1528 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1529 Err(e) => Err(e),
1530 };
1531 if let Err(e) = result {
1532 git::worktree_remove(repo, wt).await.ok();
1533 if in_prep {
1534 self.rollback_prep(repo).await;
1535 }
1536 self.state.status = RunStatus::Blocked;
1537 self.state.event(
1538 "setup",
1539 format!("worktree setup failed in {}: {e:#}", wt.display()),
1540 );
1541 self.state.save()?;
1542 return Err(e);
1543 }
1544 Ok(())
1545 }
1546
1547 async fn rollback_prep(&mut self, repo: &Path) {
1549 let root = self.state.worktree_root();
1550 for c in std::mem::take(&mut self.state.candidates) {
1551 git::worktree_remove(repo, &c.worktree).await.ok();
1552 git::branch_delete(repo, &c.branch).await.ok();
1553 }
1554 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1555 for j in 1..=seats {
1556 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1557 .await
1558 .ok();
1559 }
1560 for k in 1..=self.state.config.graph.advisors {
1561 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1562 .await
1563 .ok();
1564 }
1565 }
1566
1567 async fn prep(&mut self) -> Result<()> {
1568 if let Some(reason) = &self.state.prep_failure {
1569 bail!(
1570 "candidate preparation previously failed: {reason}; correct the source before retrying"
1571 );
1572 }
1573 if self
1574 .state
1575 .prep_complete
1576 .unwrap_or(!self.state.candidates.is_empty())
1577 {
1578 return Ok(());
1579 }
1580 let repo = self.state.repo.clone();
1581 self.rollback_prep(&repo).await;
1582 self.state.prep_complete = Some(false);
1583 self.state.status = RunStatus::Prep;
1584 self.state.save()?;
1585 let repo = self.state.repo.clone();
1586 let base = self.state.base_commit.clone();
1587 let plan = refs::plan(&repo, &self.state.seeds).await?;
1588 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1589 let root = self.state.worktree_root();
1590 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1591
1592 let hooks_dir = self.state.dir().join("hooks");
1595 if self.state.config.blind.commit_msg_hook {
1596 std::fs::create_dir_all(&hooks_dir)
1597 .with_context(|| format!("create {}", hooks_dir.display()))?;
1598 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1599 let path = hooks_dir.join("commit-msg");
1600 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1601 make_executable(&path)?;
1602 git::acquire_worktree_config(&repo).await?;
1610 self.state.enabled_worktree_config = true;
1611 }
1612
1613 ensure_setup_config(&mut self.state, &repo).await?;
1616
1617 for (index, (spec, label)) in self
1618 .roles
1619 .implementers
1620 .clone()
1621 .into_iter()
1622 .zip(labels)
1623 .enumerate()
1624 {
1625 let branch = self.state.branch_for(label);
1626 let worktree = root.join(format!("cand-{label}"));
1627 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1628 if self.state.config.blind.commit_msg_hook {
1629 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1630 }
1631 git::local_exclude(&worktree, "/.magi/").await?;
1632 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1633 git::worktree_remove(&repo, &worktree).await.ok();
1636 git::branch_delete(&repo, &branch).await.ok();
1637 self.rollback_prep(&repo).await;
1638 self.state.status = RunStatus::Blocked;
1639 self.state
1640 .event("prep", format!("worktree setup failed: {e:#}"));
1641 self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1642 self.state.save()?;
1643 return Err(e);
1644 }
1645 self.state.candidates.push(Candidate {
1646 index,
1647 label,
1648 agent: spec.id.clone(),
1649 branch,
1650 worktree,
1651 summary: String::new(),
1652 stat: String::new(),
1653 files: 0,
1654 commits: 0,
1655 empty: false,
1656 failed: None,
1657 verified_noop: None,
1658 duration_ms: 0,
1659 folded: false,
1660 });
1661 self.state.save()?;
1662 for pick in &plan.picks {
1663 if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1664 {
1665 self.state.status = RunStatus::Blocked;
1666 self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1667 self.state
1668 .event("prep", format!("cannot apply referenced commit: {e}"));
1669 self.state.save()?;
1670 return Err(e);
1671 }
1672 }
1673 }
1674
1675 for j in 1..=self.roles.judges.len() {
1676 let wt = root.join(format!("judge-{j}"));
1677 if !wt.exists() {
1678 git::worktree_add_detached(&repo, &wt, &base).await?;
1679 self.setup_seat_worktree(&repo, &wt, true).await?;
1680 }
1681 }
1682
1683 if self.state.config.graph.advise {
1691 for k in 1..=self.state.config.graph.advisors {
1692 let wt = root.join(format!("advisor-{k}"));
1693 if !wt.exists() {
1694 git::worktree_add_detached(&repo, &wt, &base).await?;
1695 self.setup_seat_worktree(&repo, &wt, true).await?;
1696 }
1697 }
1698 }
1699
1700 let authors: Vec<&str> = self
1705 .roles
1706 .implementers
1707 .iter()
1708 .map(|a| a.id.as_str())
1709 .collect();
1710 let overlap: Vec<String> = self
1711 .roles
1712 .judges
1713 .iter()
1714 .enumerate()
1715 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1716 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1717 .collect();
1718 if !overlap.is_empty() {
1719 let note = format!(
1720 "{} also authored a candidate; blind, but the panel is less \
1721 independent than {} distinct agents would be",
1722 overlap.join(", "),
1723 self.roles.judges.len()
1724 );
1725 self.state.event("prep", note);
1726 }
1727
1728 self.state.event(
1729 "prep",
1730 format!(
1731 "{} candidates, {} judges, base {} ({})",
1732 self.state.candidates.len(),
1733 self.roles.judges.len(),
1734 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1735 self.state.base_branch
1736 ),
1737 );
1738 self.state.prep_complete = Some(true);
1739 self.state.status = RunStatus::Implementing;
1740 self.state.save()?;
1741 Ok(())
1742 }
1743
1744 async fn advise(&mut self) -> Result<()> {
1777 let implement_untouched = self
1778 .state
1779 .candidates
1780 .iter()
1781 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1782 if !self.state.config.graph.advise || self.state.advise_attempted {
1783 return Ok(());
1784 }
1785 if !implement_untouched {
1786 self.state.event(
1787 "advise",
1788 "skipping the design-deliberation stage: at least one \
1789 candidate already shows implementation progress, so this \
1790 run is past the point the stage exists to run before"
1791 .to_owned(),
1792 );
1793 self.state.advise_attempted = true;
1794 self.state.save()?;
1795 return Ok(());
1796 }
1797 let run_id = self.state.id.clone();
1798 let prompts = self.state.config.prompts.clone();
1799 let instruction = self.state.instruction.clone();
1800 let language = self.state.config.graph.language.clone();
1801 let root = self.state.worktree_root();
1802 let n = self.state.config.graph.advisors;
1803 let where_recorded = self.state.dir().join("run.json");
1804
1805 let seats = match self.state.config.advisors() {
1806 Ok(seats) if !seats.is_empty() => seats,
1807 Ok(_) => {
1808 self.state.event(
1809 "advise",
1810 format!(
1811 "[graph] advisors is 0; skipping the design-deliberation \
1812 stage and continuing without a synthesis brief (see {})",
1813 where_recorded.display()
1814 ),
1815 );
1816 self.state.advise_attempted = true;
1817 self.state.save()?;
1818 return Ok(());
1819 }
1820 Err(e) => {
1821 self.state.event(
1822 "advise",
1823 format!(
1824 "could not resolve advisor seats ({e:#}); continuing \
1825 without a design-deliberation brief (see {})",
1826 where_recorded.display()
1827 ),
1828 );
1829 self.state.advise_attempted = true;
1830 self.state.save()?;
1831 return Ok(());
1832 }
1833 };
1834
1835 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1836 let artifacts = agent::artifacts_dir(&self.state.dir());
1837 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1838
1839 let mut jobs = Vec::new();
1840 for (i, spec) in seats.iter().cloned().enumerate() {
1841 let seat_key = format!("advisor-{}", i + 1);
1842 let seat = self.seat(&seat_key, &spec.id);
1843 jobs.push(SeatJob {
1844 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1845 spec,
1846 seat,
1847 cwd: worktrees[i % worktrees.len()].clone(),
1848 timeout,
1849 allow_write: false,
1850 sessions: false,
1851 artifacts: artifacts.clone(),
1852 stem: seat_key,
1853 handover: None,
1854 });
1855 }
1856
1857 self.state.event(
1858 "advise",
1859 format!(
1860 "{} advisor seat(s) sketching a design in parallel",
1861 jobs.len()
1862 ),
1863 );
1864 let mut quota_losses = Vec::new();
1865 let cache = self.state.config.cache_dir();
1866 let ctx = WaveCtx {
1867 carry_seats: false,
1868 pauses: Some((&self.pause, &self.interrupt)),
1869 run: &run_id,
1870 node: "advise",
1871 prompts: &prompts,
1872 cache: cache.as_deref(),
1873 round: None,
1874 };
1875 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1876 let results = ask_json_wave::<Proposal>(
1877 jobs,
1878 Arc::clone(&self.sem),
1879 self.state.config.graph.retries,
1880 &advisor_roster,
1881 &ctx,
1882 &mut quota_losses,
1883 &mut self.state,
1884 &|p: &Proposal| p.validate(),
1885 )
1886 .await;
1887 if self.state.resource_block.is_some() {
1888 return self.stop_for_resource();
1889 }
1890 self.state.quota.extend(quota_losses);
1891
1892 let mut records = Vec::with_capacity(results.len());
1893 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1894 let agent_id = seat.agent.clone();
1895 self.state.seats.insert(seat.key.clone(), seat);
1896 match res {
1897 Ok((proposal, out)) => {
1898 self.state
1899 .event("advise", format!("advisor-{} proposed a design", i + 1));
1900 records.push(advise::AdvisorRecord::proposed(
1901 i + 1,
1902 agent_id,
1903 proposal,
1904 out.duration_ms,
1905 ));
1906 }
1907 Err(e) => {
1908 self.state.event(
1909 "advise",
1910 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1911 );
1912 records.push(advise::AdvisorRecord::failed(
1913 i + 1,
1914 agent_id,
1915 e.to_string(),
1916 ));
1917 }
1918 }
1919 }
1920
1921 let mut advice = advise::Advice {
1922 records,
1923 synthesis: None,
1924 };
1925 if advice.proposals().is_empty() {
1926 self.state.event(
1927 "advise",
1928 "no advisor produced a usable proposal; continuing without a \
1929 synthesis brief"
1930 .to_owned(),
1931 );
1932 } else {
1933 match self
1934 .synthesize_brief(
1935 &advice,
1936 &instruction,
1937 &language,
1938 &worktrees[0],
1939 &artifacts,
1940 &run_id,
1941 &prompts,
1942 cache.as_deref(),
1943 )
1944 .await
1945 {
1946 Ok(Some(text)) => {
1947 self.state.event(
1948 "advise",
1949 "synthesized a design brief for the implementer".to_owned(),
1950 );
1951 advice.synthesis = Some(text);
1952 }
1953 Ok(None) => {
1954 self.state.event(
1955 "advise",
1956 "the synthesis seat produced nothing usable; continuing \
1957 without a design brief"
1958 .to_owned(),
1959 );
1960 }
1961 Err(e) => {
1962 self.state.event(
1963 "advise",
1964 format!("could not synthesize a design brief: {e:#}"),
1965 );
1966 }
1967 }
1968 }
1969 advise::apply_reflection(&mut advice);
1970
1971 self.state.advice = Some(advice);
1972 self.state.advise_attempted = true;
1973 self.state.save()?;
1974 Ok(())
1975 }
1976
1977 #[allow(clippy::too_many_arguments)]
1989 async fn synthesize_brief(
1990 &mut self,
1991 advice: &advise::Advice,
1992 instruction: &str,
1993 language: &str,
1994 cwd: &Path,
1995 artifacts: &Path,
1996 run_id: &str,
1997 prompts: &Prompts,
1998 cache: Option<&Path>,
1999 ) -> Result<Option<String>> {
2000 let chain = agent::pick_chain(
2001 &self.state.config.agents,
2002 self.state.config.roles.synthesizer.as_ref(),
2003 &agent::installed,
2004 "synthesizer",
2005 )?;
2006 let proposals = advice.proposals();
2007 let mut prompt = prompt::with_overlay(
2008 prompt::synthesize_brief(instruction, &proposals, language),
2009 prompts.overlay("advise"),
2010 );
2011 if cache.is_some() {
2012 prompt.push('\n');
2017 prompt.push_str(&prompt::build_cache_note("advise", false));
2018 }
2019 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2020 let mut last = None;
2025 for (n, spec) in chain.iter().enumerate() {
2026 if n > 0 {
2027 self.state
2028 .event("advise", format!("synthesis falling back to {}", spec.id));
2029 }
2030 let mut seat = self.seat("advise-synthesis", &spec.id);
2031 let outcome = agent::invoke(
2032 spec,
2033 &mut seat,
2034 &Invocation {
2035 cwd,
2036 prompt: &prompt,
2037 timeout,
2038 allow_write: false,
2039 unsandboxed: false,
2040 sessions: false,
2041 artifacts,
2042 stem: &if n == 0 {
2043 "advise-synthesis".to_owned()
2044 } else {
2045 format!("advise-synthesis-{}", spec.id)
2046 },
2047 run: run_id,
2048 node: "advise",
2049 cache_dir: None,
2050 attachments: &[],
2051 writable: &[],
2052 },
2053 )
2054 .await;
2055 if outcome.is_ok() {
2056 self.state.seats.insert(seat.key.clone(), seat);
2057 }
2058 let advance = agent::chain_advances(&outcome);
2059 last = Some(outcome);
2060 if !advance {
2061 break;
2062 }
2063 }
2064 let out = last.expect("a chain holds at least one agent")?;
2067 if !out.usable() {
2068 return Ok(None);
2069 }
2070 let text =
2071 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2072 Ok((!text.trim().is_empty()).then_some(text))
2073 }
2074
2075 async fn implement(&mut self) -> Result<()> {
2078 let run_id = self.state.id.clone();
2083 let prompts = self.state.config.prompts.clone();
2084 let todo: Vec<usize> = self
2085 .state
2086 .candidates
2087 .iter()
2088 .enumerate()
2089 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2090 .map(|(i, _)| i)
2091 .collect();
2092 if todo.is_empty() {
2093 return self.after_implement();
2094 }
2095 self.state.status = RunStatus::Implementing;
2096
2097 let language = self.state.config.graph.language.clone();
2098 let github_language = self.state.config.graph.github_language.clone();
2099 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2100 let sessions = self.state.config.graph.sessions;
2101 let artifacts = agent::artifacts_dir(&self.state.dir());
2102 let brief = self
2106 .state
2107 .advice
2108 .as_ref()
2109 .and_then(|a| a.synthesis.as_deref())
2110 .map(str::to_owned);
2111 let attachments = self.state.attachments.clone();
2112
2113 let mut jobs = Vec::new();
2114 for &i in &todo {
2115 let (index, label, worktree) = {
2116 let c = &self.state.candidates[i];
2117 (c.index, c.label, c.worktree.clone())
2118 };
2119 let spec = self.roles.implementers[index].clone();
2120 let seat_key = format!("impl-{label}");
2121 let seat = self.seat(&seat_key, &spec.id);
2122 let instruction = seeded_instruction(&self.state);
2123 jobs.push(SeatJob {
2124 spec,
2125 seat,
2126 prompt: prompt::implement_in(
2127 &instruction,
2128 &worktree.to_string_lossy(),
2129 &language,
2130 &github_language,
2131 brief.as_deref(),
2132 &attachments,
2133 ),
2134 cwd: worktree,
2135 timeout,
2136 allow_write: true,
2137 sessions,
2138 artifacts: artifacts.clone(),
2139 stem: format!("impl-{label}"),
2140 handover: None,
2141 });
2142 }
2143
2144 self.state.event(
2145 "implement",
2146 format!("{} candidates in parallel", jobs.len()),
2147 );
2148 let mut sent = jobs.clone();
2154 let cache = self.state.config.cache_dir();
2155 let ctx = WaveCtx {
2156 carry_seats: false,
2157 pauses: Some((&self.pause, &self.interrupt)),
2158 run: &run_id,
2159 node: "implement",
2160 prompts: &prompts,
2161 cache: cache.as_deref(),
2162 round: None,
2163 };
2164 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2165 if wave_blocked(&results) {
2166 return self.stop_for_resource();
2167 }
2168 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2169 .await;
2170 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2171 .await;
2172 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2173 .await;
2174 if self.state.resource_block.is_some() {
2175 return self.stop_for_resource();
2176 }
2177
2178 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2179 let seat_key = seat.key.clone();
2180 let agent = seat.agent.clone();
2189 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2190 self.state.seats.insert(seat.key.clone(), seat);
2191 let label = self.state.candidates[i].label;
2192 let worktree = self.state.candidates[i].worktree.clone();
2193 let base = self.state.base_commit.clone();
2194
2195 let (summary, duration, failed, verified_claim) = match out {
2196 AgentOutcome::Ok(o) => {
2197 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2198 let failed = (!o.usable()).then(|| {
2199 if o.timed_out {
2200 "agent timed out".to_owned()
2201 } else {
2202 format!("agent exited with {:?}", o.exit_code)
2203 }
2204 });
2205 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2206 (text, o.duration_ms, failed, verified_claim)
2207 }
2208 AgentOutcome::Dropped(o) => {
2214 let why = o
2215 .dropped
2216 .as_ref()
2217 .map(|d| d.why.as_str())
2218 .unwrap_or("the CLI ended the stream without delivering its answer");
2219 (
2220 String::new(),
2221 o.duration_ms,
2222 Some(format!("the CLI dropped the stream ({why})")),
2223 None,
2224 )
2225 }
2226 AgentOutcome::Quota(o) => {
2227 self.state.quota.push(QuotaLoss {
2228 seat: seat_key,
2229 node: "implement".to_owned(),
2230 at: Timestamp::now(),
2231 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2232 });
2233 (
2234 String::new(),
2235 o.duration_ms,
2236 Some("rate limited (quota); produced no change".to_owned()),
2237 None,
2238 )
2239 }
2240 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2241 (String::new(), 0, Some(e), None)
2242 }
2243 };
2244
2245 let rescued = match git::rescue_commit(
2248 &worktree,
2249 &format!("magi: candidate {label} (uncommitted work)"),
2250 )
2251 .await
2252 {
2253 Ok(r) => {
2254 self.state.note_withheld("implement", &r.withheld);
2255 r.committed
2256 }
2257 Err(_) => false,
2258 };
2259 let commits = git::commits_ahead(&worktree, &base, "HEAD")
2260 .await
2261 .unwrap_or(0);
2262 let patch = git::diff(&worktree, &base, "HEAD")
2263 .await
2264 .unwrap_or_default();
2265 let stat = git::diff_stat(&worktree, &base, "HEAD")
2266 .await
2267 .unwrap_or_default();
2268 let files = git::changed_files(&worktree, &base, "HEAD")
2269 .await
2270 .map(|f| f.len())
2271 .unwrap_or(0);
2272 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2273
2274 let c = &mut self.state.candidates[i];
2275 if !exhausted_the_fallback_chain {
2276 c.agent = agent;
2277 }
2278 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2279 c.stat = stat;
2280 c.files = files;
2281 c.commits = commits;
2282 c.duration_ms = duration;
2283 c.empty = commits == 0 || patch.trim().is_empty();
2284 c.failed = match failed {
2287 Some(_) if c.empty => failed,
2288 _ => None,
2289 };
2290 c.verified_noop = if c.empty { verified_claim } else { None };
2295 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2296 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2297 (None, true, Some(_), _) => {
2298 format!("candidate {label}: no change produced (agent-verified no-op)")
2299 }
2300 (None, true, None, _) => format!("candidate {label}: no change produced"),
2301 (None, false, _, true) => {
2302 format!(
2303 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2304 )
2305 }
2306 (None, false, _, false) => {
2307 format!("candidate {label}: {files} files, {commits} commits")
2308 }
2309 };
2310 self.state.event("implement", note);
2311 self.state.save()?;
2312 }
2313
2314 self.after_implement()
2315 }
2316
2317 async fn resume_undelivered(
2345 &mut self,
2346 results: &mut [(usize, SeatState, AgentOutcome)],
2347 sent: &[SeatJob],
2348 prompts: &Prompts,
2349 run_id: &str,
2350 ) {
2351 for (wi, seat, out) in results.iter_mut() {
2352 let Some(dropped) = (match &*out {
2353 AgentOutcome::Dropped(o) => o.dropped.clone(),
2354 _ => None,
2355 }) else {
2356 continue;
2357 };
2358 let Some(job) = sent.get(*wi) else { continue };
2359 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2361 self.state.event(
2362 "implement",
2363 format!(
2364 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2365 work is in the tree",
2366 seat.key, dropped.output_tokens, dropped.why
2367 ),
2368 );
2369 continue;
2370 }
2371 if !has_context(&job.spec, seat, job.sessions) {
2379 self.state.event(
2380 "implement",
2381 format!(
2382 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2383 is no session left to resume",
2384 seat.key, dropped.output_tokens, dropped.why
2385 ),
2386 );
2387 continue;
2388 }
2389 self.state.event(
2390 "implement",
2391 format!(
2392 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2393 conversation",
2394 seat.key, dropped.output_tokens, dropped.why
2395 ),
2396 );
2397 let mut retry = job.clone();
2398 retry.seat = seat.clone();
2399 retry.prompt = prompt::resume_after_drop(&dropped.why);
2400 retry.timeout = retry_budget(job.timeout, true);
2401 retry.stem = format!("{}-resume", job.stem);
2402 let cache = self.state.config.cache_dir();
2403 let ctx = WaveCtx {
2404 carry_seats: false,
2405 pauses: Some((&self.pause, &self.interrupt)),
2406 run: run_id,
2407 node: "implement",
2408 prompts,
2409 cache: cache.as_deref(),
2410 round: None,
2411 };
2412 let (resumed_seat, resumed) =
2413 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2414 *seat = resumed_seat;
2415 *out = resumed;
2416 }
2417 }
2418
2419 async fn resume_seat_handovers(
2484 &mut self,
2485 results: &mut [(usize, SeatState, AgentOutcome)],
2486 sent: &mut [SeatJob],
2487 prompts: &Prompts,
2488 run_id: &str,
2489 ) {
2490 let instruction = seeded_instruction(&self.state);
2491 let language = self.state.config.graph.language.clone();
2492 let github_language = self.state.config.graph.github_language.clone();
2493 let brief = self
2494 .state
2495 .advice
2496 .as_ref()
2497 .and_then(|a| a.synthesis.as_deref())
2498 .map(str::to_owned);
2499 let attachments = self.state.attachments.clone();
2500 for (wi, seat, out) in results.iter_mut() {
2501 let others: BTreeSet<String> = sent
2504 .iter()
2505 .enumerate()
2506 .filter(|(j, _)| j != wi)
2507 .map(|(_, j)| j.spec.id.clone())
2508 .collect();
2509 let Some(job) = sent.get_mut(*wi) else {
2510 continue;
2511 };
2512 let start = self
2517 .roles
2518 .implementer_roster
2519 .iter()
2520 .position(|s| s.id == job.spec.id)
2521 .unwrap_or(0);
2522 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2523 let mut fallback_attempt = 0usize;
2524 let mut prev: Option<FailClass> = None;
2525 while let Some(cur) = FailClass::of(&*out) {
2526 if !should_hand_over(prev.as_ref(), &cur) {
2527 break;
2528 }
2529 let Some(next) =
2530 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2531 .cloned()
2532 else {
2533 break;
2534 };
2535 tried.insert(next.id.clone());
2536 fallback_attempt += 1;
2537
2538 if let Ok(r) = git::rescue_commit(
2539 &job.cwd,
2540 &format!(
2541 "magi: candidate {} (uncommitted work before {} fallback)",
2542 seat.key,
2543 if cur == FailClass::Quota {
2544 "quota"
2545 } else {
2546 "handover"
2547 }
2548 ),
2549 )
2550 .await
2551 {
2552 self.state.note_withheld("implement", &r.withheld);
2553 }
2554
2555 record_handover(
2556 &mut self.state,
2557 "implement",
2558 &seat.key,
2559 &seat.agent,
2560 &next.id,
2561 &cur,
2562 &fail_reason(&*out),
2563 );
2564 prev = Some(cur.clone());
2565
2566 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2567 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2568 job.spec = next.clone();
2576 let mut retry = job.clone();
2577 retry.seat = new_seat;
2578 retry.prompt = prompt::implement_in(
2579 &instruction,
2580 &job.cwd.to_string_lossy(),
2581 &language,
2582 &github_language,
2583 brief.as_deref(),
2584 &attachments,
2585 );
2586 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2587 let cache = self.state.config.cache_dir();
2588 let ctx = WaveCtx {
2589 carry_seats: false,
2590 pauses: Some((&self.pause, &self.interrupt)),
2591 run: run_id,
2592 node: "implement",
2593 prompts,
2594 cache: cache.as_deref(),
2595 round: None,
2596 };
2597 let (fallback_seat, fallback_out) = run_one(
2598 retry,
2599 Arc::clone(&self.sem),
2600 &ctx,
2601 &mut self.state,
2602 fallback_attempt,
2603 )
2604 .await;
2605 *seat = fallback_seat;
2606 *out = fallback_out;
2607 }
2608 }
2609 }
2610
2611 async fn resume_unconfirmed_commands(
2635 &mut self,
2636 results: &mut [(usize, SeatState, AgentOutcome)],
2637 sent: &[SeatJob],
2638 prompts: &Prompts,
2639 run_id: &str,
2640 ) {
2641 for (wi, seat, out) in results.iter_mut() {
2642 let AgentOutcome::Ok(o) = &*out else {
2643 continue;
2644 };
2645 if !has_unconfirmed_command(&o.commands) {
2646 continue;
2647 }
2648 let Some(job) = sent.get(*wi) else { continue };
2649 if !has_context(&job.spec, seat, job.sessions) {
2650 self.state.event(
2651 "implement",
2652 format!(
2653 "{}: the reply named a command whose own CLI never confirmed the exit \
2654 status of, but there is no session left to resume",
2655 seat.key
2656 ),
2657 );
2658 continue;
2659 }
2660 self.state.event(
2661 "implement",
2662 format!(
2663 "{}: the reply named a command whose own CLI never confirmed the exit \
2664 status of; resuming the conversation",
2665 seat.key
2666 ),
2667 );
2668 let mut retry = job.clone();
2669 retry.seat = seat.clone();
2670 retry.prompt = prompt::resume_incomplete(
2671 "a command in your last reply had no confirmed exit status",
2672 );
2673 retry.timeout = retry_budget(job.timeout, true);
2674 retry.stem = format!("{}-confirm", job.stem);
2675 let cache = self.state.config.cache_dir();
2676 let ctx = WaveCtx {
2677 carry_seats: false,
2678 pauses: Some((&self.pause, &self.interrupt)),
2679 run: run_id,
2680 node: "implement",
2681 prompts,
2682 cache: cache.as_deref(),
2683 round: None,
2684 };
2685 let (resumed_seat, resumed) =
2686 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2687 *seat = resumed_seat;
2688 *out = resumed;
2689 }
2690 }
2691
2692 async fn continue_fix_report(
2713 &mut self,
2714 mut seat: SeatState,
2715 parse_err: String,
2716 job: &SeatJob,
2717 prompts: &Prompts,
2718 run_id: &str,
2719 round: usize,
2720 ) -> (
2721 SeatState,
2722 Option<FixReport>,
2723 Option<String>,
2724 ContinuationRecord,
2725 ) {
2726 let mut last_err = parse_err;
2727 let mut cumulative_wait_ms = 0u64;
2728 let mut attempts = 0usize;
2729 loop {
2730 if !has_context(&job.spec, &seat, job.sessions) {
2731 self.state.event(
2732 "fix",
2733 format!(
2734 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2735 session left to resume into"
2736 ),
2737 );
2738 let outcome = if attempts == 0 {
2739 ContinuationOutcome::NoSession
2740 } else {
2741 ContinuationOutcome::Exhausted
2742 };
2743 return (
2744 seat,
2745 None,
2746 Some(format!("unparsable fix report: {last_err}")),
2747 ContinuationRecord {
2748 attempts,
2749 cumulative_wait_ms,
2750 outcome,
2751 },
2752 );
2753 }
2754 if attempts >= MAX_FIX_CONTINUATIONS {
2755 self.state.event(
2756 "fix",
2757 format!(
2758 "round {round}: fixer's reply still had no adoption report after \
2759 {attempts} continuation(s) ({last_err}); giving up"
2760 ),
2761 );
2762 return (
2763 seat,
2764 None,
2765 Some(format!(
2766 "unparsable fix report after {attempts} continuation(s): {last_err}"
2767 )),
2768 ContinuationRecord {
2769 attempts,
2770 cumulative_wait_ms,
2771 outcome: ContinuationOutcome::Exhausted,
2772 },
2773 );
2774 }
2775 attempts += 1;
2776 self.state.event(
2777 "fix",
2778 format!(
2779 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2780 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2781 ),
2782 );
2783 let mut retry = job.clone();
2784 retry.seat = seat.clone();
2785 retry.prompt = prompt::resume_incomplete(&last_err);
2786 retry.timeout = retry_budget(job.timeout, true);
2787 retry.stem = format!("{}-continue{attempts}", job.stem);
2788 let cache = self.state.config.cache_dir();
2789 let ctx = WaveCtx {
2790 carry_seats: false,
2791 pauses: Some((&self.pause, &self.interrupt)),
2792 run: run_id,
2793 node: "fix",
2794 prompts,
2795 cache: cache.as_deref(),
2796 round: Some(round),
2797 };
2798 let (resumed_seat, resumed_out) = run_one(
2799 retry,
2800 Arc::clone(&self.sem),
2801 &ctx,
2802 &mut self.state,
2803 attempts,
2804 )
2805 .await;
2806 seat = resumed_seat;
2807 match resumed_out {
2808 AgentOutcome::Ok(o) => {
2809 cumulative_wait_ms += o.duration_ms;
2810 match verdict::extract_json::<FixReport>(&o.text) {
2811 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2812 self.state.event(
2813 "fix",
2814 format!(
2815 "round {round}: fixer's adoption report recovered after \
2816 {attempts} continuation(s)"
2817 ),
2818 );
2819 return (
2820 seat,
2821 Some(report),
2822 None,
2823 ContinuationRecord {
2824 attempts,
2825 cumulative_wait_ms,
2826 outcome: ContinuationOutcome::Resumed,
2827 },
2828 );
2829 }
2830 Ok(_) => {
2838 last_err = "the reply parsed, but it reported a command whose own CLI \
2839 never confirmed an exit status"
2840 .to_owned();
2841 }
2842 Err(e) => last_err = e.to_string(),
2843 }
2844 }
2845 AgentOutcome::Quota(o) => {
2846 cumulative_wait_ms += o.duration_ms;
2847 self.state.quota.push(QuotaLoss {
2848 seat: seat.key.clone(),
2849 node: "fix".to_owned(),
2850 at: Timestamp::now(),
2851 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2852 });
2853 self.state.event(
2854 "fix",
2855 format!(
2856 "round {round}: continuation rate limited (quota); not retrying now"
2857 ),
2858 );
2859 return (
2860 seat,
2861 None,
2862 Some("rate limited (quota) while recovering the fix report".to_owned()),
2863 ContinuationRecord {
2864 attempts,
2865 cumulative_wait_ms,
2866 outcome: ContinuationOutcome::QuotaLost,
2867 },
2868 );
2869 }
2870 AgentOutcome::Dropped(o) => {
2871 cumulative_wait_ms += o.duration_ms;
2872 let why = o
2873 .dropped
2874 .as_ref()
2875 .map(|d| d.why.as_str())
2876 .unwrap_or("the CLI ended the stream without delivering its answer");
2877 last_err = format!("the CLI dropped the stream ({why})");
2878 }
2879 AgentOutcome::Failed(e) => last_err = e,
2880 AgentOutcome::ResourceBlocked(e) => {
2883 return (
2884 seat,
2885 None,
2886 Some(format!("the continuation was not started: {e}")),
2887 ContinuationRecord {
2888 attempts: attempts.saturating_sub(1),
2889 cumulative_wait_ms,
2890 outcome: ContinuationOutcome::NoSession,
2891 },
2892 );
2893 }
2894 }
2895 }
2896 }
2897
2898 fn after_implement(&mut self) -> Result<()> {
2899 if self.state.leaks.is_empty() {
2901 let cfg = self.state.config.blind.clone();
2902 let mut leaks = Vec::new();
2903 for c in &self.state.candidates {
2904 let Some(patch) =
2905 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2906 else {
2907 continue;
2908 };
2909 leaks.extend(blind::scan(
2910 &format!("candidate {} patch", c.label),
2911 &patch,
2912 &cfg.vendor_tokens,
2913 ));
2914 }
2915 if !leaks.is_empty() {
2916 let summary = leaks
2917 .iter()
2918 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2919 .collect::<Vec<_>>()
2920 .join(", ");
2921 match cfg.on_leak {
2922 LeakPolicy::Fail => {
2923 self.state.status = RunStatus::Failed;
2924 self.state
2925 .event("blind", format!("vendor text in a patch: {summary}"));
2926 self.state.leaks = leaks;
2927 self.state.save()?;
2928 self.settle_questions();
2929 bail!(
2930 "blind.on_leak = \"fail\" and vendor text reached a \
2931 judged patch: {summary}"
2932 );
2933 }
2934 LeakPolicy::Redact => self.state.event(
2935 "blind",
2936 format!("redacting vendor text for judging: {summary}"),
2937 ),
2938 LeakPolicy::Warn => self.state.event(
2939 "blind",
2940 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2941 ),
2942 }
2943 self.state.leaks = leaks;
2944 }
2945 }
2946
2947 if self.state.viable().is_empty() {
2948 if self.state.all_candidates_verified_noop() {
2949 self.state.status = RunStatus::VerifiedNoop;
2960 self.state.save()?;
2961 self.settle_questions();
2962 return Ok(());
2963 }
2964 self.state.status = RunStatus::Failed;
2965 self.state.save()?;
2966 self.settle_questions();
2967 bail!("no candidate produced a change; nothing to judge");
2968 }
2969 self.state.status = RunStatus::Judging;
2970 self.state.save()?;
2971 Ok(())
2972 }
2973
2974 async fn judge(&mut self) -> Result<()> {
2977 let run_id = self.state.id.clone();
2982 let prompts = self.state.config.prompts.clone();
2983 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2984 return Ok(());
2985 }
2986 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2987 if viable.len() == 1 {
2988 self.state.judge_skipped = true;
2995 self.state.event(
2996 "judge",
2997 format!(
2998 "only candidate {} produced a change; judging skipped",
2999 viable[0].label
3000 ),
3001 );
3002 self.state.save()?;
3003 return Ok(());
3004 }
3005 self.state.status = RunStatus::Judging;
3006
3007 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3008 let language = self.state.config.graph.language.clone();
3009 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3010 let sessions = self.state.config.graph.sessions;
3011 let artifacts = agent::artifacts_dir(&self.state.dir());
3012 let root = self.state.worktree_root();
3013 let base_short = short(&self.state.base_commit);
3014
3015 let mut jobs = Vec::new();
3016 let mut orders = Vec::new();
3017 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3018 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3019 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3020 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3021 let seat_key = format!("judge-{}", j + 1);
3022 let seat = self.seat(&seat_key, &spec.id);
3023 jobs.push(SeatJob {
3024 prompt: prompt::judge(
3025 &self.state.instruction,
3026 &views,
3027 self.roles.judges.len(),
3028 &base_short,
3029 &language,
3030 ),
3031 spec,
3032 seat,
3033 cwd: root.join(format!("judge-{}", j + 1)),
3034 timeout,
3035 allow_write: false,
3036 sessions,
3037 artifacts: artifacts.clone(),
3038 stem: format!("judge-{}", j + 1),
3039 handover: None,
3040 });
3041 }
3042
3043 self.state.event(
3044 "judge",
3045 format!(
3046 "{} judges ranking {} candidates blind",
3047 jobs.len(),
3048 viable.len()
3049 ),
3050 );
3051 let labels_for_check = labels.clone();
3052 let mut quota_losses = Vec::new();
3053 let cache = self.state.config.cache_dir();
3054 let ctx = WaveCtx {
3055 carry_seats: false,
3056 pauses: Some((&self.pause, &self.interrupt)),
3057 run: &run_id,
3058 node: "judge",
3059 prompts: &prompts,
3060 cache: cache.as_deref(),
3061 round: None,
3062 };
3063 let results = ask_json_wave::<Ranking>(
3064 jobs,
3065 Arc::clone(&self.sem),
3066 self.state.config.graph.retries,
3067 &self.roles.judge_roster,
3068 &ctx,
3069 &mut quota_losses,
3070 &mut self.state,
3071 &move |r: &Ranking| r.validate(&labels_for_check),
3072 )
3073 .await;
3074 if self.state.resource_block.is_some() {
3075 return self.stop_for_resource();
3076 }
3077 self.state.quota.extend(quota_losses);
3078
3079 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3080 let agent_id = seat.agent.clone();
3081 self.state.seats.insert(seat.key.clone(), seat);
3082 let mut record = Judgement {
3083 judge: j + 1,
3084 seat: format!("judge-{}", j + 1),
3085 agent: agent_id,
3086 ranking: Vec::new(),
3087 reasons: BTreeMap::new(),
3088 confidence: None,
3089 strengths: BTreeMap::new(),
3090 order: orders[j].clone(),
3091 failed: None,
3092 duration_ms: 0,
3093 };
3094 match res {
3095 Ok((ranking, out)) => {
3096 record.ranking = ranking.normalized();
3097 record.reasons = ranking.reasons;
3098 record.strengths = ranking.strengths;
3099 record.confidence = ranking.confidence;
3100 record.duration_ms = out.duration_ms;
3101 self.state.event(
3102 "judge",
3103 format!(
3104 "judge {} ranked {}",
3105 j + 1,
3106 record.ranking.iter().collect::<String>()
3107 ),
3108 );
3109 }
3110 Err(e) => {
3111 record.failed = Some(e.to_string());
3112 self.state
3113 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3114 }
3115 }
3116 self.state.judgements.push(record);
3117 self.state.save()?;
3118 }
3119 Ok(())
3120 }
3121
3122 async fn deliberate(&mut self) -> Result<()> {
3125 let run_id = self.state.id.clone();
3130 let prompts = self.state.config.prompts.clone();
3131 if !self.state.deliberation.is_empty() {
3132 return Ok(());
3133 }
3134 let tops: Vec<char> = self
3135 .state
3136 .judgements
3137 .iter()
3138 .filter_map(|j| j.ranking.first().copied())
3139 .collect();
3140 let rounds = self.state.config.graph.deliberate_rounds;
3141 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3142 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3143 self.state.event(
3144 "deliberate",
3145 format!("judges agreed on {} outright; no deliberation", tops[0]),
3146 );
3147 }
3148 self.state.status = RunStatus::Voting;
3149 self.state.save()?;
3150 return Ok(());
3151 }
3152
3153 self.state.status = RunStatus::Deliberating;
3154 self.state.event(
3155 "deliberate",
3156 format!(
3157 "split: first choices were {} — opening {rounds} round(s)",
3158 tops.iter().collect::<String>()
3159 ),
3160 );
3161
3162 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3163 let language = self.state.config.graph.language.clone();
3164 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3165 let sessions = self.state.config.graph.sessions;
3166 let artifacts = agent::artifacts_dir(&self.state.dir());
3167 let root = self.state.worktree_root();
3168 let base_short = short(&self.state.base_commit);
3169
3170 for round in 1..=rounds {
3174 let mut turns: Vec<DeliberationTurn> = Vec::new();
3175 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3176 if self.state.judgements[j].failed.is_some() {
3177 continue;
3178 }
3179 let seat_key = format!("judge-{}", j + 1);
3180 let spec = self.occupant(&seat_key, spec);
3181 let mut seat = self.seat(&seat_key, &spec.id);
3182 let transcript = self.transcript(&turns, j);
3183 let build = |context: Option<&str>| {
3184 prompt::deliberate(
3185 &self.state.instruction,
3186 context,
3187 &transcript,
3188 round,
3189 rounds,
3190 &language,
3191 )
3192 };
3193 let block = self.candidate_block(&viable, &base_short);
3194 let full = build(Some(&block));
3195 let text = if has_context(&spec, &seat, sessions) {
3196 build(None)
3197 } else {
3198 full.clone()
3199 };
3200 let job = SeatJob {
3201 spec,
3202 seat: seat.clone(),
3203 prompt: text,
3204 cwd: root.join(format!("judge-{}", j + 1)),
3205 timeout,
3206 allow_write: false,
3207 sessions,
3208 artifacts: artifacts.clone(),
3209 stem: format!("delib-{round}-judge-{}", j + 1),
3210 handover: Some(full),
3211 };
3212 let cache = self.state.config.cache_dir();
3213 let ctx = WaveCtx {
3214 carry_seats: false,
3215 pauses: Some((&self.pause, &self.interrupt)),
3216 run: &run_id,
3217 node: "deliberate",
3218 prompts: &prompts,
3219 cache: cache.as_deref(),
3220 round: None,
3221 };
3222 let mut losses = Vec::new();
3226 let mut results = ask_wave_with::<String>(
3227 vec![job],
3228 Arc::clone(&self.sem),
3229 0,
3230 &self.roles.judge_roster,
3231 &ctx,
3232 &mut losses,
3233 &mut self.state,
3234 &|text: &str| {
3235 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3236 },
3237 )
3238 .await;
3239 self.state.quota.extend(losses);
3240 let (updated, res, _) = results.pop().expect("one job in, one result out");
3241 seat = updated;
3242 let agent_id = seat.agent.clone();
3243 self.state.seats.insert(seat.key.clone(), seat);
3244 let body = match res {
3245 Ok((body, _)) => body,
3246 Err(e) => {
3249 self.state
3250 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3251 continue;
3252 }
3253 };
3254 let tentative = verdict::extract_json::<Position>(&body)
3255 .ok()
3256 .and_then(|p| p.tentative)
3257 .and_then(|s| s.trim().chars().next())
3258 .map(|c| c.to_ascii_uppercase());
3259 self.state.event(
3260 "deliberate",
3261 format!(
3262 "round {round}: judge {} now favours {}",
3263 j + 1,
3264 tentative.map_or("—".to_owned(), |c| c.to_string())
3265 ),
3266 );
3267 turns.push(DeliberationTurn {
3268 judge: j + 1,
3269 agent: agent_id,
3270 body: blind::sanitize_prose(&body, &self.state.config.blind),
3271 tentative,
3272 });
3273 }
3274 self.state
3275 .deliberation
3276 .push(DeliberationRound { round, turns });
3277 self.state.save()?;
3278 }
3279
3280 self.state.status = RunStatus::Voting;
3281 self.state.save()?;
3282 Ok(())
3283 }
3284
3285 async fn vote(&mut self) -> Result<()> {
3288 let run_id = self.state.id.clone();
3293 let prompts = self.state.config.prompts.clone();
3294 if !self.state.votes.is_empty() {
3295 return Ok(());
3296 }
3297 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3298 if viable.len() == 1 {
3299 return Ok(());
3300 }
3301 self.state.status = RunStatus::Voting;
3302
3303 let language = self.state.config.graph.language.clone();
3304 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3305 let sessions = self.state.config.graph.sessions;
3306 let artifacts = agent::artifacts_dir(&self.state.dir());
3307 let root = self.state.worktree_root();
3308 let base_short = short(&self.state.base_commit);
3309 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3310
3311 let mut jobs = Vec::new();
3312 let mut seats_at = Vec::new();
3313 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3314 if self
3315 .state
3316 .judgements
3317 .get(j)
3318 .is_some_and(|r| r.failed.is_some())
3319 {
3320 continue;
3321 }
3322 let seat_key = format!("judge-{}", j + 1);
3323 let spec = self.occupant(&seat_key, spec);
3324 let seat = self.seat(&seat_key, &spec.id);
3325 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3326 let text = if has_context(&spec, &seat, sessions) {
3327 prompt::final_vote(&viable, &language)
3328 } else {
3329 full.clone()
3330 };
3331 jobs.push(SeatJob {
3332 spec,
3333 seat,
3334 prompt: text,
3335 cwd: root.join(format!("judge-{}", j + 1)),
3336 timeout,
3337 allow_write: false,
3338 sessions,
3339 artifacts: artifacts.clone(),
3340 stem: format!("vote-judge-{}", j + 1),
3341 handover: Some(full),
3342 });
3343 seats_at.push(j);
3344 }
3345
3346 self.state.event(
3347 "vote",
3348 format!(
3349 "collecting {} final votes one by one, privately",
3350 jobs.len()
3351 ),
3352 );
3353 let allowed = viable.clone();
3354 let mut quota_losses = Vec::new();
3355 let cache = self.state.config.cache_dir();
3356 let ctx = WaveCtx {
3357 carry_seats: false,
3358 pauses: Some((&self.pause, &self.interrupt)),
3359 run: &run_id,
3360 node: "vote",
3361 prompts: &prompts,
3362 cache: cache.as_deref(),
3363 round: None,
3364 };
3365 let results = ask_json_wave::<FinalVote>(
3366 jobs,
3367 Arc::clone(&self.sem),
3368 self.state.config.graph.retries,
3369 &self.roles.judge_roster,
3370 &ctx,
3371 &mut quota_losses,
3372 &mut self.state,
3373 &move |v: &FinalVote| match v.label() {
3374 Some(c) if allowed.contains(&c) => Ok(()),
3375 other => bail!("vote {other:?} is not one of {allowed:?}"),
3376 },
3377 )
3378 .await;
3379 if self.state.resource_block.is_some() {
3380 return self.stop_for_resource();
3381 }
3382 self.state.quota.extend(quota_losses);
3383
3384 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3385 let agent_id = seat.agent.clone();
3386 self.state.seats.insert(seat.key.clone(), seat);
3387 let initial = self
3388 .state
3389 .judgements
3390 .get(j)
3391 .and_then(|r| r.ranking.first().copied());
3392 let mut record = VoteRecord {
3393 judge: j + 1,
3394 agent: agent_id,
3395 vote: None,
3396 reason: String::new(),
3397 changed: false,
3398 };
3399 match res {
3400 Ok((v, _)) => {
3401 record.vote = v.label();
3402 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3403 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3404 self.state.event(
3405 "vote",
3406 format!(
3407 "judge {} voted {}{}",
3408 j + 1,
3409 record.vote.unwrap_or('?'),
3410 if record.changed { " (changed)" } else { "" }
3411 ),
3412 );
3413 }
3414 Err(e) => {
3415 self.state
3416 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3417 }
3418 }
3419 self.state.votes.push(record);
3420 self.state.save()?;
3421 }
3422 Ok(())
3423 }
3424
3425 fn tally(&mut self) -> Result<()> {
3428 if self.state.tally.is_some() {
3429 return Ok(());
3430 }
3431 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3432 let tops: Vec<char> = self
3433 .state
3434 .judgements
3435 .iter()
3436 .filter_map(|j| j.ranking.first().copied())
3437 .collect();
3438 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3439
3440 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3443 let mut cast: Vec<char> = Vec::new();
3444 for (i, j) in self.state.judgements.iter().enumerate() {
3445 let vote = self
3446 .state
3447 .votes
3448 .iter()
3449 .find(|v| v.judge == i + 1)
3450 .and_then(|v| v.vote)
3451 .or_else(|| j.ranking.first().copied());
3452 if let Some(v) = vote {
3453 *first_choice.entry(v).or_insert(0) += 1;
3454 cast.push(v);
3455 }
3456 }
3457
3458 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3459 for j in &self.state.judgements {
3460 let n = j.ranking.len();
3461 for (pos, label) in j.ranking.iter().enumerate() {
3462 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3463 }
3464 }
3465
3466 let best = first_choice.values().copied().max().unwrap_or(0);
3467 let mut leaders: Vec<char> = first_choice
3468 .iter()
3469 .filter(|(_, v)| **v == best)
3470 .map(|(k, _)| *k)
3471 .collect();
3472 let mut tie_break = None;
3473 if leaders.len() > 1 {
3474 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3475 let borda_leaders: Vec<char> = leaders
3476 .iter()
3477 .copied()
3478 .filter(|l| borda[l] == top_borda)
3479 .collect();
3480 tie_break = Some(if borda_leaders.len() == 1 {
3481 format!(
3482 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3483 leaders.len()
3484 )
3485 } else {
3486 format!(
3487 "{} way tie on both first-choice votes and Borda points, broken by label order",
3488 leaders.len()
3489 )
3490 });
3491 leaders = borda_leaders;
3492 leaders.sort_unstable();
3493 }
3494 let winner = *leaders
3495 .first()
3496 .or(viable.first())
3497 .context("no candidate to declare a winner from")?;
3498
3499 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3500 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3501 let deliberated = !self.state.deliberation.is_empty();
3502
3503 let quota_seats: std::collections::BTreeSet<&str> =
3507 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3508 let mut present = 0usize;
3509 for (i, j) in self.state.judgements.iter().enumerate() {
3510 if quota_seats.contains(j.seat.as_str()) {
3511 continue;
3512 }
3513 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3514 let voted = self
3515 .state
3516 .votes
3517 .iter()
3518 .any(|v| v.judge == i + 1 && v.vote.is_some());
3519 if ranked || voted {
3520 present += 1;
3521 }
3522 }
3523 let needs_quorum = viable.len() > 1;
3529 let judges_total = if needs_quorum {
3530 self.roles.judges.len()
3531 } else {
3532 0
3533 };
3534 let quorum = if needs_quorum {
3535 judges_total / 2 + 1
3536 } else {
3537 0
3538 };
3539 let met_quorum = !needs_quorum || present >= quorum;
3540 let uncontested = (!needs_quorum).then(|| {
3541 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3542 });
3543
3544 self.state.event(
3545 "tally",
3546 match &uncontested {
3547 Some(reason) => format!("winner {winner} — {reason}"),
3548 None => format!(
3549 "winner {winner} — votes {} | initial {} | {} changed | \
3550 {present}/{judges_total} judges{}",
3551 first_choice
3552 .iter()
3553 .map(|(k, v)| format!("{k}:{v}"))
3554 .collect::<Vec<_>>()
3555 .join(" "),
3556 if unanimous_initial {
3557 "unanimous"
3558 } else {
3559 "split"
3560 },
3561 changed_votes,
3562 if met_quorum {
3563 String::new()
3564 } else {
3565 format!(" — below quorum ({quorum} required)")
3566 },
3567 ),
3568 },
3569 );
3570 if !met_quorum {
3571 self.state.event(
3572 "stall",
3573 format!(
3574 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3575 the run stops here, resumable"
3576 ),
3577 );
3578 }
3579 self.state.tally = Some(Tally {
3580 first_choice,
3581 borda,
3582 winner,
3583 rankings: tops.len(),
3584 unanimous_initial,
3585 deliberated,
3586 changed_votes,
3587 unanimous_final,
3588 tie_break,
3589 judges: judges_total,
3590 present,
3591 quorum,
3592 met_quorum,
3593 uncontested,
3594 });
3595 self.state.status = if met_quorum {
3596 RunStatus::Reviewing
3597 } else {
3598 RunStatus::Stalled
3599 };
3600 self.state.save()?;
3601 Ok(())
3602 }
3603
3604 #[allow(clippy::too_many_lines)]
3625 async fn recover_stall(&mut self) -> Result<bool> {
3626 let run_id = self.state.id.clone();
3631 let prompts = self.state.config.prompts.clone();
3632 let quota_seats: BTreeSet<&str> =
3637 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3638 let absent: Vec<String> = self
3639 .state
3640 .judgements
3641 .iter()
3642 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3643 .map(|j| j.seat.clone())
3644 .collect();
3645 if absent.is_empty() {
3646 return Ok(false);
3647 }
3648 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3649 if viable.len() <= 1 {
3650 return Ok(false);
3651 }
3652 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3653 let language = self.state.config.graph.language.clone();
3654 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3655 let sessions = self.state.config.graph.sessions;
3656 let artifacts = agent::artifacts_dir(&self.state.dir());
3657 let root = self.state.worktree_root();
3658 let base_short = short(&self.state.base_commit);
3659 let candidates: Vec<Candidate> = viable.clone();
3660
3661 let mut positions: Vec<usize> = absent
3663 .iter()
3664 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3665 .collect();
3666 if positions.is_empty() {
3667 return Ok(false);
3668 }
3669 positions.sort_unstable();
3670 positions.dedup();
3671
3672 let mut judge_jobs = Vec::new();
3674 for &j in &positions {
3675 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3676 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3677 let seat_key = format!("judge-{}", j + 1);
3678 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3679 let seat = self.seat(&seat_key, &spec.id);
3680 judge_jobs.push(SeatJob {
3681 spec,
3682 seat,
3683 prompt: prompt::judge(
3684 &self.state.instruction,
3685 &views,
3686 self.roles.judges.len(),
3687 &base_short,
3688 &language,
3689 ),
3690 cwd: root.join(seat_key),
3691 timeout,
3692 allow_write: false,
3693 sessions,
3694 artifacts: artifacts.clone(),
3695 stem: format!("judge-{}-recover", j + 1),
3696 handover: None,
3697 });
3698 }
3699
3700 let labels_for_check = labels.clone();
3701 let mut judge_losses = Vec::new();
3702 let retries = self.state.config.graph.retries;
3703 let cache = self.state.config.cache_dir();
3704 let ctx = WaveCtx {
3705 carry_seats: false,
3706 pauses: Some((&self.pause, &self.interrupt)),
3707 run: &run_id,
3708 node: "judge",
3709 prompts: &prompts,
3710 cache: cache.as_deref(),
3711 round: None,
3712 };
3713 let results = ask_json_wave::<Ranking>(
3714 judge_jobs,
3715 Arc::clone(&self.sem),
3716 retries,
3717 &self.roles.judge_roster,
3718 &ctx,
3719 &mut judge_losses,
3720 &mut self.state,
3721 &move |r: &Ranking| r.validate(&labels_for_check),
3722 )
3723 .await;
3724 if self.state.resource_block.is_some() {
3725 self.stop_for_resource()?;
3726 return Ok(false);
3727 }
3728
3729 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3731 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3732 let agent_id = seat.agent.clone();
3733 self.state.seats.insert(seat.key.clone(), seat);
3734 let record = &mut self.state.judgements[j];
3735 match res {
3736 Ok((ranking, out)) => {
3737 record.agent = agent_id;
3738 record.ranking = ranking.normalized();
3739 record.reasons = ranking.reasons;
3740 record.strengths = ranking.strengths;
3741 record.confidence = ranking.confidence;
3742 record.failed = None;
3743 record.duration_ms = out.duration_ms;
3744 recovered.insert(j);
3745 self.state.event(
3746 "recover",
3747 format!("judge {} ranked again after the limit", j + 1),
3748 );
3749 }
3750 Err(e) => {
3751 self.state
3752 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3753 }
3754 }
3755 }
3756
3757 let mut vote_jobs = Vec::new();
3759 let mut vote_pos: Vec<usize> = Vec::new();
3760 for &j in &recovered {
3761 let seat_key = format!("judge-{}", j + 1);
3762 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3763 let seat = self.seat(&seat_key, &spec.id);
3764 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3765 let text = if has_context(&spec, &seat, sessions) {
3766 prompt::final_vote(&labels, &language)
3767 } else {
3768 full.clone()
3769 };
3770 vote_jobs.push(SeatJob {
3771 spec,
3772 seat,
3773 prompt: text,
3774 cwd: root.join(seat_key),
3775 timeout,
3776 allow_write: false,
3777 sessions,
3778 artifacts: artifacts.clone(),
3779 stem: format!("vote-judge-{}-recover", j + 1),
3780 handover: Some(full),
3781 });
3782 vote_pos.push(j);
3783 }
3784 let allowed = labels.clone();
3785 let mut vote_losses = Vec::new();
3786 let vote_retries = self.state.config.graph.retries;
3787 let vote_cache = self.state.config.cache_dir();
3788 let ctx = WaveCtx {
3789 carry_seats: false,
3790 pauses: Some((&self.pause, &self.interrupt)),
3791 run: &run_id,
3792 node: "vote",
3793 prompts: &prompts,
3794 cache: vote_cache.as_deref(),
3795 round: None,
3796 };
3797 let votes = ask_json_wave::<FinalVote>(
3798 vote_jobs,
3799 Arc::clone(&self.sem),
3800 vote_retries,
3801 &self.roles.judge_roster,
3802 &ctx,
3803 &mut vote_losses,
3804 &mut self.state,
3805 &move |v: &FinalVote| match v.label() {
3806 Some(c) if allowed.contains(&c) => Ok(()),
3807 other => bail!("vote {other:?} is not one of {allowed:?}"),
3808 },
3809 )
3810 .await;
3811 if self.state.resource_block.is_some() {
3812 self.stop_for_resource()?;
3813 return Ok(false);
3814 }
3815 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3816 let agent_id = seat.agent.clone();
3817 self.state.seats.insert(seat.key.clone(), seat);
3818 match res {
3819 Ok((v, _)) => {
3820 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3821 rec.vote = v.label();
3822 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3823 } else {
3824 self.state.votes.push(VoteRecord {
3825 judge: j + 1,
3826 agent: agent_id,
3827 vote: v.label(),
3828 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3829 changed: false,
3830 });
3831 }
3832 self.state.event(
3833 "recover",
3834 format!("judge {} voted again after the limit", j + 1),
3835 );
3836 }
3837 Err(e) => {
3838 self.state
3839 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3840 }
3841 }
3842 }
3843
3844 let recovered_keys: BTreeSet<String> = recovered
3848 .iter()
3849 .map(|&j| format!("judge-{}", j + 1))
3850 .collect();
3851 self.state
3852 .quota
3853 .retain(|q| !recovered_keys.contains(&q.seat));
3854 for loss in judge_losses.into_iter().chain(vote_losses) {
3858 if recovered_keys.contains(&loss.seat) {
3859 continue;
3860 }
3861 self.state.quota.retain(|q| q.seat != loss.seat);
3862 self.state.quota.push(loss);
3863 }
3864
3865 self.state.tally = None;
3867 self.tally()?;
3868 Ok(self
3869 .state
3870 .tally
3871 .as_ref()
3872 .map(|t| t.met_quorum)
3873 .unwrap_or(false))
3874 }
3875
3876 async fn graft(&mut self) -> Result<()> {
3893 let Err(e) = self.graft_inner().await else {
3894 return Ok(());
3895 };
3896 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3897 self.state.status = RunStatus::Blocked;
3898 self.state.event("graft", format!("blocked: {e:#}"));
3899 self.state.save()?;
3900 return Err(e);
3901 }
3902 if let Some(rec) = self.state.graft.clone()
3907 && !rec.outcome.settled()
3908 {
3909 if let Some(winner) = self.state.winner().cloned() {
3910 if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3911 self.state.status = RunStatus::Blocked;
3912 self.state.event("graft", format!("blocked: {re:#}"));
3913 self.state.save()?;
3914 return Err(re);
3915 }
3916 }
3917 if let Some(g) = self.state.graft.as_mut() {
3918 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3919 }
3920 }
3921 self.state.event("graft", format!("skipped: {e:#}"));
3922 if let Err(e) = self.state.save() {
3923 tracing::warn!("save after graft: {e:#}");
3924 }
3925 Ok(())
3926 }
3927
3928 async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3931 crate::recovery::restore_repair(wt, pre)
3932 .await
3933 .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
3934 }
3935
3936 fn graft_skipped(&mut self, why: &str) {
3938 let message = format!("skipped: {why}");
3939 if !self
3940 .state
3941 .events
3942 .iter()
3943 .any(|e| e.node == "graft" && e.message == message)
3944 {
3945 self.state.event("graft", message);
3946 }
3947 }
3948
3949 async fn graft_inner(&mut self) -> Result<()> {
3950 if let Some(rec) = self.state.graft.clone()
3953 && !rec.outcome.settled()
3954 {
3955 if self.state.reviews.is_empty()
3959 && let Some(winner) = self.state.winner().cloned()
3960 {
3961 Self::graft_restore(&winner.worktree, &rec.pre).await?;
3962 }
3963 if let Some(g) = self.state.graft.as_mut() {
3964 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3965 }
3966 self.state.event(
3967 "graft",
3968 "rolled back: the previous round was interrupted; the winner is untouched",
3969 );
3970 self.state.save()?;
3971 return Ok(());
3972 }
3973 let plan = match graft::plan(&self.state) {
3974 Ok(plan) => plan,
3975 Err(skip) => {
3976 if skip != graft::Skip::AlreadyDone {
3977 self.graft_skipped(skip.reason());
3978 self.state.save()?;
3979 }
3980 return Ok(());
3981 }
3982 };
3983 let Some(winner) = self.state.winner().cloned() else {
3984 return Ok(());
3985 };
3986 let wt = winner.worktree.clone();
3987 if !git::is_clean(&wt).await? {
3988 self.graft_skipped("the winner's worktree has uncommitted changes");
3989 self.state.save()?;
3990 return Ok(());
3991 }
3992 let pre = git::rev_parse(&wt, "HEAD").await?;
3993 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3994 else {
3995 self.graft_skipped("the winner's diff could not be measured");
3996 self.state.save()?;
3997 return Ok(());
3998 };
3999 let limit = graft::limit(winner_lines);
4000 self.state.graft = Some(GraftRecord {
4003 pre: pre.clone(),
4004 post: None,
4005 lines: None,
4006 limit: Some(limit),
4007 agent: None,
4008 outcome: GraftOutcome::Running,
4009 requested: graft::requested(&plan),
4010 adoption: None,
4011 adoption_error: None,
4012 });
4013 self.state.event(
4014 "graft",
4015 format!(
4016 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4017 plan.strengths
4018 .iter()
4019 .map(|(l, _, _)| l.to_string())
4020 .collect::<Vec<_>>()
4021 .join(", "),
4022 plan.winner
4023 ),
4024 );
4025 self.state.save()?;
4026
4027 let fix_prompt = prompt::graft(
4028 &self.state.instruction,
4029 plan.winner,
4030 &plan.strengths,
4031 limit,
4032 &self.state.config.graph.language,
4033 );
4034 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4035 let sessions = self.state.config.graph.sessions;
4036 let artifacts = agent::artifacts_dir(&self.state.dir());
4037 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4038 let (_, seat, out) = self
4039 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4040 SeatJob {
4041 prompt: fix_prompt.clone(),
4042 spec,
4043 seat,
4044 cwd: wt.clone(),
4045 timeout,
4046 allow_write: true,
4047 sessions,
4048 artifacts: artifacts.clone(),
4049 stem: "graft".to_owned(),
4050 handover: None,
4051 }
4052 })
4053 .await;
4054 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4059 Self::graft_restore(&wt, &pre).await?;
4060 self.state.graft = None;
4061 self.state.save()?;
4062 return self.stop_for_resource();
4063 }
4064 let mut why: Option<String> = None;
4065 match &out {
4066 AgentOutcome::Ok(o) if agent::output_advances(o) => {
4067 why = Some("the fixer produced nothing usable".to_owned());
4068 }
4069 AgentOutcome::Ok(_) => {}
4070 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4071 AgentOutcome::Quota(o) => {
4072 self.state.quota.push(QuotaLoss {
4073 seat: seat.key.clone(),
4074 node: "graft".to_owned(),
4075 at: Timestamp::now(),
4076 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4077 });
4078 why = Some("rate limited (quota)".to_owned());
4079 }
4080 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4081 why = Some(format!(
4082 "the fixer failed: {}",
4083 e.lines()
4084 .next()
4085 .unwrap_or("")
4086 .chars()
4087 .take(160)
4088 .collect::<String>()
4089 ));
4090 }
4091 }
4092 let agent_id = seat.agent.clone();
4093 self.state.seats.insert(seat.key.clone(), seat);
4094
4095 let mut post = None;
4096 let mut lines = None;
4097 if why.is_none() {
4098 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4099 if let Ok(r) = &rescued {
4100 self.state.note_withheld("graft", &r.withheld);
4101 }
4102 let head = git::rev_parse(&wt, "HEAD").await?;
4103 let dirty = match &rescued {
4107 Ok(r) => git::status_porcelain(&wt)
4108 .await
4109 .map(|st| {
4110 st.lines().any(|l| {
4111 !r.withheld
4112 .iter()
4113 .any(|w| l.trim_end().ends_with(w.path.as_str()))
4114 })
4115 })
4116 .unwrap_or(true),
4117 Err(_) => true,
4118 };
4119 if dirty {
4120 why = Some("the fixer left changes that could not be committed".to_owned());
4121 } else if head == pre {
4122 why = Some("the fixer changed nothing".to_owned());
4123 } else {
4124 lines = git::changed_lines(&wt, &pre, &head).await;
4125 if graft::within_budget(lines, limit) {
4126 post = Some(head);
4127 } else {
4128 why = Some(format!(
4129 "{} changed lines against a limit of {limit}",
4130 lines.map_or_else(
4131 || "an unmeasurable number of".to_owned(),
4132 |l| l.to_string()
4133 )
4134 ));
4135 }
4136 }
4137 }
4138 let (adoption, adoption_error) = match &out {
4139 AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4140 _ => (None, None),
4141 };
4142 let requested = graft::requested(&plan);
4143 let outcome = match why {
4144 None => {
4145 self.state.event(
4146 "graft",
4147 format!(
4148 "applied: {} changed lines (limit {limit})",
4149 lines.unwrap_or(0)
4150 ),
4151 );
4152 GraftOutcome::Applied
4153 }
4154 Some(why) => {
4155 Self::graft_restore(&wt, &pre).await?;
4158 self.state.event(
4159 "graft",
4160 format!("rolled back: {why}; the winner is untouched"),
4161 );
4162 GraftOutcome::RolledBack(why)
4163 }
4164 };
4165 self.state.graft = Some(GraftRecord {
4166 pre,
4167 post,
4168 lines,
4169 limit: Some(limit),
4170 agent: Some(agent_id),
4171 outcome,
4172 requested,
4173 adoption,
4174 adoption_error,
4175 });
4176 self.state.save()?;
4177 Ok(())
4178 }
4179
4180 async fn fold_losers(&mut self) -> Result<()> {
4183 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4184 return Ok(());
4185 };
4186 let repo = self.state.repo.clone();
4187 let mut folded = Vec::new();
4188 for i in 0..self.state.candidates.len() {
4189 let c = &self.state.candidates[i];
4190 if c.label == winner || c.folded {
4191 continue;
4192 }
4193 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4194 git::worktree_remove(&repo, &wt).await.ok();
4195 git::branch_delete(&repo, &branch).await.ok();
4196 self.state.candidates[i].folded = true;
4197 folded.push(label.to_string());
4198 }
4199 let root = self.state.worktree_root();
4201 for j in 1..=self.roles.judges.len() {
4202 let wt = root.join(format!("judge-{j}"));
4203 if wt.exists() {
4204 git::worktree_remove(&repo, &wt).await.ok();
4205 }
4206 }
4207 if self.state.config.graph.advise {
4210 for k in 1..=self.state.config.graph.advisors {
4211 let wt = root.join(format!("advisor-{k}"));
4212 if wt.exists() {
4213 git::worktree_remove(&repo, &wt).await.ok();
4214 }
4215 }
4216 }
4217 if !folded.is_empty() {
4218 self.state
4219 .event("fold", format!("folded candidates {}", folded.join(", ")));
4220 self.state.save()?;
4221 }
4222 Ok(())
4223 }
4224
4225 async fn sync_to_base(&mut self) -> Result<()> {
4265 if self.state.status == RunStatus::AlreadyInBase {
4266 return Ok(());
4267 }
4268 let conflicted = self
4269 .state
4270 .base_sync
4271 .as_ref()
4272 .is_some_and(|s| s.conflict.is_some());
4273 let Some(winner) = self.state.winner().cloned() else {
4274 return Ok(());
4275 };
4276
4277 let repo = self.state.repo.clone();
4278 let remote = self.state.config.merge.remote.clone();
4279 let base_branch = self.state.base_branch.clone();
4280 let tracking = format!("{remote}/{base_branch}");
4281
4282 git::fetch(&repo, &remote, &base_branch).await.ok();
4283 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4288 return Ok(());
4289 };
4290
4291 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4292 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4293 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4294
4295 if (behind > 0 || conflicted || head == tip)
4304 && self
4305 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4306 .await?
4307 {
4308 return Ok(());
4309 }
4310 if conflicted {
4311 return Ok(());
4312 }
4313
4314 if behind == 0 {
4315 if let Some(from) = self
4324 .state
4325 .rebase_fixes
4326 .iter()
4327 .rev()
4328 .find_map(|r| r.from.clone())
4329 && from != head
4330 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4331 .await
4332 .is_ok_and(|o| o.ok())
4333 {
4334 git::sync_to_head(&winner.worktree).await?;
4335 }
4336 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4342 if let Some(why) = &conflict {
4343 self.state.status = RunStatus::Blocked;
4344 self.state.event("land", why.clone());
4345 }
4346 self.state.base_sync = Some(BaseSync {
4347 tip,
4348 behind: 0,
4349 attempts,
4350 conflict,
4351 already_in: None,
4352 });
4353 self.state.save()?;
4354 return Ok(());
4355 }
4356
4357 if attempts >= BASE_SYNC_ROUNDS {
4358 let why = format!(
4359 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4360 rebase(s); rebasing again would only race it",
4361 winner.branch
4362 );
4363 self.state.status = RunStatus::Blocked;
4364 self.state.base_sync = Some(BaseSync {
4365 tip,
4366 behind,
4367 attempts,
4368 conflict: Some(why.clone()),
4369 already_in: None,
4370 });
4371 self.state.event("land", why);
4372 self.state.save()?;
4373 return Ok(());
4374 }
4375
4376 self.state.event(
4377 "land",
4378 format!(
4379 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4380 winner.branch
4381 ),
4382 );
4383 self.state.save()?;
4384
4385 let branch_tracking = format!("{remote}/{}", winner.branch);
4391 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4392 let remote_tip = if self.state.task_pr_branch().is_none()
4393 && matches!(&fetched_branch, Ok(o) if o.ok())
4394 {
4395 git::rev_parse(&repo, &branch_tracking).await.ok()
4396 } else {
4397 None
4398 };
4399 if let Some(theirs) = &remote_tip
4403 && !git::is_ancestor(&repo, theirs, &head).await
4404 && !crate::reconcile::origin_missing(&repo, &head, theirs)
4405 .await
4406 .is_ok_and(|missing| missing.is_empty())
4407 {
4408 let why = format!(
4409 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4410 them",
4411 short(theirs),
4412 winner.branch
4413 );
4414 self.state.status = RunStatus::Blocked;
4415 self.state.base_sync = Some(BaseSync {
4416 tip,
4417 behind,
4418 attempts,
4419 conflict: Some(why.clone()),
4420 already_in: None,
4421 });
4422 self.state.event("land", why);
4423 self.state.save()?;
4424 return Ok(());
4425 }
4426
4427 let scratch = self.state.dir().join("base-sync");
4428 let rebased = match crate::rebase::rebase_with_fixer(
4429 &mut self.state,
4430 &scratch,
4431 &winner.branch,
4432 &tracking,
4433 )
4434 .await
4435 {
4436 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4437 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4438 Err(e) => Err(e),
4439 };
4440 let attempts = attempts + 1;
4441 match rebased {
4442 Ok(None) => {
4443 git::sync_to_head(&winner.worktree).await?;
4447 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4448 let mut conflict = None;
4449 if let Some(pinned) = &remote_tip {
4450 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4451 match pushed {
4452 Ok(o) if o.ok() => self.state.event(
4453 "land",
4454 format!("pushed rebased {} to {remote}", winner.branch),
4455 ),
4456 Ok(o) => {
4457 conflict = Some(format!(
4458 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4459 winner.branch,
4460 short(pinned),
4461 o.stderr.chars().take(600).collect::<String>()
4462 ));
4463 }
4464 Err(e) => {
4465 conflict = Some(format!(
4466 "rebased {} locally but could not push it: {e:#}",
4467 winner.branch
4468 ));
4469 }
4470 }
4471 }
4472 if let Some(why) = &conflict {
4473 self.state.status = RunStatus::Blocked;
4474 self.state.event("land", why.clone());
4475 }
4476 self.state.base_sync = Some(BaseSync {
4477 tip: tip.clone(),
4478 behind: 0,
4479 attempts,
4480 conflict,
4481 already_in: None,
4482 });
4483 self.state
4484 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4485 }
4486 Ok(Some(conflict)) => {
4487 let why = format!(
4488 "{} conflicts with {tracking} and did not rebase: {}",
4489 winner.branch,
4490 conflict.chars().take(600).collect::<String>()
4491 );
4492 self.state.status = RunStatus::Blocked;
4493 self.state.base_sync = Some(BaseSync {
4494 tip,
4495 behind,
4496 attempts,
4497 conflict: Some(why.clone()),
4498 already_in: None,
4499 });
4500 self.state.event("land", why);
4501 }
4502 Err(e) => {
4503 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4504 self.state.status = RunStatus::Blocked;
4505 self.state.base_sync = Some(BaseSync {
4506 tip,
4507 behind,
4508 attempts,
4509 conflict: Some(why.clone()),
4510 already_in: None,
4511 });
4512 self.state.event("land", why);
4513 }
4514 }
4515 self.state.save()?;
4516 Ok(())
4517 }
4518
4519 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4525 if self.state.task_pr_branch().is_some() {
4528 return None;
4529 }
4530 let repo = self.state.repo.clone();
4531 let remote = self.state.config.merge.remote.clone();
4532 let fetched = git::fetch(&repo, &remote, branch).await;
4533 if !matches!(&fetched, Ok(o) if o.ok()) {
4534 return None;
4535 }
4536 let branch_tracking = format!("{remote}/{branch}");
4537 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4538 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4539 return None;
4540 }
4541 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4542 .await
4543 .is_ok_and(|missing| missing.is_empty())
4544 {
4545 return Some(format!(
4546 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4547 them",
4548 short(&theirs)
4549 ));
4550 }
4551 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4552 Ok(o) if o.ok() => {
4553 self.state
4554 .event("land", format!("pushed rebased {branch} to {remote}"));
4555 None
4556 }
4557 Ok(o) => Some(format!(
4558 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4559 someone may have pushed): {}",
4560 short(&theirs),
4561 o.stderr.chars().take(600).collect::<String>()
4562 )),
4563 Err(e) => Some(format!(
4564 "{branch} is rebased locally but could not be pushed: {e:#}"
4565 )),
4566 }
4567 }
4568
4569 async fn settle_already_in(
4586 &mut self,
4587 branch: &str,
4588 tip: &str,
4589 head: &str,
4590 attempts: usize,
4591 behind: usize,
4592 ) -> Result<bool> {
4593 let repo = self.state.repo.clone();
4594 let remote = self.state.config.merge.remote.clone();
4595 let start = self.state.base_commit.clone();
4596 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4597 Ok(Some(e)) => e,
4598 Ok(None) => return Ok(false),
4599 Err(e) => {
4600 tracing::warn!("already-in-base check for {branch}: {e:#}");
4601 return Ok(false);
4602 }
4603 };
4604 let mut verified = vec![head.to_owned()];
4605 let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4606 let fetched = git::fetch(&repo, &remote, &public_branch).await;
4607 if matches!(&fetched, Ok(o) if o.ok())
4608 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4609 && theirs != head
4610 {
4611 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4612 Ok(Some(_)) => verified.push(theirs),
4613 _ => return Ok(false),
4614 }
4615 }
4616 let base_branch = self.state.base_branch.clone();
4617 let message = format!(
4618 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4619 land",
4620 evidence.names(),
4621 evidence.proof.as_str()
4622 );
4623 let closed =
4624 crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4625 .await;
4626 match closed {
4627 Ok(Ok(url)) => self
4628 .state
4629 .event("land", format!("closed {url}: superseded on {base_branch}")),
4630 Ok(Err(why)) => self
4631 .state
4632 .event("land", format!("did not close a pull request: {why}")),
4633 Err(e) => {
4634 let why = format!(
4635 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4636 not be closed ({e:#}); resume to retry"
4637 );
4638 self.state.status = RunStatus::Blocked;
4639 self.state.base_sync = Some(BaseSync {
4640 tip: tip.to_owned(),
4641 behind,
4642 attempts,
4643 conflict: Some(why.clone()),
4644 already_in: None,
4645 });
4646 self.state.event("land", why);
4647 self.state.save()?;
4648 return Ok(true);
4649 }
4650 }
4651 self.state.status = RunStatus::AlreadyInBase;
4652 self.state.base_sync = Some(BaseSync {
4653 tip: tip.to_owned(),
4654 behind,
4655 attempts,
4656 conflict: None,
4657 already_in: Some(evidence),
4658 });
4659 self.state.event("land", message);
4660 self.state.save()?;
4661 self.settle_questions();
4662 Ok(true)
4663 }
4664
4665 fn landing_base(&self) -> String {
4675 self.state
4676 .base_sync
4677 .as_ref()
4678 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4679 }
4680
4681 pub async fn fix_selected(
4714 &mut self,
4715 ids: &[String],
4716 reason: &str,
4717 allow_stale: bool,
4718 ) -> Result<()> {
4719 let reason = reason.trim();
4720 if reason.is_empty() {
4721 bail!("a fix request needs a reason — that is the operator's own record of why");
4722 }
4723 if ids.is_empty() {
4724 bail!("no finding id given");
4725 }
4726 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4727 bail!(
4728 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4729 has already concluded — can be given a targeted fix. A run still \
4730 in progress should simply be resumed; a `merged` run's branch has \
4731 already landed, so its answer is a fresh `magi review <branch>`, \
4732 not reopening this run's own record",
4733 self.state.id,
4734 self.state.status.as_str()
4735 );
4736 }
4737 let Some(winner) = self.state.winner().cloned() else {
4738 bail!("run {} has no winning candidate to fix", self.state.id);
4739 };
4740 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4741 bail!(
4742 "branch `{}` no longer exists; this run cannot be extended",
4743 winner.branch
4744 );
4745 }
4746 let home = crate::run::home();
4747 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4748 bail!(
4749 "run {} is currently being worked on by another magi process",
4750 self.state.id
4751 );
4752 }
4753 let _claim = FixClaim::acquire(&self.state.dir())?;
4759
4760 let mut seen = BTreeSet::new();
4764 let mut findings = Vec::new();
4765 let mut missing = Vec::new();
4766 for id in ids {
4767 if !seen.insert(id.clone()) {
4768 continue;
4769 }
4770 match self.state.finding(id) {
4771 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4772 id: f.id.clone(),
4773 severity: f.severity,
4774 reviewer_vote: rec.vote,
4775 round: round.round,
4776 round_head: round.head.clone(),
4777 reviewer: rec.reviewer,
4778 agent: rec.agent.clone(),
4779 file: f.file.clone(),
4780 line: f.line,
4781 title: f.title.clone(),
4782 detail: f.detail.clone(),
4783 outcome: OperatorFixOutcome::Pending,
4784 }),
4785 None => missing.push(id.clone()),
4786 }
4787 }
4788 if !missing.is_empty() {
4789 bail!(
4790 "unknown finding id(s): {}; nothing was changed",
4791 missing.join(", ")
4792 );
4793 }
4794
4795 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4796 let stale_details: Vec<(String, String)> = findings
4797 .iter()
4798 .filter(|f| f.round_head != head_at_request)
4799 .map(|f| (f.id.clone(), f.round_head.clone()))
4800 .collect();
4801 let stale = !stale_details.is_empty();
4802 if stale && !allow_stale {
4803 bail!(
4804 "the branch has moved since some finding(s) were raised — {} — now \
4805 at {}; pass --allow-stale to fix anyway, or re-run review first",
4806 stale_details
4807 .iter()
4808 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4809 .collect::<Vec<_>>()
4810 .join(", "),
4811 short(&head_at_request)
4812 );
4813 }
4814
4815 let request = OperatorFixRequest {
4816 requested_at: Timestamp::now(),
4817 reason: reason.to_owned(),
4818 findings,
4819 head_at_request: head_at_request.clone(),
4820 allow_stale,
4821 stale,
4822 fix: None,
4823 result_head: None,
4824 follow_up_review_run: None,
4825 };
4826 self.state.event(
4827 "fix",
4828 format!(
4829 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4830 request.findings.len(),
4831 request
4832 .findings
4833 .iter()
4834 .map(|f| f.id.as_str())
4835 .collect::<Vec<_>>()
4836 .join(", "),
4837 ),
4838 );
4839 self.state.operator_fixes.push(request);
4846 self.state.save()?;
4847 let request_index = self.state.operator_fixes.len() - 1;
4848
4849 if winner.worktree.exists() {
4858 let dirty = git::git(
4861 &winner.worktree,
4862 &["status", "--porcelain", "--untracked-files=all"],
4863 )
4864 .await?;
4865 let only_withheld = dirty.lines().all(|l| {
4866 l.strip_prefix("?? ")
4867 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4868 });
4869 if !only_withheld {
4870 bail!(
4871 "`{}` has uncommitted changes; refusing to touch it — commit or \
4872 discard them first",
4873 winner.worktree.display()
4874 );
4875 }
4876 git::worktree_remove(&self.state.repo, &winner.worktree)
4877 .await
4878 .ok();
4879 }
4880 let fix_worktree = self.state.worktree_root().join("operator-fix");
4881 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4882 git::git(
4883 &self.state.repo,
4884 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4885 )
4886 .await
4887 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4888 if !git::is_clean(&fix_worktree).await? {
4889 git::worktree_remove(&self.state.repo, &fix_worktree)
4890 .await
4891 .ok();
4892 bail!(
4893 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4894 winner.branch
4895 );
4896 }
4897 let repo_for_setup = self.state.repo.clone();
4898 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4899 Ok(()) => {
4900 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4901 }
4902 Err(e) => Err(e),
4903 };
4904 if let Err(e) = setup {
4905 git::worktree_remove(&self.state.repo, &fix_worktree)
4906 .await
4907 .ok();
4908 return Err(e);
4909 }
4910
4911 let run_id = self.state.id.clone();
4912 let prompts = self.state.config.prompts.clone();
4913 let language = self.state.config.graph.language.clone();
4914 let sessions = self.state.config.graph.sessions;
4915 let artifacts = agent::artifacts_dir(&self.state.dir());
4916 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4917 .findings
4918 .iter()
4919 .map(|f| Finding {
4920 id: f.id.clone(),
4921 severity: f.severity,
4922 file: f.file.clone(),
4923 line: f.line,
4924 title: f.title.clone(),
4925 detail: f.detail.clone(),
4926 })
4927 .collect();
4928 let fix_prompt = prompt::operator_fix_in(
4929 &self.state.instruction,
4930 &finding_list,
4931 reason,
4932 &stale_details,
4933 &head_at_request,
4934 &language,
4935 &self.state.config.graph.github_language,
4936 );
4937 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4938 let (job, seat, out) = self
4939 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4940 prompt: fix_prompt.clone(),
4941 spec,
4942 seat,
4943 cwd: fix_worktree.clone(),
4944 timeout,
4945 allow_write: true,
4946 sessions,
4947 artifacts: artifacts.clone(),
4948 stem: "operator-fix".to_owned(),
4949 handover: None,
4950 })
4951 .await;
4952 let agent_id = seat.agent.clone();
4953
4954 let mut fix = FixRecord {
4955 agent: agent_id,
4956 addressed: Vec::new(),
4957 rejected: Vec::new(),
4958 notes: String::new(),
4959 committed: false,
4960 failed: None,
4961 duration_ms: 0,
4962 continuation: None,
4963 };
4964 let mut final_seat = seat.clone();
4965 match out {
4966 AgentOutcome::Ok(o) => {
4967 fix.duration_ms = o.duration_ms;
4968 let parsed = verdict::extract_json::<FixReport>(&o.text);
4969 let incomplete_reason = match &parsed {
4970 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4971 "the reply parsed, but it reported a command whose own CLI \
4972 never confirmed an exit status"
4973 .to_owned(),
4974 ),
4975 Ok(_) => None,
4976 Err(e) => Some(e.to_string()),
4977 };
4978 match incomplete_reason {
4979 None => {
4980 let report = parsed.expect("checked Ok above");
4981 fix.addressed = report.addressed;
4982 fix.rejected = report.rejected;
4983 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4984 }
4985 Some(reason) => {
4986 let (resumed_seat, resolved, failure, cont) = self
4987 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4988 .await;
4989 fix.duration_ms += cont.cumulative_wait_ms;
4990 fix.continuation = Some(cont);
4991 final_seat = resumed_seat;
4992 match resolved {
4993 Some(report) => {
4994 fix.addressed = report.addressed;
4995 fix.rejected = report.rejected;
4996 fix.notes =
4997 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4998 }
4999 None => fix.failed = failure,
5000 }
5001 }
5002 }
5003 }
5004 AgentOutcome::Dropped(o) => {
5005 fix.duration_ms = o.duration_ms;
5006 let why = o
5007 .dropped
5008 .as_ref()
5009 .map(|d| d.why.as_str())
5010 .unwrap_or("the CLI ended the stream without delivering its answer");
5011 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5012 }
5013 AgentOutcome::Quota(o) => {
5014 self.state.quota.push(QuotaLoss {
5015 seat: final_seat.key.clone(),
5016 node: "fix".to_owned(),
5017 at: Timestamp::now(),
5018 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5019 });
5020 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5021 }
5022 AgentOutcome::Failed(e) => fix.failed = Some(e),
5023 AgentOutcome::ResourceBlocked(e) => {
5024 fix.failed = Some(format!("the fixer was not started: {e}"));
5025 }
5026 }
5027 if fix.continuation.is_none() {
5028 fix.continuation = Some(ContinuationRecord::not_needed());
5029 }
5030 self.state.seats.insert(final_seat.key.clone(), final_seat);
5031
5032 let rescue_message = format!(
5033 "magi: operator-selected fix ({}) (uncommitted work)",
5034 self.state.operator_fixes[request_index]
5035 .findings
5036 .iter()
5037 .map(|f| f.id.as_str())
5038 .collect::<Vec<_>>()
5039 .join(", ")
5040 );
5041 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5042 self.state.note_withheld("fix", &r.withheld);
5043 }
5044 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5045 fix.committed = after != head_at_request;
5046 git::worktree_remove(&self.state.repo, &fix_worktree)
5047 .await
5048 .ok();
5049
5050 self.state.event(
5051 "fix",
5052 match &fix.failed {
5053 Some(reason) => format!(
5054 "operator fix: adoption report was lost ({reason}); {}",
5055 if fix.committed {
5056 "committed"
5057 } else {
5058 "NO new commit"
5059 }
5060 ),
5061 None => format!(
5062 "operator fix: {} addressed, {} rejected, {}",
5063 fix.addressed.len(),
5064 fix.rejected.len(),
5065 if fix.committed {
5066 "committed"
5067 } else {
5068 "NO new commit"
5069 }
5070 ),
5071 },
5072 );
5073
5074 for f in &mut self.state.operator_fixes[request_index].findings {
5081 f.outcome = if fix.failed.is_some() {
5082 OperatorFixOutcome::Unreported
5083 } else if fix.addressed.contains(&f.id) {
5084 OperatorFixOutcome::Addressed
5085 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5086 OperatorFixOutcome::Rejected { why: r.why.clone() }
5087 } else {
5088 OperatorFixOutcome::Unreported
5089 };
5090 }
5091
5092 let committed = fix.committed;
5093 if committed {
5094 self.state.operator_fixes[request_index].result_head = Some(after.clone());
5095 }
5096 self.state.operator_fixes[request_index].fix = Some(fix);
5097 self.state.save()?;
5100
5101 if committed {
5102 self.state.event(
5103 "fix",
5104 format!(
5105 "operator fix committed {}; opening a follow-up review-only run",
5106 short(&after)
5107 ),
5108 );
5109 let origin =
5112 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5113 match Self::review(
5114 &self.state.repo,
5115 &winner.branch,
5116 self.state.config.clone(),
5117 origin,
5118 )
5119 .await
5120 {
5121 Ok(mut follow_up) => {
5122 follow_up.state.event(
5123 "start",
5124 format!(
5125 "requested by an operator fix on run {} for finding(s) {}",
5126 self.state.id,
5127 self.state.operator_fixes[request_index]
5128 .findings
5129 .iter()
5130 .map(|f| f.id.as_str())
5131 .collect::<Vec<_>>()
5132 .join(", "),
5133 ),
5134 );
5135 follow_up.state.save()?;
5136 let follow_up_id = follow_up.state.id.clone();
5137 let adopted = match crate::direct::adopt(
5140 &follow_up.state,
5141 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5142 ) {
5143 Ok(a) => a,
5144 Err(e) => {
5145 self.state.event(
5148 "fix",
5149 format!(
5150 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5151 ),
5152 );
5153 self.state.save()?;
5154 return Ok(());
5155 }
5156 };
5157 let executed = follow_up.execute().await;
5158 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5159 if let Some(a) = adopted {
5160 a.finish(&follow_up.state, executed);
5161 }
5162 if let Some(e) = failure {
5163 self.state.event(
5164 "fix",
5165 format!(
5166 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5167 ),
5168 );
5169 }
5170 self.state.operator_fixes[request_index].follow_up_review_run =
5171 Some(follow_up_id);
5172 }
5173 Err(e) => {
5174 self.state.event(
5175 "fix",
5176 format!("committed the fix but could not open a follow-up review: {e:#}"),
5177 );
5178 }
5179 }
5180 self.state.save()?;
5181 }
5182
5183 Ok(())
5184 }
5185
5186 async fn ask_fixer(
5206 &mut self,
5207 winner: &Candidate,
5208 node: &'static str,
5209 round: Option<usize>,
5210 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5211 ) -> (SeatJob, SeatState, AgentOutcome) {
5212 let attempts = fixer::attempts(&self.state, &self.roles, winner);
5213 self.ask_fixer_among(attempts, node, round, None, build)
5214 .await
5215 }
5216
5217 async fn ask_fixer_among(
5222 &mut self,
5223 attempts: Vec<(AgentSpec, String)>,
5224 node: &'static str,
5225 round: Option<usize>,
5226 restore: Option<(&Path, &str)>,
5227 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5228 ) -> (SeatJob, SeatState, AgentOutcome) {
5229 let run_id = self.state.id.clone();
5230 let prompts = self.state.config.prompts.clone();
5231 let cache = self.state.config.cache_dir();
5232 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5233 let mut last = None;
5234 for (i, (spec, key)) in attempts.into_iter().enumerate() {
5235 let seat = self.seat(&key, &spec.id);
5236 let mut job = build(spec.clone(), seat);
5237 if i > 0 {
5238 job.stem = format!("{}-{}", job.stem, spec.id);
5239 }
5240 let ctx = WaveCtx {
5241 carry_seats: false,
5242 pauses: Some((&self.pause, &self.interrupt)),
5243 run: &run_id,
5244 node,
5245 prompts: &prompts,
5246 cache: cache.as_deref(),
5247 round,
5248 };
5249 let (seat, out) =
5250 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5251 let advances = match &out {
5254 AgentOutcome::Ok(o) => agent::output_advances(o),
5255 AgentOutcome::ResourceBlocked(_) => false,
5256 _ => true,
5257 };
5258 if let Some(next) = ids.get(i + 1)
5259 && advances
5260 {
5261 self.state.seats.insert(seat.key.clone(), seat.clone());
5262 let class =
5263 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5264 let reason = match &out {
5265 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5266 other => fail_reason(other),
5267 };
5268 record_handover(
5269 &mut self.state,
5270 node,
5271 &seat.key,
5272 &spec.id,
5273 next,
5274 &class,
5275 &reason,
5276 );
5277 if let Some((wt, rev)) = restore
5281 && let Err(e) = git::restore_to(wt, rev).await
5282 {
5283 let why =
5284 format!("could not restore the worktree before the next fixer: {e:#}");
5285 tracing::warn!("{why}");
5286 last = Some((job, seat, AgentOutcome::Failed(why)));
5287 break;
5288 }
5289 continue;
5290 }
5291 last = Some((job, seat, out));
5292 break;
5293 }
5294 last.expect("the fixer chain always has an entry")
5295 }
5296
5297 async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5308 use crate::recovery::{Mode, Repair};
5309 if !self.state.reviews.is_empty() {
5310 return Ok(());
5311 }
5312 let Some(mut rec) = self.state.recovery.clone() else {
5313 return Ok(());
5314 };
5315 if rec.mode != Mode::VerifyRepair {
5316 return Ok(());
5317 }
5318 let wt = winner.worktree.clone();
5319 let head = git::rev_parse(&wt, "HEAD").await?;
5320 match rec.repair.clone() {
5321 Repair::Done { .. } => return Ok(()),
5322 Repair::Started { before } => {
5323 if let Err(e) = Self::graft_restore(&wt, &before).await {
5327 return self
5328 .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5329 .await;
5330 }
5331 rec.repair = Repair::Done {
5332 before: before.clone(),
5333 after: before,
5334 committed: false,
5335 failed: Some("interrupted; rolled back".to_owned()),
5336 };
5337 self.state
5338 .event("recovery", "the repair was interrupted and rolled back");
5339 self.state.recovery = Some(rec);
5340 self.state.save()?;
5341 return Ok(());
5342 }
5343 Repair::NotStarted => {}
5344 }
5345 if crate::recovery::recheck(&mut rec, &head) {
5346 self.state.event("recovery", rec.summary());
5347 self.state.recovery = Some(rec);
5348 self.state.save()?;
5349 return Ok(());
5350 }
5351 rec.repair = Repair::Started {
5352 before: head.clone(),
5353 };
5354 self.state.recovery = Some(rec.clone());
5355 self.state.save()?;
5356
5357 let language = self.state.config.graph.language.clone();
5358 let sessions = self.state.config.graph.sessions;
5359 let artifacts = agent::artifacts_dir(&self.state.dir());
5360 let fix_prompt = prompt::recovery_fix(
5361 &self.state.instruction,
5362 &rec,
5363 self.state.config.graph.review_rounds,
5364 &language,
5365 );
5366 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5367 self.state.event(
5368 "recovery",
5369 format!(
5370 "repair: the fixer starts from the failed verification on {}",
5371 short(&head)
5372 ),
5373 );
5374 let (_job, seat, out) = self
5375 .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5376 prompt: fix_prompt.clone(),
5377 spec,
5378 seat,
5379 cwd: wt.clone(),
5380 timeout,
5381 allow_write: true,
5382 sessions,
5383 artifacts: artifacts.clone(),
5384 stem: "fix-recovery".to_owned(),
5385 handover: None,
5386 })
5387 .await;
5388 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5391 if let Err(e) = Self::graft_restore(&wt, &head).await {
5392 return self
5393 .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5394 .await;
5395 }
5396 rec.repair = Repair::NotStarted;
5397 self.state.recovery = Some(rec);
5398 self.state.save()?;
5399 return self.stop_for_resource();
5400 }
5401 let failed = match &out {
5402 AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5403 AgentOutcome::Quota(o) => {
5404 self.state.quota.push(QuotaLoss {
5405 seat: seat.key.clone(),
5406 node: "fix".to_owned(),
5407 at: Timestamp::now(),
5408 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5409 });
5410 Some("rate limited (quota); the fixer could not run".to_owned())
5411 }
5412 AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5413 AgentOutcome::Failed(e) => Some(e.clone()),
5414 };
5415 self.state.seats.insert(seat.key.clone(), seat);
5416 let failed = match failed {
5417 Some(why) => {
5419 if let Err(e) = git::restore_to(&wt, &head).await {
5420 return self
5421 .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5422 .await;
5423 }
5424 Some(why)
5425 }
5426 None => {
5427 if let Ok(r) = git::rescue_commit(
5428 &wt,
5429 "magi: recovery repair of the failed verification (uncommitted work)",
5430 )
5431 .await
5432 {
5433 self.state.note_withheld("fix", &r.withheld);
5434 }
5435 None
5436 }
5437 };
5438 let after = git::rev_parse(&wt, "HEAD").await?;
5439 self.state.event(
5440 "recovery",
5441 match &failed {
5442 Some(why) => format!("repair: nothing kept ({why})"),
5443 None if after != head => {
5444 format!("repair: committed {}", short(&after))
5445 }
5446 None => "repair: the fixer made no commit".to_owned(),
5447 },
5448 );
5449 rec.repair = Repair::Done {
5450 before: head.clone(),
5451 committed: after != head,
5452 after,
5453 failed,
5454 };
5455 self.state.recovery = Some(rec);
5456 self.state.save()?;
5457 Ok(())
5458 }
5459
5460 async fn stop_recovery(
5463 &mut self,
5464 mut rec: crate::recovery::RecoveryRecord,
5465 head: &str,
5466 why: String,
5467 ) -> Result<()> {
5468 rec.repair = crate::recovery::Repair::Started {
5470 before: head.to_owned(),
5471 };
5472 self.state.recovery = Some(rec);
5473 self.state
5474 .event("recovery", format!("repair could not be undone: {why}"));
5475 self.state.status = RunStatus::Blocked;
5476 self.state.save()?;
5477 bail!("recovery repair could not be rolled back: {why}")
5478 }
5479
5480 async fn review_loop(&mut self) -> Result<()> {
5481 if self
5486 .state
5487 .base_sync
5488 .as_ref()
5489 .is_some_and(|s| s.conflict.is_some())
5490 {
5491 return Ok(());
5492 }
5493 let run_id = self.state.id.clone();
5498 let prompts = self.state.config.prompts.clone();
5499 let Some(winner) = self.state.winner().cloned() else {
5500 return Ok(());
5501 };
5502 let max_rounds = self.state.config.graph.review_rounds;
5503 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5513 if status == RunStatus::Gating {
5516 self.record_contested_handoff();
5517 }
5518 self.state.status = status;
5519 self.state.save()?;
5520 return Ok(());
5521 }
5522 self.state.status = RunStatus::Reviewing;
5523 if self
5533 .state
5534 .reviews
5535 .last()
5536 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5537 {
5538 let shell = self.state.config.shell();
5539 return self
5540 .stop_reviewing(
5541 "the last round's own verification never resolved",
5542 &shell,
5543 &winner.worktree,
5544 )
5545 .await;
5546 }
5547
5548 self.recovery_repair(&winner).await?;
5549 if self.resource_halt()? {
5550 return Ok(());
5551 }
5552
5553 let repo = self.state.repo.clone();
5554 let root = self.state.worktree_root();
5555 let language = self.state.config.graph.language.clone();
5556 let sessions = self.state.config.graph.sessions;
5557 let artifacts = self.merge_repair_artifacts();
5558 let base = self.landing_base();
5559 let base_short = short(&base);
5560 let reviewers = self.roles.reviewers.clone();
5561 let shell = self.state.config.shell();
5562
5563 for round in (self.state.reviews.len() + 1)..=max_rounds {
5564 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5565 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5566 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5567 let prev_verification = self
5576 .state
5577 .reviews
5578 .last()
5579 .and_then(|r| r.verification_summary(&head));
5580 let prior_section = match (&self.state.recovery, round) {
5583 (Some(rec), 1) if self.state.reviews.is_empty() => {
5584 let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5585 (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5586 Some(crate::recovery::head_of(
5587 &git::diff(&winner.worktree, reviewed, "HEAD")
5588 .await
5589 .unwrap_or_default(),
5590 crate::recovery::SINCE_DIFF_MAX,
5591 ))
5592 }
5593 _ => None,
5594 };
5595 Some(prompt::recovery_review(rec, since.as_deref()))
5596 }
5597 _ => None,
5598 };
5599
5600 let mut jobs = Vec::new();
5604 for (r, spec) in reviewers.iter().cloned().enumerate() {
5605 let wt = root.join(format!("review-{}", r + 1));
5606 if wt.exists() {
5609 git::reset_detached(&wt, &head).await?;
5610 } else {
5611 git::worktree_add_detached(&repo, &wt, &head).await?;
5612 }
5613 self.setup_seat_worktree(&repo, &wt, false).await?;
5614 let seat_key = format!("review-{}", r + 1);
5615 let spec = pick_start_spec(
5618 &self.roles.reviewer_roster,
5619 spec,
5620 self.state.seat_history.get(&seat_key),
5621 );
5622 let seat = self.seat(&seat_key, &spec.id);
5623 jobs.push(SeatJob {
5624 prompt: prompt::review(&prompt::ReviewCtx {
5625 instruction: &self.state.instruction,
5626 branch: &winner.branch,
5627 base_short: &base_short,
5628 stat: &stat,
5629 patch: &patch,
5630 verification: prev_verification.as_ref(),
5631 prior: prior_section.as_deref(),
5632 reviewers: reviewers.len(),
5633 round,
5634 rounds: max_rounds,
5635 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5638 lens: Lens::for_seat(r),
5639 language: &language,
5640 github_language: &self.state.config.graph.github_language,
5641 }),
5642 spec,
5643 seat,
5644 cwd: wt,
5645 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5646 allow_write: false,
5647 sessions,
5648 artifacts: artifacts.clone(),
5649 stem: format!("review-{round}-{}", r + 1),
5650 handover: None,
5651 });
5652 }
5653
5654 self.state.event(
5655 "review",
5656 format!(
5657 "round {round}: {} reviewers on {}",
5658 jobs.len(),
5659 short(&head)
5660 ),
5661 );
5662 let mut quota_losses = Vec::new();
5663 let review_retries = self.state.config.graph.retries;
5664 let review_cache = self.state.config.cache_dir();
5665 let ctx = WaveCtx {
5666 carry_seats: true,
5667 pauses: Some((&self.pause, &self.interrupt)),
5668 run: &run_id,
5669 node: "review",
5670 prompts: &prompts,
5671 cache: review_cache.as_deref(),
5672 round: Some(round),
5673 };
5674 let results = ask_json_wave::<Review>(
5675 jobs,
5676 Arc::clone(&self.sem),
5677 review_retries,
5678 &self.roles.reviewer_roster,
5679 &ctx,
5680 &mut quota_losses,
5681 &mut self.state,
5682 &|_: &Review| Ok(()),
5683 )
5684 .await;
5685 if self.state.resource_block.is_some() {
5686 return self.stop_for_resource();
5687 }
5688 let round_quota_missing = quota_losses.len();
5692 self.state.quota.extend(quota_losses);
5693
5694 let mut records = Vec::new();
5695 let mut all_findings = Vec::new();
5696 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5697 let agent_id = seat.agent.clone();
5698 self.state.seats.insert(seat.key.clone(), seat);
5699 let mut record = ReviewRecord {
5700 reviewer: r + 1,
5701 agent: agent_id,
5702 summary: String::new(),
5703 findings: Vec::new(),
5704 vote: None,
5705 failed: None,
5706 duration_ms: 0,
5707 attempts,
5713 };
5714 match res {
5715 Ok((review, out)) => {
5716 record.summary =
5724 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5725 record.vote = Some(review.vote);
5726 record.duration_ms = out.duration_ms;
5727 for (n, mut f) in review.findings.into_iter().enumerate() {
5728 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5731 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5732 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5733 f.file = f
5739 .file
5740 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5741 all_findings.push(f.clone());
5742 record.findings.push(f);
5743 }
5744 self.state.event(
5745 "review",
5746 format!(
5747 "round {round}: reviewer {} voted {} with {} finding(s)",
5748 r + 1,
5749 review.vote.label(),
5750 record.findings.len()
5751 ),
5752 );
5753 }
5754 Err(e) => {
5755 record.failed = Some(e.to_string());
5756 self.state.event(
5757 "review",
5758 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5759 );
5760 }
5761 }
5762 records.push(record);
5763 }
5764
5765 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5772 let vote_split =
5773 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5774 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5775 if vote_split {
5776 self.state.event(
5777 "review",
5778 format!(
5779 "round {round}: votes split ({}) — one round of reconsideration",
5780 initial_votes
5781 .iter()
5782 .map(|v| v.label())
5783 .collect::<Vec<_>>()
5784 .join(", ")
5785 ),
5786 );
5787 let panel: Vec<ReviewSeatReport<'_>> = records
5790 .iter()
5791 .filter_map(|r| {
5792 r.vote.map(|vote| ReviewSeatReport {
5793 reviewer: r.reviewer,
5794 vote,
5795 summary: &r.summary,
5796 findings: &r.findings,
5797 })
5798 })
5799 .collect();
5800
5801 let mut jobs = Vec::new();
5802 let mut seats_at = Vec::new();
5803 for (r, spec) in reviewers.iter().cloned().enumerate() {
5804 if records[r].vote.is_none() {
5808 continue;
5809 }
5810 let wt = root.join(format!("review-{}", r + 1));
5811 let seat_key = format!("review-{}", r + 1);
5812 let spec = self.occupant(&seat_key, spec);
5813 let seat = self.seat(&seat_key, &spec.id);
5814 let build = |with_patch: bool| {
5822 prompt::review_reconsider(&ReviewReconsiderCtx {
5823 instruction: &self.state.instruction,
5824 reviewer: r + 1,
5825 lens: Lens::for_seat(r),
5826 panel: &panel,
5827 patch: with_patch.then_some(ReviewPatch {
5828 branch: &winner.branch,
5829 base_short: &base_short,
5830 stat: &stat,
5831 patch: &patch,
5832 }),
5833 round,
5834 rounds: max_rounds,
5835 language: &language,
5836 })
5837 };
5838 let full = build(true);
5839 let prompt = if has_context(&spec, &seat, sessions) {
5840 build(false)
5841 } else {
5842 full.clone()
5843 };
5844 jobs.push(SeatJob {
5845 prompt,
5846 spec,
5847 seat,
5848 cwd: wt,
5849 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5850 allow_write: false,
5851 sessions,
5852 artifacts: artifacts.clone(),
5853 stem: format!("review-{round}-reconsider-{}", r + 1),
5854 handover: Some(full),
5855 });
5856 seats_at.push(r);
5857 }
5858
5859 let mut recon_quota_losses = Vec::new();
5860 let recon_cache = self.state.config.cache_dir();
5861 let recon_ctx = WaveCtx {
5862 carry_seats: true,
5863 pauses: Some((&self.pause, &self.interrupt)),
5864 run: &run_id,
5865 node: "review",
5866 prompts: &prompts,
5867 cache: recon_cache.as_deref(),
5868 round: Some(round),
5869 };
5870 let recon_results = ask_json_wave::<ReviewRevote>(
5871 jobs,
5872 Arc::clone(&self.sem),
5873 review_retries,
5874 &self.roles.reviewer_roster,
5875 &recon_ctx,
5876 &mut recon_quota_losses,
5877 &mut self.state,
5878 &|_: &ReviewRevote| Ok(()),
5879 )
5880 .await;
5881 if self.state.resource_block.is_some() {
5882 return self.stop_for_resource();
5883 }
5884 self.state.quota.extend(recon_quota_losses);
5885
5886 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5887 let agent_id = seat.agent.clone();
5888 self.state.seats.insert(seat.key.clone(), seat);
5889 let mut rec = ReviewRevoteRecord {
5890 reviewer: r + 1,
5891 agent: agent_id,
5892 vote: None,
5893 reason: String::new(),
5894 failed: None,
5895 };
5896 match res {
5897 Ok((rv, _)) => {
5898 rec.vote = Some(rv.vote);
5899 rec.reason =
5900 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5901 self.state.event(
5902 "review",
5903 format!(
5904 "round {round}: reviewer {} revoted {}",
5905 r + 1,
5906 rv.vote.label()
5907 ),
5908 );
5909 }
5910 Err(e) => {
5911 rec.failed = Some(e.to_string());
5912 self.state.event(
5913 "review",
5914 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5915 );
5916 }
5917 }
5918 reconsideration.push(rec);
5919 }
5920 } else if initial_votes.len() > 1 {
5921 self.state.event(
5922 "review",
5923 format!(
5924 "round {round}: votes agreed ({}) — no reconsideration",
5925 initial_votes[0].label()
5926 ),
5927 );
5928 }
5929
5930 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5931 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5932 let defer_e2e =
5943 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5944 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5945 let reason =
5946 format!("{blocking} blocking finding(s) already required a fix this round");
5947 self.state.event(
5948 "verify",
5949 format!(
5950 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5951 {}); it will run once a round has none left",
5952 short(&head)
5953 ),
5954 );
5955 (Vec::new(), false, true, Some(reason))
5956 } else {
5957 let e2e_commands = self.state.config.verify.e2e.clone();
5958 let cache_dir = self.state.config.cache_dir();
5959 let context = format!("round {round}");
5960 let (e2e, verify_retried) = with_cache_lease(
5961 &mut self.state,
5962 cache_dir.as_deref(),
5963 "e2e",
5964 "e2e",
5965 &winner.worktree,
5966 &head,
5967 verify_timeout,
5968 &context,
5969 |state, budget| {
5970 let shell = shell.clone();
5971 let e2e_commands = e2e_commands.clone();
5972 let worktree = winner.worktree.clone();
5973 let context = context.clone();
5974 async move {
5975 run_e2e_with_retry(
5976 state,
5977 &shell,
5978 &e2e_commands,
5979 &worktree,
5980 budget,
5981 &context,
5982 )
5983 .await
5984 }
5985 },
5986 )
5987 .await;
5988 (e2e, verify_retried, false, None)
5989 };
5990
5991 let expected = records.len();
5992 let answered = records.iter().filter(|r| r.failed.is_none()).count();
5993 let incomplete = answered < expected;
5994 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5995 let policy = self.state.config.graph.incomplete_review;
5996 let clean = round_is_clean(
5997 blocking,
5998 e2e_ok,
5999 answered,
6000 expected,
6001 round_quota_missing,
6002 policy,
6003 );
6004
6005 let mut round_record = ReviewRound {
6006 round,
6007 head: head.clone(),
6008 verified_head: None,
6009 verified_at: None,
6010 reviews: records,
6011 e2e,
6012 verify_retried,
6013 e2e_deferred,
6014 e2e_defer_reason,
6015 fix: None,
6016 blocking,
6017 answered,
6018 expected,
6019 clean,
6020 progressed: false,
6021 vote_split,
6022 reconsideration,
6023 verdict: None,
6024 };
6025 round_record.verdict = ReviewVote::worst(
6029 round_record
6030 .final_votes()
6031 .into_iter()
6032 .map(|(_, _, vote)| vote),
6033 );
6034 if !matches!(
6045 round_record.e2e_status(),
6046 E2eStatus::Deferred | E2eStatus::NotConfigured
6047 ) {
6048 round_record.verified_head = Some(head.clone());
6049 round_record.verified_at = Some(Timestamp::now());
6050 }
6051 let this_round_verification = round_record.verification_summary(&head);
6052
6053 if incomplete {
6054 let missing: Vec<String> = round_record
6055 .reviews
6056 .iter()
6057 .filter(|r| r.failed.is_some())
6058 .map(|r| format!("review-{}", r.reviewer))
6059 .collect();
6060 self.state.event(
6061 "review",
6062 format!(
6063 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6064 missing.join(", ")
6065 ),
6066 );
6067 }
6068
6069 if clean {
6070 self.state.event(
6071 "review",
6072 if incomplete && policy == IncompleteReviewPolicy::Warn {
6073 format!(
6074 "round {round}: clean (warn policy, incomplete panel) — no \
6075 blocking findings from the seats that answered, verification green"
6076 )
6077 } else if incomplete {
6078 format!(
6079 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6080 quorum) — no blocking findings from the seats that answered, \
6081 verification green",
6082 expected - answered
6083 )
6084 } else {
6085 format!("round {round}: clean — no blocking findings, verification green")
6086 },
6087 );
6088 self.state.reviews.push(round_record);
6089 self.state.status = RunStatus::Gating;
6090 self.state.save()?;
6091 return Ok(());
6092 }
6093
6094 if incomplete && blocking == 0 && e2e_ok {
6102 self.state.reviews.push(round_record);
6103 self.state.save()?;
6104 if round == max_rounds {
6105 self.state.status = RunStatus::Blocked;
6106 self.state.event(
6107 "review",
6108 format!(
6109 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6110 refusing to call it clean",
6111 expected - answered
6112 ),
6113 );
6114 return Ok(());
6115 }
6116 continue;
6117 }
6118
6119 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6131 self.state.reviews.push(round_record);
6132 return self
6133 .stop_reviewing(
6134 "the round's own verification could not run",
6135 &shell,
6136 &winner.worktree,
6137 )
6138 .await;
6139 }
6140
6141 if round == max_rounds {
6142 self.state.reviews.push(round_record);
6143 return self
6144 .stop_reviewing(
6145 &format!(
6146 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6147 ),
6148 &shell,
6149 &winner.worktree,
6150 )
6151 .await;
6152 }
6153
6154 let blocking_findings: Vec<_> = all_findings
6157 .iter()
6158 .filter(|f| f.severity.blocks())
6159 .cloned()
6160 .collect();
6161 let fix_prompt = prompt::fix_in(
6162 &self.state.instruction,
6163 &blocking_findings,
6164 this_round_verification.as_ref(),
6165 round,
6166 max_rounds,
6167 &language,
6168 &self.state.config.graph.github_language,
6169 );
6170 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6171 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6172 let (job, seat, out) = self
6173 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6174 prompt: fix_prompt.clone(),
6175 spec,
6176 seat,
6177 cwd: winner.worktree.clone(),
6178 timeout,
6179 allow_write: true,
6180 sessions,
6181 artifacts: artifacts.clone(),
6182 stem: format!("fix-{round}"),
6183 handover: None,
6184 })
6185 .await;
6186 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6189 return self.stop_for_resource();
6190 }
6191 let agent_id = seat.agent.clone();
6192
6193 let mut fix = FixRecord {
6194 agent: agent_id,
6195 addressed: Vec::new(),
6196 rejected: Vec::new(),
6197 notes: String::new(),
6198 committed: false,
6199 failed: None,
6200 duration_ms: 0,
6201 continuation: None,
6202 };
6203 let mut continuation = ContinuationRecord::not_needed();
6204 let mut final_seat = seat.clone();
6205 match out {
6206 AgentOutcome::Ok(o) => {
6207 fix.duration_ms = o.duration_ms;
6208 let parsed = verdict::extract_json::<FixReport>(&o.text);
6209 let incomplete_reason = match &parsed {
6216 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6217 "the reply parsed, but it reported a command whose own CLI never \
6218 confirmed an exit status"
6219 .to_owned(),
6220 ),
6221 Ok(_) => None,
6222 Err(e) => Some(e.to_string()),
6223 };
6224 match incomplete_reason {
6225 None => {
6226 let report = parsed.expect("checked Ok above");
6227 fix.addressed = report.addressed;
6228 fix.rejected = report.rejected;
6229 fix.notes =
6230 blind::sanitize_prose(&report.notes, &self.state.config.blind);
6231 }
6232 Some(reason) => {
6233 let (resumed_seat, resolved, failure, cont) = self
6234 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6235 .await;
6236 fix.duration_ms += cont.cumulative_wait_ms;
6237 continuation = cont;
6238 final_seat = resumed_seat;
6239 match resolved {
6240 Some(report) => {
6241 fix.addressed = report.addressed;
6242 fix.rejected = report.rejected;
6243 fix.notes = blind::sanitize_prose(
6244 &report.notes,
6245 &self.state.config.blind,
6246 );
6247 }
6248 None => fix.failed = failure,
6249 }
6250 }
6251 }
6252 }
6253 AgentOutcome::Dropped(o) => {
6255 fix.duration_ms = o.duration_ms;
6256 let why = o
6257 .dropped
6258 .as_ref()
6259 .map(|d| d.why.as_str())
6260 .unwrap_or("the CLI ended the stream without delivering its answer");
6261 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6262 }
6263 AgentOutcome::Quota(o) => {
6264 self.state.quota.push(QuotaLoss {
6265 seat: final_seat.key.clone(),
6266 node: "fix".to_owned(),
6267 at: Timestamp::now(),
6268 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6269 });
6270 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6271 }
6272 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6273 }
6274 fix.continuation = Some(continuation);
6275 self.state.seats.insert(final_seat.key.clone(), final_seat);
6276 if let Ok(r) = git::rescue_commit(
6277 &winner.worktree,
6278 &format!("magi: review round {round} fixes (uncommitted work)"),
6279 )
6280 .await
6281 {
6282 self.state.note_withheld("fix", &r.withheld);
6283 }
6284 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6285 fix.committed = after != before;
6286 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6294 let progressed = diff_after != patch;
6295 let commit_note = if fix.committed {
6296 "committed"
6297 } else {
6298 "NO new commit"
6299 };
6300 let tree_note = if progressed {
6301 "changed vs base"
6302 } else {
6303 "unchanged vs base"
6304 };
6305 self.state.event(
6306 "fix",
6307 match &fix.failed {
6308 Some(reason) => {
6314 format!(
6315 "round {round}: fixer's adoption report was lost ({reason}); \
6316 {commit_note}, tree {tree_note}"
6317 )
6318 }
6319 None => format!(
6320 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6321 {tree_note}{}",
6322 fix.addressed.len(),
6323 fix.rejected.len(),
6324 if continuation.outcome == ContinuationOutcome::Resumed {
6325 format!(
6326 " (adoption report recovered after {} continuation(s))",
6327 continuation.attempts
6328 )
6329 } else {
6330 String::new()
6331 },
6332 ),
6333 },
6334 );
6335 round_record.fix = Some(fix);
6336 round_record.progressed = progressed;
6337 self.state.reviews.push(round_record);
6338 self.state.save()?;
6339
6340 if matches!(
6353 continuation.outcome,
6354 ContinuationOutcome::Exhausted
6355 | ContinuationOutcome::QuotaLost
6356 | ContinuationOutcome::NoSession
6357 ) {
6358 return self
6359 .stop_reviewing(
6360 "the fixer's adoption report never came back, even after resuming its \
6361 own seat; refusing to start another round against the same worktree \
6362 while that is unresolved",
6363 &shell,
6364 &winner.worktree,
6365 )
6366 .await;
6367 }
6368
6369 let streak = self
6370 .state
6371 .reviews
6372 .iter()
6373 .rev()
6374 .take_while(|r| !r.progressed)
6375 .count();
6376 if streak >= STAGNANT_LIMIT {
6377 return self
6378 .stop_reviewing(
6379 &format!(
6380 "the tree has not moved against base for {streak} round(s) in a row"
6381 ),
6382 &shell,
6383 &winner.worktree,
6384 )
6385 .await;
6386 }
6387 }
6388 Ok(())
6389 }
6390
6391 fn record_contested_handoff(&mut self) {
6425 if self.state.contested_handoff.is_some() {
6426 return;
6427 }
6428 let Some(contested) = self
6429 .state
6430 .reviews
6431 .last()
6432 .and_then(ReviewRound::contested_handoff)
6433 else {
6434 return;
6435 };
6436 self.state.event(
6437 "review",
6438 format!(
6439 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6440 wait for the owner's approval",
6441 contested.findings.len(),
6442 contested.rejecters.len()
6443 ),
6444 );
6445 self.state.contested_handoff = Some(contested);
6446 }
6447
6448 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6449 let round_idx = self.state.reviews.len() - 1;
6450 let needs_catchup_run = matches!(
6458 self.state.reviews[round_idx].e2e_status(),
6459 E2eStatus::Deferred | E2eStatus::ResourceBlocked
6460 );
6461 if needs_catchup_run {
6462 let round = self.state.reviews[round_idx].round;
6463 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6464 let commands = self.state.config.verify.e2e.clone();
6465 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6466 let cache_dir = self.state.config.cache_dir();
6467 let context = format!(
6468 "round {round}: verification unresolved, catching up before the final decision"
6469 );
6470 let (outcomes, verify_retried) = with_cache_lease(
6471 &mut self.state,
6472 cache_dir.as_deref(),
6473 "e2e",
6474 "e2e",
6475 worktree,
6476 &attempted_head,
6477 timeout,
6478 &context,
6479 |state, budget| {
6480 let shell = shell.to_vec();
6481 let commands = commands.clone();
6482 let context = context.clone();
6483 async move {
6484 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6485 .await
6486 }
6487 },
6488 )
6489 .await;
6490 let last = &mut self.state.reviews[round_idx];
6491 last.e2e = outcomes;
6492 last.verify_retried = verify_retried;
6493 last.verified_head = Some(attempted_head);
6500 last.verified_at = Some(Timestamp::now());
6501 if verify_inconclusive(&last.e2e) {
6502 self.state.save()?;
6509 return Ok(());
6510 }
6511 last.e2e_deferred = false;
6512 }
6513 let last = &self.state.reviews[round_idx];
6514 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6515
6516 match last.e2e_status() {
6517 E2eStatus::Failed => {
6518 let red: Vec<String> = last
6519 .e2e
6520 .iter()
6521 .filter(|o| !o.ok())
6522 .map(|o| {
6523 format!(
6524 "`{}` -> {:?}\n{}",
6525 o.command,
6526 o.code,
6527 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6528 )
6529 })
6530 .collect();
6531 self.state
6532 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6533 self.state.status = RunStatus::Blocked;
6534 }
6535 E2eStatus::ResourceBlocked => {
6540 self.state.event(
6541 "review",
6542 format!(
6543 "{why}; e2e could not run (shared build cache unavailable); not \
6544 deciding yet"
6545 ),
6546 );
6547 }
6548 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6549 self.state.event(
6550 "review",
6551 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6552 );
6553 self.record_contested_handoff();
6554 self.state.status = RunStatus::Gating;
6555 }
6556 }
6557 self.state.save()?;
6558 Ok(())
6559 }
6560
6561 async fn gate(&mut self) -> Result<()> {
6564 if self.state.status == RunStatus::Failed
6576 || self
6577 .state
6578 .base_sync
6579 .as_ref()
6580 .is_some_and(|s| s.conflict.is_some())
6581 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6582 != Some(RunStatus::Gating)
6583 {
6584 return Ok(());
6585 }
6586 if self.state.gate_ran {
6587 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6598 self.state.status = RunStatus::Blocked;
6599 self.state.save()?;
6600 }
6601 return Ok(());
6602 }
6603 let Some(winner) = self.state.winner().cloned() else {
6604 return Ok(());
6605 };
6606 self.state.status = RunStatus::Gating;
6607 let mut outcomes = self.run_gate(&winner).await?;
6608 loop {
6609 if verify_inconclusive(&outcomes) {
6620 self.state.save()?;
6621 return Ok(());
6622 }
6623 if outcomes.iter().all(CommandOutcome::ok) {
6624 break;
6625 }
6626 match self.gate_fix_round(&winner, &outcomes).await? {
6627 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6628 GateFix::Stop => break,
6629 GateFix::Defer => {
6630 self.state.save()?;
6631 return Ok(());
6632 }
6633 }
6634 }
6635 let passed = outcomes.iter().all(CommandOutcome::ok);
6636 self.state.gate = outcomes;
6637 self.state.gate_ran = true;
6638 self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6639 if !passed {
6640 self.state.status = RunStatus::Blocked;
6641 let spent = self.state.gate_fixes.len();
6642 self.state.event(
6643 "gate",
6644 if spent == 0 {
6645 "gate failed; not merging".to_owned()
6646 } else {
6647 format!("gate failed after {spent} gate-fix round(s); not merging")
6648 },
6649 );
6650 }
6651 self.state.save()?;
6652 Ok(())
6653 }
6654
6655 async fn run_pre_gate(&mut self, winner: &Candidate) {
6665 let commands = self.state.config.verify.pre_gate.clone();
6666 if commands.is_empty() {
6667 return;
6668 }
6669 let shell = self.state.config.shell();
6670 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6671 let (outcomes, _) = run_commands(
6672 &mut self.state,
6673 "pre_gate",
6674 "pre_gate",
6675 0,
6676 &shell,
6677 &commands,
6678 &winner.worktree,
6679 timeout,
6680 )
6681 .await;
6682 for o in &outcomes {
6683 if !o.ok() {
6684 tracing::warn!(
6685 "pre_gate `{}` failed ({:?}); the gate decides",
6686 o.command,
6687 o.code
6688 );
6689 }
6690 self.state.event(
6691 "pre_gate",
6692 format!(
6693 "`{}` -> {}",
6694 o.command,
6695 if o.ok() {
6696 "pass".to_owned()
6697 } else {
6698 format!(
6699 "FAIL ({:?})\n{}",
6700 o.code,
6701 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6702 )
6703 }
6704 ),
6705 );
6706 }
6707 self.state.pre_gate = outcomes;
6708 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6709 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6710 Ok(head) => {
6711 self.state
6712 .event("pre_gate", format!("committed mechanical fixes ({head})"));
6713 self.state.pre_gate_commit = Some(head);
6714 }
6715 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6716 },
6717 Ok(false) => {}
6718 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6719 }
6720 if let Err(e) = self.state.save() {
6721 tracing::warn!("could not persist the pre_gate record: {e:#}");
6722 }
6723 }
6724
6725 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6728 self.run_pre_gate(winner).await;
6729 let shell = self.state.config.shell();
6730 let gate_commands = self.state.config.verify.gate.clone();
6731 let outcomes = if gate_commands.is_empty() {
6740 Vec::new()
6741 } else {
6742 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6743 let cache_dir = self.state.config.cache_dir();
6744 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6745 let (outcomes, _) = with_cache_lease(
6746 &mut self.state,
6747 cache_dir.as_deref(),
6748 "gate",
6749 "gate",
6750 &winner.worktree,
6751 &head,
6752 timeout,
6753 "final gate",
6754 |state, budget| {
6755 let shell = shell.clone();
6756 let gate_commands = gate_commands.clone();
6757 let worktree = winner.worktree.clone();
6758 async move {
6759 let (outcomes, timed_out_pids) = run_commands(
6760 state,
6761 "gate",
6762 "gate",
6763 0,
6764 &shell,
6765 &gate_commands,
6766 &worktree,
6767 budget,
6768 )
6769 .await;
6770 (outcomes, false, timed_out_pids)
6771 }
6772 },
6773 )
6774 .await;
6775 outcomes
6776 };
6777 if outcomes.is_empty() {
6778 self.state.event(
6783 "gate",
6784 "no gate commands configured; nothing to check, passing",
6785 );
6786 }
6787 for o in &outcomes {
6788 self.state.event(
6789 "gate",
6790 format!(
6791 "`{}` -> {}",
6792 o.command,
6793 if o.ok() {
6794 "pass".to_owned()
6795 } else {
6796 format!(
6797 "FAIL ({:?})\n{}",
6798 o.code,
6799 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6800 )
6801 }
6802 ),
6803 );
6804 }
6805 Ok(outcomes)
6806 }
6807
6808 async fn gate_fix_round(
6821 &mut self,
6822 winner: &Candidate,
6823 outcomes: &[CommandOutcome],
6824 ) -> Result<GateFix> {
6825 let cap = self.state.config.graph.gate_fix_rounds;
6826 let spent = self.state.gate_fixes.len();
6827 if spent >= cap {
6828 if cap > 0 {
6829 self.state.event(
6830 "gate",
6831 format!("{spent} gate-fix round(s) spent and the gate still fails"),
6832 );
6833 }
6834 return Ok(GateFix::Stop);
6835 }
6836 if !gate_fixable(outcomes) {
6837 self.state.event(
6838 "gate",
6839 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6840 command or similar); not spending a fix round on it",
6841 );
6842 return Ok(GateFix::Stop);
6843 }
6844 let min_free = self.state.config.disk.min_free_bytes;
6845 if min_free > 0 {
6846 match crate::disk::free_bytes(&winner.worktree) {
6847 Ok(free) if crate::disk::enough_space(free, min_free) => {}
6848 Ok(free) => {
6849 self.state.event(
6850 "gate",
6851 format!(
6852 "only {free} bytes free ({min_free} required by `[disk] \
6853 min_free_bytes`); not spending a fix round on a failure the disk \
6854 may explain"
6855 ),
6856 );
6857 return Ok(GateFix::Stop);
6858 }
6859 Err(e) => {
6860 self.state.event(
6861 "gate",
6862 format!("free disk space could not be measured ({e:#}); no fix round"),
6863 );
6864 return Ok(GateFix::Stop);
6865 }
6866 }
6867 }
6868
6869 let attempt = spent + 1;
6870 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6871 let base = self.landing_base();
6872 let fix_prompt = prompt::gate_fix_in(
6873 &self.state.instruction,
6874 &failed,
6875 attempt,
6876 cap,
6877 &self.state.config.graph.language,
6878 &self.state.config.graph.github_language,
6879 );
6880 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6881 let sessions = self.state.config.graph.sessions;
6882 let artifacts = self.merge_repair_artifacts();
6883 self.state.event(
6884 "gate",
6885 format!("gate failed; gate-fix round {attempt} of {cap}"),
6886 );
6887 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6888 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6889 let (_, seat, out) = self
6890 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6891 prompt: fix_prompt.clone(),
6892 spec,
6893 seat,
6894 cwd: winner.worktree.clone(),
6895 timeout,
6896 allow_write: true,
6897 sessions,
6898 artifacts: artifacts.clone(),
6899 stem: format!("gate-fix-{attempt}"),
6900 handover: None,
6901 })
6902 .await;
6903 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6906 return Ok(GateFix::Defer);
6907 }
6908 let mut record = GateFixRecord {
6909 agent: seat.agent.clone(),
6910 failed,
6911 notes: String::new(),
6912 committed: false,
6913 error: None,
6914 };
6915 match out {
6916 AgentOutcome::Ok(o) => {
6917 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6920 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6921 }
6922 }
6923 AgentOutcome::Dropped(_) => {
6924 record.error = Some("the CLI dropped the stream".to_owned());
6925 }
6926 AgentOutcome::Quota(o) => {
6927 self.state.quota.push(QuotaLoss {
6928 seat: seat.key.clone(),
6929 node: "gate-fix".to_owned(),
6930 at: Timestamp::now(),
6931 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6932 });
6933 record.error = Some("rate limited (quota); fixer could not run".to_owned());
6934 }
6935 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6936 }
6937 self.state.seats.insert(seat.key.clone(), seat);
6938 if let Ok(r) = git::rescue_commit(
6939 &winner.worktree,
6940 &format!("magi: gate fix {attempt} (uncommitted work)"),
6941 )
6942 .await
6943 {
6944 self.state.note_withheld("gate-fix", &r.withheld);
6945 }
6946 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6947 record.committed = after != before;
6948 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6949 let note = record.error.clone();
6950 self.state.gate_fixes.push(record);
6951 self.state.save()?;
6952 if !changed {
6953 self.state.event(
6954 "gate",
6955 match note {
6956 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6957 None => format!("gate-fix round {attempt}: the tree did not change"),
6958 },
6959 );
6960 return Ok(GateFix::Stop);
6961 }
6962 self.state.event(
6963 "gate",
6964 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6965 );
6966
6967 let commands = self.state.config.verify.e2e.clone();
6968 if !commands.is_empty() {
6969 let shell = self.state.config.shell();
6970 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6971 let cache_dir = self.state.config.cache_dir();
6972 let context = format!("gate-fix round {attempt}");
6973 let (e2e, _) = with_cache_lease(
6974 &mut self.state,
6975 cache_dir.as_deref(),
6976 "e2e",
6977 "e2e",
6978 &winner.worktree,
6979 &after,
6980 timeout,
6981 &context,
6982 |state, budget| {
6983 let shell = shell.clone();
6984 let commands = commands.clone();
6985 let context = context.clone();
6986 let worktree = winner.worktree.clone();
6987 async move {
6988 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6989 .await
6990 }
6991 },
6992 )
6993 .await;
6994 if verify_inconclusive(&e2e) {
6995 return Ok(GateFix::Defer);
6996 }
6997 if e2e.iter().any(|o| !o.ok()) {
6998 self.state.event(
6999 "gate",
7000 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7001 );
7002 return Ok(GateFix::Stop);
7003 }
7004 }
7005 Ok(GateFix::Retry)
7006 }
7007
7008 async fn guarded_pr_message(
7015 &mut self,
7016 winner: &Candidate,
7017 facts: Option<&BranchFacts>,
7018 posting: bool,
7019 ) -> Result<Option<PrMessage>> {
7020 let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7021 let head = match final_head.as_deref() {
7022 Some(h) => crate::pr_outcome::FinalHead::At(h),
7023 None => crate::pr_outcome::FinalHead::Unknown,
7024 };
7025 let summarized = if posting {
7027 Some(self.summarize_change(winner, final_head.as_deref()).await)
7028 } else {
7029 None
7030 };
7031 if self.state.resource_block.is_some() {
7036 self.state.event(
7037 "pr-summary",
7038 "a shared resource was busy; nothing was published, resume to summarize again",
7039 );
7040 return Ok(None);
7041 }
7042 if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7043 anyhow::bail!(
7044 "the final commit moved during public description generation; retry before publishing"
7045 );
7046 }
7047 let note = match (&summarized, &self.state.change_summary) {
7048 (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7049 (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7050 (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7051 (None, _) => ChangeNote::NotAsked,
7052 };
7053 let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7054 if !posting {
7055 let identity = crate::scrub::Identity::current();
7056 return Ok(Some(PrMessage {
7057 title: crate::scrub::scrub(&pr.title, &identity),
7058 body: crate::scrub::scrub(&pr.body, &identity),
7059 }));
7060 }
7061 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7064 if let Some(known) = self
7065 .state
7066 .github_text
7067 .clone()
7068 .filter(|g| g.fingerprint == raw_fp)
7069 {
7070 return self.settle_withheld(winner, &pr, known).await;
7071 }
7072 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7073 let mut violations =
7074 crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7075 let identity = crate::scrub::Identity::current();
7076 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7077 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7078 && !violations.contains(&crate::github_text::Violation::SensitiveData)
7079 {
7080 violations.push(crate::github_text::Violation::SensitiveData);
7081 }
7082 let has_language = violations
7086 .iter()
7087 .any(|v| *v != crate::github_text::Violation::SensitiveData);
7088 if self.state.config.graph.github_text_guard && !violations.is_empty() {
7089 self.state.event(
7090 "github-text",
7091 if has_language {
7092 format!("description rejected: {violations:?}; requesting one rewrite")
7093 } else {
7094 format!("description rejected: {violations:?}; asking the owner")
7095 },
7096 );
7097 let mut rewritten = false;
7098 let mut rejected_rewrite = None;
7099 if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7100 {
7101 let output = text;
7102 if let Some(title) = summary_title(&output) {
7103 let raw_body = summary_without_title(&output);
7106 let clean_title = crate::scrub::scrub(&title, &identity);
7107 let mut body = crate::scrub::scrub(&raw_body, &identity);
7108 let redacted = clean_title != title || body != raw_body;
7109 let title = clean_title;
7110 let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7111 if !complete {
7112 self.state.event(
7113 "github-text",
7114 "description rewrite omitted required findings",
7115 );
7116 }
7117 if !body.trim().is_empty() {
7118 body.push_str("\n\n");
7119 body.push_str(&public_pr_evidence(&self.state, head));
7120 }
7121 let marker = format!("magi:run/{}", self.state.id);
7122 if !body.trim().is_empty() && !body.contains(&marker) {
7123 body.push_str(&format!("\n\n{marker}"));
7124 }
7125 let rewrite = PrMessage {
7126 title: title.clone(),
7127 body: body.clone(),
7128 };
7129 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7130 let mut failed = crate::github_text::check_for(
7131 &self.state.config.graph,
7132 &title,
7133 &body,
7134 redecision,
7135 );
7136 if (!crate::github_text::shareable(&title)
7137 || !crate::github_text::shareable(&body))
7138 && !failed.contains(&crate::github_text::Violation::SensitiveData)
7139 {
7140 failed.push(crate::github_text::Violation::SensitiveData);
7141 }
7142 if complete
7143 && !title.trim().is_empty()
7144 && !body.trim().is_empty()
7145 && !failed.is_empty()
7146 {
7147 rejected_rewrite = Some(crate::run::GithubTextRewrite {
7148 title: title.clone(),
7149 body: body.clone(),
7150 failed: failed
7151 .iter()
7152 .map(|v| crate::github_text::category(*v).to_owned())
7153 .collect(),
7154 });
7155 }
7156 if complete
7157 && !title.trim().is_empty()
7158 && !raw_body.trim().is_empty()
7159 && failed.is_empty()
7160 {
7161 decision = redecision;
7162 pr = PrMessage { title, body };
7163 rewritten = true;
7164 self.state
7165 .event("github-text", "description rewrite passed");
7166 if redacted {
7167 self.state.event(
7168 "github-text",
7169 "sensitive data removed from description rewrite",
7170 );
7171 }
7172 }
7173 }
7174 }
7175 if !rewritten {
7176 self.state.event(
7177 "github-text",
7178 "description rewrite unavailable or rejected; asking the owner",
7179 );
7180 if let Some(w) =
7181 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7182 {
7183 self.retire_github_text_question("the withheld text changed");
7186 let record = crate::run::GithubTextAsk {
7187 rewrite: rejected_rewrite,
7188 fingerprint: raw_fp,
7189 title: w.title,
7190 body: w.body,
7191 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7192 question: None,
7193 asks: 0,
7194 resolved: false,
7195 chosen_title: None,
7196 choice: None,
7197 };
7198 return self.settle_withheld(winner, &pr, record).await;
7199 }
7200 }
7201 }
7202 let (title, body) =
7203 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7204 Ok(Some(PrMessage { title, body }))
7205 }
7206
7207 async fn summarize_change(
7218 &mut self,
7219 winner: &Candidate,
7220 head: Option<&str>,
7221 ) -> std::result::Result<(), String> {
7222 let Some(head) = head else {
7223 return Err("the final commit could not be read".to_owned());
7224 };
7225 let remote = self.state.config.merge.remote.clone();
7226 let tracking = format!("{remote}/{}", self.state.base_branch);
7227 if !git::rev_exists(&winner.worktree, &tracking).await {
7228 return Err(format!("the remote base `{tracking}` could not be read"));
7229 }
7230 let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7231 Ok(b) if !b.is_empty() => b,
7232 _ => return Err("the merge base with the remote base could not be read".to_owned()),
7233 };
7234 let evidence = crate::pr_outcome::summary_evidence(
7235 &self.state,
7236 crate::pr_outcome::FinalHead::At(head),
7237 );
7238 let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7239 if self
7240 .state
7241 .change_summary
7242 .as_ref()
7243 .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7244 {
7245 return Ok(());
7246 }
7247 if self.state.change_summary.take().is_some() {
7248 self.state.event(
7249 "pr-summary",
7250 "the final diff or evidence changed; the earlier change summary was discarded",
7251 );
7252 }
7253 if let Err(e) = write_artifact(
7255 &self.state,
7256 &format!("public-description-{fingerprint}.md"),
7257 &format!("Final diff: {base}..{head}\n\n{evidence}"),
7258 ) {
7259 tracing::warn!("could not save public description evidence: {e:#}");
7260 }
7261 let chain = match agent::pick_chain(
7262 &self.state.config.agents,
7263 self.state.config.roles.synthesizer.as_ref(),
7264 &agent::installed,
7265 "synthesizer",
7266 ) {
7267 Ok(chain) => chain,
7268 Err(e) => {
7269 tracing::warn!("no agent for the change summary: {e:#}");
7270 self.state.event("pr-summary", "no summary agent available");
7271 return Err("no agent was available to summarize it".to_owned());
7272 }
7273 };
7274 let subjects = git::subjects(&winner.worktree, &base, head)
7275 .await
7276 .unwrap_or_default();
7277 let stat = git::diff_stat(&winner.worktree, &base, head)
7278 .await
7279 .unwrap_or_default();
7280 let stat: String = stat.chars().take(6_000).collect();
7281 let mut prompt = crate::prompt::change_summary_in(
7282 &base,
7283 head,
7284 &subjects,
7285 stat.trim(),
7286 &self.state.config.graph.github_language,
7287 );
7288 prompt.push_str(&format!("\n\n# Final-state evidence\n{evidence}\n\nUse these records as evidence, not public text to copy. Paraphrase every open or declined finding and its reason in the requested GitHub language, retaining its exact ID and severity when available. Do not translate or quote internal operator reports. Explain unresolved risks for a reviewer without the task conversation. Historical sandbox or commit failures are not the final state. Verification is appended from these records; do not make additional validation claims."));
7289 let sessions = self.state.config.graph.sessions;
7290 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7291 let prompts = self.state.config.prompts.clone();
7292 let cache = self.state.config.cache_dir();
7293 let run = self.state.id.clone();
7294 for (i, spec) in chain.into_iter().enumerate() {
7295 let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7296 let job = SeatJob {
7297 spec: spec.clone(),
7298 seat,
7299 cwd: winner.worktree.clone(),
7300 prompt: prompt.clone(),
7301 timeout,
7302 allow_write: false,
7303 sessions,
7304 artifacts: agent::artifacts_dir(&self.state.dir()),
7305 stem: if i == 0 {
7306 "change-summary".to_owned()
7307 } else {
7308 format!("change-summary-{}", spec.id)
7309 },
7310 handover: None,
7311 };
7312 let ctx = WaveCtx {
7313 run: &run,
7314 node: "pr-summary",
7315 prompts: &prompts,
7316 cache: cache.as_deref(),
7317 round: None,
7318 carry_seats: false,
7319 pauses: Some((&self.pause, &self.interrupt)),
7320 };
7321 let (seat, outcome) =
7322 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7323 self.state.seats.insert(seat.key.clone(), seat);
7324 let text = match outcome {
7325 AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7326 AgentOutcome::ResourceBlocked(why) => {
7327 if self.state.resource_block.is_none() {
7330 self.state.resource_block = Some(crate::run::ResourceBlock {
7331 node: "pr-summary".to_owned(),
7332 kind: "shared resource".to_owned(),
7333 detail: why,
7334 at: Timestamp::now(),
7335 });
7336 }
7337 return Err("a shared resource was busy".to_owned());
7338 }
7339 _ => continue,
7340 };
7341 let blind = &self.state.config.blind;
7342 let Some(title) = summary_title(&text) else {
7343 continue;
7344 };
7345 let body: String = summary_without_title(&text)
7346 .lines()
7347 .filter(|l| !l.contains("magi:run/"))
7348 .collect::<Vec<_>>()
7349 .join("\n");
7350 let body = blind::sanitize_prose(body.trim(), blind);
7351 let title = blind::sanitize_prose(&title, blind);
7352 if body.trim().is_empty() || title.trim().is_empty() {
7353 continue;
7354 }
7355 if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7356 continue;
7357 }
7358 if git::rev_parse(&winner.worktree, "HEAD")
7360 .await
7361 .ok()
7362 .as_deref()
7363 != Some(head)
7364 {
7365 return Err("the branch moved while the summary was written".to_owned());
7366 }
7367 if git::merge_base(&winner.worktree, &tracking, head)
7368 .await
7369 .ok()
7370 .as_deref()
7371 != Some(base.as_str())
7372 || crate::pr_outcome::summary_evidence(
7373 &self.state,
7374 crate::pr_outcome::FinalHead::At(head),
7375 ) != evidence
7376 {
7377 return Err("the summary evidence changed while it was written".to_owned());
7378 }
7379 self.state.change_summary = Some(crate::run::ChangeSummary {
7380 head: head.to_owned(),
7381 base,
7382 evidence: fingerprint,
7383 title,
7384 body,
7385 });
7386 self.state.event(
7387 "pr-summary",
7388 format!("change summary written by {} for the final diff", spec.id),
7389 );
7390 if let Err(e) = self.state.save() {
7391 tracing::warn!("could not save the change summary: {e:#}");
7392 }
7393 return Ok(());
7394 }
7395 self.state
7396 .event("pr-summary", "no agent produced a change summary");
7397 Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7398 }
7399
7400 async fn rewrite_pr_text(
7408 &mut self,
7409 winner: &Candidate,
7410 pr: &PrMessage,
7411 violations: &[crate::github_text::Violation],
7412 ) -> Option<String> {
7413 let prompt = format!(
7414 "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 rewritten description. Include an explanation of every open or declined finding with its exact ID. Omit the generated Review rounds, Final verification and Open review findings sections, the Evidence line and run markers: magi appends those from its records.\n\nTITLE: {}\n{}",
7415 pr.title, pr.body
7416 );
7417 let sessions = self.state.config.graph.sessions;
7418 let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7419 let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7421 let key = format!("impl-{}", winner.label);
7422 if let Some(spec) = self
7423 .state
7424 .config
7425 .agents
7426 .iter()
7427 .find(|a| a.id == winner.agent)
7428 .cloned()
7429 && let Some(existing) = self.state.seats.get(&key)
7430 && existing.agent == spec.id
7431 && agent::has_session(spec.kind, existing, sessions)
7432 {
7433 attempts.push((spec, existing.clone(), true));
7434 } else {
7435 let chain = agent::pick_chain(
7436 &self.state.config.agents,
7437 self.state.config.roles.language_judge.as_ref(),
7438 &agent::installed,
7439 "language_judge",
7440 );
7441 match chain {
7442 Ok(chain) => {
7443 for spec in chain {
7444 let seat = crate::fixer::seat_for(
7445 &mut self.state,
7446 "github-text-rewrite",
7447 &spec.id,
7448 );
7449 attempts.push((spec, seat, false));
7450 }
7451 }
7452 Err(e) => {
7453 tracing::warn!("no agent for the description rewrite: {e:#}");
7454 self.state
7455 .event("github-text", "no rewrite agent available");
7456 return None;
7457 }
7458 }
7459 }
7460 let prompts = self.state.config.prompts.clone();
7461 let cache = self.state.config.cache_dir();
7462 let run = self.state.id.clone();
7463 for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7464 let job = SeatJob {
7465 spec: spec.clone(),
7466 seat,
7467 cwd: winner.worktree.clone(),
7468 prompt: prompt.clone(),
7469 timeout: retry_budget(timeout_full, resumed),
7470 allow_write: false,
7471 sessions,
7472 artifacts: agent::artifacts_dir(&self.state.dir()),
7473 stem: if i == 0 {
7474 "github-text-rewrite".to_owned()
7475 } else {
7476 format!("github-text-rewrite-{}", spec.id)
7477 },
7478 handover: None,
7479 };
7480 let ctx = WaveCtx {
7481 run: &run,
7482 node: "github-text",
7483 prompts: &prompts,
7484 cache: cache.as_deref(),
7485 round: None,
7486 carry_seats: false,
7487 pauses: Some((&self.pause, &self.interrupt)),
7488 };
7489 let (seat, outcome) =
7490 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7491 self.state.seats.insert(seat.key.clone(), seat);
7492 match outcome {
7493 AgentOutcome::Ok(o)
7494 if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7495 {
7496 return Some(o.text);
7497 }
7498 AgentOutcome::ResourceBlocked(_) => return None,
7499 _ => {}
7500 }
7501 }
7502 None
7503 }
7504
7505 fn retire_github_text_question(&mut self, why: &str) {
7507 let Some(id) = self
7508 .state
7509 .github_text
7510 .as_ref()
7511 .and_then(|g| g.question.clone())
7512 else {
7513 return;
7514 };
7515 let _ = ask::Questions::open().update(&id, |q| {
7516 q.abandon(why);
7517 Ok(())
7518 });
7519 }
7520
7521 async fn settle_withheld(
7525 &mut self,
7526 winner: &Candidate,
7527 pr: &PrMessage,
7528 mut g: crate::run::GithubTextAsk,
7529 ) -> Result<Option<PrMessage>> {
7530 use crate::github_text as gt;
7531 let store = ask::Questions::open();
7532 let timeout = self.state.config.graph.answer_timeout;
7533 if !g.resolved {
7534 let mut standing = g
7535 .question
7536 .as_ref()
7537 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7538 if let Some(q) = standing.as_ref()
7539 && q.status.open()
7540 && gt::expired(q, timeout)
7541 {
7542 let why = format!("no answer within {}s of asking", timeout.max(1));
7543 standing = store
7544 .update(&q.id, |q| {
7545 q.abandon(&why);
7546 Ok(())
7547 })
7548 .ok()
7549 .map(|(q, ())| q);
7550 }
7551 let mut ask_again: Option<Option<Vec<String>>> = None;
7552 match standing {
7553 None if g.asks <= 1 => {
7556 g.asks = 0;
7557 ask_again = Some(None);
7558 }
7559 None => {
7560 g.resolved = true;
7561 g.chosen_title = None;
7562 g.choice = Some(GithubTextChoice::Fallback);
7563 }
7564 Some(q) if q.status.open() => {
7565 self.park_on_github_text(&g)?;
7566 return Ok(None);
7567 }
7568 Some(q) => match gt::read_reply(&q) {
7569 gt::Reply::Fallback => {
7570 g.resolved = true;
7571 g.chosen_title = None;
7572 g.choice = Some(GithubTextChoice::Fallback);
7573 }
7574 gt::Reply::Original => {
7575 g.resolved = true;
7576 g.chosen_title = None;
7577 g.choice = Some(GithubTextChoice::Original);
7578 }
7579 gt::Reply::Rewrite => {
7580 g.resolved = true;
7581 g.chosen_title = None;
7582 g.choice = Some(if g.rewrite.is_some() {
7583 GithubTextChoice::Rewrite
7584 } else {
7585 GithubTextChoice::Fallback
7586 });
7587 }
7588 gt::Reply::Title(text) => {
7589 let first = text
7590 .lines()
7591 .map(str::trim)
7592 .find(|l| !l.is_empty())
7593 .unwrap_or("");
7594 let decision = if first.is_empty() {
7595 None
7596 } else {
7597 let candidate = PrMessage {
7598 title: first.to_owned(),
7599 body: String::new(),
7600 };
7601 self.judge_pr_language(&winner.worktree, &candidate).await
7602 };
7603 match gt::vet_title(
7604 &text,
7605 decision,
7606 self.state.config.graph.github_english(),
7607 ) {
7608 Ok(title) => {
7609 g.resolved = true;
7610 g.chosen_title = Some(title);
7611 g.choice = Some(GithubTextChoice::MyTitle);
7612 }
7613 Err(cats) if g.asks < 2 => {
7614 ask_again = Some(Some(
7615 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7616 ))
7617 }
7618 Err(_) => {
7619 g.resolved = true;
7620 g.chosen_title = None;
7621 g.choice = Some(GithubTextChoice::Fallback);
7622 }
7623 }
7624 }
7625 },
7626 }
7627 if let Some(rejected) = ask_again {
7628 let w = gt::Withheld {
7631 title: g.title,
7632 body: g.body,
7633 categories: g.categories.clone(),
7634 };
7635 let artifact = match crate::run::write_artifact(
7638 &self.state,
7639 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
7640 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7641 ) {
7642 Ok(p) => Some(p),
7643 Err(e) => {
7644 tracing::warn!("could not write the withheld-text artifact: {e:#}");
7645 None
7646 }
7647 };
7648 let (detail, comparison) = gt::question_content(
7649 &w,
7650 &pr.title,
7651 &pr.body,
7652 &self.state.id,
7653 rejected.as_deref(),
7654 artifact.as_deref(),
7655 g.rewrite.as_ref(),
7656 );
7657 let mut q = ask::Question::new(
7658 self.state.id.clone(),
7659 gt::ASK_NODE.to_owned(),
7660 gt::ASK_SEAT.to_owned(),
7661 gt::question_summary(&self.state.config.graph.language, &w),
7662 detail,
7663 gt::question_choices(&w, g.rewrite.as_ref()),
7664 );
7665 q.posting_comparison = Some(comparison);
7666 q.answer_timeout = timeout;
7667 g.question = Some(q.id.clone());
7670 g.asks += 1;
7671 self.state.github_text = Some(g.clone());
7672 self.state.event(
7673 "github-text",
7674 format!("asking the owner about the withheld text ({})", q.short()),
7675 );
7676 self.state.save()?;
7677 store
7678 .put(&mut q)
7679 .context("file the posting-gate question")?;
7680 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7681 tracing::warn!(
7682 "could not notify about posting-gate question {}: {e:#}",
7683 q.short()
7684 );
7685 }
7686 self.park_on_github_text(&g)?;
7687 return Ok(None);
7688 }
7689 }
7690 let resolved = g.chosen_title.clone();
7691 let original = g.choice == Some(GithubTextChoice::Original);
7692 self.state.event(
7694 "github-text",
7695 if original {
7696 format!(
7697 "posting the original text for: {}",
7698 match (g.title, g.body) {
7699 (true, true) => "title, description",
7700 (true, false) => "title",
7701 _ => "description",
7702 }
7703 )
7704 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7705 "posting the saved rewrite title and description".to_owned()
7706 } else if resolved.is_some() {
7707 "posting the owner's replacement title".to_owned()
7708 } else {
7709 "posting the neutral text for the withheld fields".to_owned()
7710 },
7711 );
7712 self.state.github_text = Some(g.clone());
7713 self.state.save()?;
7714 if g.choice == Some(GithubTextChoice::Rewrite)
7715 && let Some(r) = g.rewrite.as_ref()
7716 {
7717 return Ok(Some(PrMessage {
7718 title: r.title.clone(),
7719 body: r.body.clone(),
7720 }));
7721 }
7722 let w = gt::Withheld {
7723 title: g.title,
7724 body: g.body,
7725 categories: g.categories.clone(),
7726 };
7727 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7728 if let Some(t) = resolved {
7729 title = t;
7730 }
7731 let english = gt::LanguageDecision {
7733 title_english: true,
7734 body_english: true,
7735 body_complete: true,
7736 };
7737 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7738 if original {
7739 if g.title {
7742 title = pr.title.clone();
7743 }
7744 if g.body {
7745 body = pr.body.clone();
7746 }
7747 }
7748 Ok(Some(PrMessage { title, body }))
7749 }
7750
7751 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7752 self.state.github_text = Some(g.clone());
7753 self.state.end_pass_parked(ParkCause::Question);
7754 self.state.parked = true;
7755 self.state.event(
7756 "github-text",
7757 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7758 );
7759 self.state.save()
7760 }
7761
7762 async fn judge_pr_language(
7765 &mut self,
7766 cwd: &Path,
7767 pr: &PrMessage,
7768 ) -> Option<crate::github_text::LanguageDecision> {
7769 self.state.config.roles.language_judge.as_ref()?;
7770 if !self.state.config.graph.language_gate() {
7771 return None;
7772 }
7773 let decision =
7774 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7775 self.state.event(
7776 "github-text",
7777 if decision.is_some() {
7778 "language decided by the language judge"
7779 } else {
7780 "language judge unavailable; using built-in heuristics"
7781 },
7782 );
7783 decision
7784 }
7785
7786 async fn merge(&mut self) -> Result<()> {
7787 if self
7802 .state
7803 .base_sync
7804 .as_ref()
7805 .is_some_and(|s| s.conflict.is_some())
7806 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7807 != Some(RunStatus::Gating)
7808 || !self.state.gate_status().ok()
7817 {
7818 return Ok(());
7819 }
7820 if self.state.merge.is_some() && !self.state.pr_publication_pending {
7829 return Ok(());
7830 }
7831 let Some(winner) = self.state.winner().cloned() else {
7832 return Ok(());
7833 };
7834 let repo = self.state.repo.clone();
7835 let base = self.state.base_branch.clone();
7836 let mode = self.state.config.merge.mode;
7837 let style = self.state.config.merge.style;
7838 let facts = if is_review_run(&self.state) {
7839 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7840 let start = review_base(
7841 &repo,
7842 &self.state.config.merge.remote,
7843 &base,
7844 &self.state.base_commit,
7845 &winner.branch,
7846 )
7847 .await;
7848 branch_facts(&repo, &start, &winner.branch).await
7849 } else {
7850 None
7851 };
7852 let leaked = if is_review_run(&self.state) {
7855 leaked_subjects(&self.state, &winner.branch).await
7856 } else {
7857 Some(Vec::new())
7858 };
7859 let Some(pr) = self
7863 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7864 .await?
7865 else {
7866 return Ok(());
7867 };
7868 let message = pr.commit_message();
7869
7870 let outcome = match mode {
7871 MergeMode::None => MergeOutcome {
7872 mode,
7873 ok: true,
7874 detail: manual_merge_command(style, &repo, &winner.branch, &message),
7875 empty: false,
7876 },
7877 MergeMode::Pr | MergeMode::Local
7878 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7879 {
7880 MergeOutcome {
7881 mode,
7882 ok: false,
7883 detail: empty_candidate_detail(&self.state, &base),
7884 empty: true,
7885 }
7886 }
7887 MergeMode::Local => {
7888 let on = git::current_branch(&repo).await?;
7889 if on.as_deref() != Some(base.as_str()) {
7890 MergeOutcome {
7891 mode,
7892 ok: false,
7893 detail: format!(
7894 "{} has {} checked out, not the base branch {base}",
7895 repo.display(),
7896 on.unwrap_or_else(|| "a detached HEAD".to_owned())
7897 ),
7898 empty: false,
7899 }
7900 } else if !git::is_clean(&repo).await? {
7901 MergeOutcome {
7902 mode,
7903 ok: false,
7904 detail: format!("{} is dirty; refusing to merge", repo.display()),
7905 empty: false,
7906 }
7907 } else {
7908 let out = match style {
7909 MergeStyle::Merge => {
7910 git::merge_no_ff(&repo, &winner.branch, &message).await?
7911 }
7912 MergeStyle::Squash => {
7913 git::merge_squash(&repo, &winner.branch, &message).await?
7914 }
7915 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7916 };
7917 MergeOutcome {
7918 mode,
7919 ok: out.ok(),
7920 detail: if out.ok() { out.stdout } else { out.stderr },
7921 empty: false,
7922 }
7923 }
7924 }
7925 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7926 self.state.pr_publication_pending = true;
7927 self.state.save()?;
7928 let out =
7929 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7930 self.state.pr_publication_pending = out.is_err();
7931 MergeOutcome {
7932 mode,
7933 ok: out.is_ok(),
7934 detail: match out {
7935 Ok(url) => url,
7936 Err(e) => format!("{e:#}"),
7937 },
7938 empty: false,
7939 }
7940 }
7941 MergeMode::Pr => {
7942 let remote = self.state.config.merge.remote.clone();
7943 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
7944 if !pushed.ok() {
7945 MergeOutcome {
7946 mode,
7947 ok: false,
7948 detail: pushed.stderr,
7949 empty: false,
7950 }
7951 } else {
7952 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7958 let out = match pr_merge_plan(found) {
7959 PrPlan::Create => {
7960 gh_pr_create(
7961 &winner.worktree,
7962 &base,
7963 &winner.branch,
7964 &pr.title,
7965 &pr.body,
7966 )
7967 .await
7968 }
7969 PrPlan::Adopt { url, title } => {
7970 self.state
7971 .event("merge", format!("Pr: adopted open pull request {url}"));
7972 if self.state.github_text.as_ref().is_some_and(|g| {
7973 g.resolved
7974 && g.choice == Some(GithubTextChoice::Rewrite)
7975 && g.rewrite.is_some()
7976 }) {
7977 land::set_pr_text_verbatim(
7980 &winner.worktree,
7981 &url,
7982 &pr.title,
7983 &pr.body,
7984 )
7985 .await
7986 .map(|()| url)
7987 } else {
7988 if title != pr.title
7989 && (!is_review_run(&self.state)
7990 || leaked
7991 .as_deref()
7992 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7993 && let Err(e) =
7994 if self.state.github_text.as_ref().is_some_and(|g| {
7995 g.resolved
7996 && g.title
7997 && g.choice == Some(GithubTextChoice::Original)
7998 }) {
7999 land::set_pr_title_verbatim(
8001 &winner.worktree,
8002 &url,
8003 &pr.title,
8004 )
8005 .await
8006 } else {
8007 land::set_pr_title(
8008 &mut self.state,
8009 &winner.worktree,
8010 &url,
8011 &pr.title,
8012 )
8013 .await
8014 }
8015 {
8016 tracing::warn!("could not refresh title of {url}: {e:#}");
8017 self.state
8018 .event("merge", format!("Pr: title refresh failed: {e:#}"));
8019 }
8020 Ok(url)
8021 }
8022 }
8023 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8024 };
8025 match out {
8026 Ok(url) => MergeOutcome {
8027 mode,
8028 ok: true,
8029 detail: url,
8030 empty: false,
8031 },
8032 Err(e) => MergeOutcome {
8033 mode,
8034 ok: false,
8035 detail: e.to_string(),
8036 empty: false,
8037 },
8038 }
8039 }
8040 }
8041 };
8042
8043 self.state.status = match (mode, outcome.ok) {
8044 (MergeMode::None, _) => RunStatus::Ready,
8045 (_, true) => RunStatus::Merged,
8046 (_, false) => RunStatus::Blocked,
8047 };
8048 self.state.event(
8049 "merge",
8050 format!(
8051 "{:?}: {}",
8052 mode,
8053 outcome.detail.lines().next().unwrap_or("")
8054 ),
8055 );
8056 self.state.merge = Some(outcome);
8057 self.state.save()?;
8058
8059 if self.state.config.graph.land
8065 && mode == MergeMode::Pr
8066 && self.state.status == RunStatus::Merged
8067 {
8068 self.run_land().await?;
8069 }
8070 self.settle_questions();
8075 Ok(())
8076 }
8077
8078 async fn run_land(&mut self) -> Result<()> {
8089 crate::pr_head::ensure_owner(&self.state)?;
8090 let url = self
8091 .state
8092 .merge
8093 .as_ref()
8094 .map(|m| m.detail.clone())
8095 .unwrap_or_default();
8096 let url = url.lines().next().unwrap_or("").trim().to_owned();
8097 if !url.starts_with("http") {
8098 return Ok(());
8099 }
8100 loop {
8103 match land::land(&mut self.state, &url).await {
8104 Ok(_)
8105 if self
8106 .state
8107 .merge_repairs
8108 .last()
8109 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8110 {
8111 if self.repair_merge_review().await? {
8112 continue;
8113 }
8114 }
8115 Ok(pr) if self.state.parked => {
8116 let _ = pr;
8120 }
8121 Ok(pr) => {
8122 self.state.status = match pr.state {
8123 land::PrLifecycle::Merged => RunStatus::Merged,
8124 _ => RunStatus::Blocked,
8125 };
8126 if bump::should_release_bump(self.state.status)
8133 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8134 {
8135 self.state
8141 .event("bump", format!("release bump skipped: {e:#}"));
8142 }
8143 if self.state.status == RunStatus::Merged {
8146 crate::followup::after_merge(&mut self.state, &pr.url).await;
8147 }
8148 self.state.save()?;
8149 }
8150 Err(e) => {
8151 self.state.status = RunStatus::Blocked;
8152 self.state.event("land", format!("gave up: {e}"));
8153 self.state.save()?;
8154 }
8155 }
8156 break;
8157 }
8158 Ok(())
8159 }
8160
8161 async fn repair_merge_review(&mut self) -> Result<bool> {
8164 use crate::run::MergeRepairPhase as Phase;
8165 crate::pr_head::ensure_owner(&self.state)?;
8166 let i = self.state.merge_repairs.len() - 1;
8167 let request = self.state.merge_repairs[i].clone();
8168 if request.phase == Phase::Fixing {
8169 let restored = match self.state.winner() {
8170 Some(winner) => {
8171 crate::recovery::restore_repair(&winner.worktree, &request.head).await
8172 }
8173 None => Err(anyhow::anyhow!("repair has no winning branch")),
8174 };
8175 let why = match restored {
8176 Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8177 Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8178 };
8179 self.hold_merge_repair(i, why)?;
8180 return Ok(false);
8181 }
8182 let failure = if i >= self.state.config.graph.land_rounds
8183 || self.state.config.graph.review_rounds == 0
8184 {
8185 Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8186 } else {
8187 None
8188 };
8189 if let Some(why) = failure {
8190 self.hold_merge_repair(i, why)?;
8191 return Ok(false);
8192 }
8193 if request.phase == Phase::Requested {
8194 let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8195 Ok(pr) => pr,
8196 Err(e) => {
8197 self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8198 return Ok(false);
8199 }
8200 };
8201 self.state.merge_repairs[i].phase = Phase::Fixing;
8202 self.state.land_approval = None;
8203 self.state.land_armed_head = None;
8204 self.state.save()?;
8205 let evidence = serde_json::to_string_pretty(&request)?;
8208 let reason = format!(
8209 "The owner requested fix findings and re-review, without merging. Address the unresolved Major findings and final rejecting votes in this exact review evidence. Do not merge or approve the PR.\n\n{evidence}"
8210 );
8211 let budget = self.state.config.graph.land_rounds;
8212 let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8213 match result {
8214 Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8215 Ok(land::Fixed::Failed(why)) => {
8216 self.hold_merge_repair(
8217 i,
8218 format!(
8219 "repair failed: {why}; inspect the branch and run an explicit review"
8220 ),
8221 )?;
8222 return Ok(false);
8223 }
8224 Err(e) => {
8225 self.hold_merge_repair(
8226 i,
8227 format!(
8228 "repair failed: {e:#}; inspect the branch and run an explicit review"
8229 ),
8230 )?;
8231 return Ok(false);
8232 }
8233 }
8234 self.begin_merge_repair_review(i)?;
8238 }
8239 self.review_loop().await?;
8240 self.gate().await?;
8241 if self.resource_halt()? || self.state.parked {
8242 return Ok(false);
8243 }
8244 if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8245 != Some(RunStatus::Gating)
8246 || !self.state.gate_status().ok()
8247 {
8248 self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8249 return Ok(false);
8250 }
8251 let winner = self
8254 .state
8255 .winner()
8256 .cloned()
8257 .context("repair has no winning branch")?;
8258 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8259 if self.state.gate_head.as_deref() != Some(after.as_str())
8260 || !git::is_clean(&winner.worktree).await?
8261 {
8262 self.hold_merge_repair(i, "repair branch changed after its recorded gate, or the verified head is unknown; nothing was published. Inspect the branch and run an explicit review before requesting a new merge decision".to_owned())?;
8263 return Ok(false);
8264 }
8265 let expected = self
8266 .state
8267 .pr_pushed_head
8268 .as_deref()
8269 .unwrap_or(&request.head)
8270 .to_owned();
8271 let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8272 let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8275 let refspec = format!("{after}:refs/heads/{public}");
8276 let push = git::git_raw(
8277 &winner.worktree,
8278 &["push", &lease, &self.state.config.merge.remote, &refspec],
8279 )
8280 .await;
8281 match push {
8282 Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8283 Ok(out) => {
8284 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8285 return Ok(false);
8286 }
8287 Err(e) => {
8288 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8289 return Ok(false);
8290 }
8291 }
8292 self.state.merge_repairs[i].phase = Phase::Complete;
8293 self.state.status = RunStatus::Landing;
8294 self.state.event(
8295 "repair",
8296 "new review and gate completed; fresh owner merge decision required",
8297 );
8298 self.state.save()?;
8299 Ok(true)
8300 }
8301
8302 fn merge_repair_artifacts(&self) -> PathBuf {
8303 let artifacts = agent::artifacts_dir(&self.state.dir());
8304 match self
8305 .state
8306 .merge_repairs
8307 .last()
8308 .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8309 {
8310 Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8311 None => artifacts,
8312 }
8313 }
8314
8315 fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8316 self.state.reviews.clear();
8317 self.state.gate.clear();
8318 self.state.gate_ran = false;
8319 self.state.gate_head = None;
8320 self.state.gate_fixes.clear();
8321 self.state.pre_gate.clear();
8322 self.state.pre_gate_commit = None;
8323 self.state.contested_handoff = None;
8324 self.state.land_reruns = None;
8325 self.state.land_approval = None;
8326 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8327 self.state.status = RunStatus::Reviewing;
8328 self.state.save()
8329 }
8330
8331 fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8332 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8333 self.state.merge_repairs[index].failure = Some(why.clone());
8334 self.state.status = RunStatus::Blocked;
8335 self.state.event("repair", why);
8336 self.state.save()
8337 }
8338
8339 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8343 if let Some(existing) = self.state.seats.get(key)
8344 && existing.agent == agent
8345 {
8346 return existing.clone();
8347 }
8348 let fresh = if self.state.seats.contains_key(key) {
8351 handover_seat(key, agent, self.state.next_seat_seed())
8352 } else {
8353 SeatState::new(key, agent, self.state.seed)
8354 };
8355 self.state.seats.insert(key.to_owned(), fresh.clone());
8356 fresh
8357 }
8358
8359 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8365 match self.state.seats.get(key) {
8366 Some(s) if s.agent != spec.id => {
8367 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8368 }
8369 _ => spec,
8370 }
8371 }
8372
8373 fn view(&self, c: &Candidate) -> CandidateView {
8375 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8376 .unwrap_or_default();
8377 let (patch, _) = blind::sanitize_patch(
8378 &format!("candidate {} patch", c.label),
8379 &raw,
8380 &self.state.config.blind,
8381 );
8382 CandidateView {
8383 label: c.label,
8384 branch: c.branch.clone(),
8385 summary: c.summary.clone(),
8386 stat: c.stat.clone(),
8387 patch,
8388 }
8389 }
8390
8391 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8393 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8394 prompt::judge(
8395 "(see above)",
8396 &views,
8397 self.roles.judges.len(),
8398 base_short,
8399 "en",
8400 )
8401 }
8402
8403 fn vote_prompt_full(
8409 &self,
8410 j: usize,
8411 labels: &[char],
8412 language: &str,
8413 candidates: &[Candidate],
8414 base_short: &str,
8415 ) -> String {
8416 let mut text = format!(
8417 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8418 prompt::final_vote(labels, language),
8419 self.state.instruction,
8420 self.candidate_block(candidates, base_short)
8421 );
8422 if let Some(own) = self
8423 .state
8424 .judgements
8425 .get(j)
8426 .filter(|r| !r.ranking.is_empty())
8427 {
8428 let reasons = own
8429 .reasons
8430 .iter()
8431 .map(|(k, v)| format!("- {k}: {v}"))
8432 .collect::<Vec<_>>()
8433 .join("\n");
8434 text.push_str(&format!(
8435 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8436 own.ranking.iter().collect::<String>(),
8437 if reasons.is_empty() {
8438 ""
8439 } else {
8440 ", because:\n"
8441 }
8442 ));
8443 }
8444 if !self.state.deliberation.is_empty() {
8445 text.push_str("\n# What was argued before this vote\n");
8446 for t in self.transcript(&[], j) {
8447 text.push_str(&format!(
8448 "\n## {}{}\n\n{}\n",
8449 t.who,
8450 if t.is_self { " (you)" } else { "" },
8451 t.body.trim()
8452 ));
8453 }
8454 }
8455 text
8456 }
8457
8458 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8465 let mut turns = Vec::new();
8466 for j in &self.state.judgements {
8467 if j.ranking.is_empty() {
8468 continue;
8469 }
8470 let reasons = j
8471 .reasons
8472 .iter()
8473 .map(|(k, v)| format!("- {k}: {v}"))
8474 .collect::<Vec<_>>()
8475 .join("\n");
8476 turns.push(Turn {
8477 who: format!("Judge {} (opening ranking)", j.judge),
8478 is_self: j.judge == self_idx + 1,
8479 body: format!(
8480 "Ranked {}{}{reasons}",
8481 j.ranking.iter().collect::<String>(),
8482 if reasons.is_empty() {
8483 ""
8484 } else {
8485 ", because:\n"
8486 }
8487 ),
8488 });
8489 }
8490 for t in self
8491 .state
8492 .deliberation
8493 .iter()
8494 .flat_map(|r| r.turns.iter())
8495 .chain(current)
8496 {
8497 turns.push(Turn {
8498 who: format!("Judge {}", t.judge),
8499 is_self: t.judge == self_idx + 1,
8500 body: t.body.clone(),
8501 });
8502 }
8503 turns
8504 }
8505}
8506
8507fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8509 agent::has_session(spec.kind, seat, sessions)
8510}
8511
8512fn next_untried_in_roster<'a>(
8533 roster: &'a [AgentSpec],
8534 start: usize,
8535 tried: &BTreeSet<String>,
8536) -> Option<&'a AgentSpec> {
8537 roster
8538 .get(start + 1..)?
8539 .iter()
8540 .find(|s| !tried.contains(&s.id))
8541}
8542
8543fn pick_successor<'a>(
8564 roster: &'a [AgentSpec],
8565 start: usize,
8566 tried: &BTreeSet<String>,
8567 carried: Option<&BTreeSet<String>>,
8568 others: &BTreeSet<String>,
8569) -> Option<&'a AgentSpec> {
8570 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8571 roster
8572 .get(start + 1..)
8573 .and_then(|tail| {
8574 tail.iter()
8575 .filter(free)
8576 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8577 })
8578 .or_else(|| {
8579 carried
8580 .is_some()
8581 .then(|| roster.iter().find(free))
8582 .flatten()
8583 })
8584 .or_else(|| match carried {
8585 Some(c) => next_for_seat(roster, start, tried, c),
8586 None => next_untried_in_roster(roster, start, tried),
8587 })
8588}
8589
8590fn next_for_seat<'a>(
8600 roster: &'a [AgentSpec],
8601 start: usize,
8602 round_tried: &BTreeSet<String>,
8603 carried_failed: &BTreeSet<String>,
8604) -> Option<&'a AgentSpec> {
8605 roster
8606 .get(start + 1..)?
8607 .iter()
8608 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8609 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8610}
8611
8612fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8618 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8619 return spec;
8620 };
8621 let ok = |id: &str| !h.failed.contains(id);
8622 if let Some(last) = h.last_ok.as_deref()
8623 && ok(last)
8624 && let Some(s) = roster.iter().find(|s| s.id == last)
8625 {
8626 return s.clone();
8627 }
8628 if ok(&spec.id) {
8629 return spec;
8630 }
8631 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8632 roster
8633 .get(from + 1..)
8634 .into_iter()
8635 .flatten()
8636 .chain(roster.iter())
8637 .find(|s| ok(&s.id))
8638 .cloned()
8639 .unwrap_or(spec)
8640}
8641
8642pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8646 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8647}
8648
8649const TIMED_OUT: &str = "timed out";
8652
8653impl FailClass {
8654 fn of(out: &AgentOutcome) -> Option<Self> {
8656 match out {
8657 AgentOutcome::Ok(_) => None,
8658 AgentOutcome::Quota(_) => Some(Self::Quota),
8659 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8660 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8661 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8662 AgentOutcome::ResourceBlocked(_) => None,
8665 }
8666 }
8667
8668 fn stem_word(&self) -> &'static str {
8670 match self {
8671 Self::Quota => "quota",
8672 _ => "handover",
8673 }
8674 }
8675}
8676
8677fn failure_signature(msg: &str) -> String {
8681 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8682 let mut out = Vec::new();
8683 for word in line.split_whitespace() {
8684 if word.contains('/') || word.contains('\\') {
8685 out.push("<path>".to_owned());
8686 continue;
8687 }
8688 let mut w = String::new();
8689 let mut in_digits = false;
8690 for c in word.chars() {
8691 if c.is_ascii_digit() {
8692 if !in_digits {
8693 w.push('#');
8694 }
8695 in_digits = true;
8696 } else {
8697 in_digits = false;
8698 w.push(c);
8699 }
8700 }
8701 out.push(w);
8702 }
8703 out.join(" ").chars().take(120).collect()
8704}
8705
8706fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8713 match cur {
8714 FailClass::Quota | FailClass::Timeout => true,
8715 FailClass::Other(_) => prev != Some(cur),
8716 }
8717}
8718
8719fn fail_reason(out: &AgentOutcome) -> String {
8721 match out {
8722 AgentOutcome::Ok(_) => String::new(),
8723 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8724 AgentOutcome::Dropped(o) => format!(
8725 "the CLI dropped the stream ({})",
8726 o.dropped
8727 .as_ref()
8728 .map(|d| d.why.as_str())
8729 .unwrap_or("it ended without delivering its answer")
8730 ),
8731 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8732 e.lines().next().unwrap_or("").chars().take(160).collect()
8733 }
8734 }
8735}
8736
8737pub(crate) fn record_handover(
8740 state: &mut RunState,
8741 node: &str,
8742 seat: &str,
8743 from: &str,
8744 to: &str,
8745 class: &FailClass,
8746 reason: &str,
8747) {
8748 let message = if *class == FailClass::Quota {
8749 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8750 } else {
8751 format!("{seat}: handed over {from} -> {to} ({reason})")
8752 };
8753 state.event(node, message);
8754 state.handovers.push(Handover {
8755 at: Timestamp::now(),
8756 node: node.to_owned(),
8757 seat: seat.to_owned(),
8758 from: from.to_owned(),
8759 to: to.to_owned(),
8760 reason: reason.to_owned(),
8761 });
8762}
8763
8764fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8778 commands.iter().any(|c| c.exit_code.is_none())
8779}
8780
8781fn verified_noop_claim(
8794 usable: bool,
8795 commands: &[agent::CommandEvidence],
8796 text: &str,
8797) -> Option<String> {
8798 (usable && !has_unconfirmed_command(commands))
8799 .then(|| verdict::verified_noop(text))
8800 .flatten()
8801}
8802
8803fn short(commit: &str) -> String {
8804 commit.chars().take(7).collect()
8805}
8806
8807fn make_executable(path: &Path) -> Result<()> {
8808 #[cfg(unix)]
8809 {
8810 use std::os::unix::fs::PermissionsExt as _;
8811 let mut perms = std::fs::metadata(path)?.permissions();
8812 perms.set_mode(0o755);
8813 std::fs::set_permissions(path, perms)?;
8814 }
8815 #[cfg(not(unix))]
8816 {
8817 let _ = path;
8818 }
8819 Ok(())
8820}
8821
8822struct WaveCtx<'a> {
8829 run: &'a str,
8832 node: &'a str,
8834 prompts: &'a Prompts,
8835 cache: Option<&'a Path>,
8837 round: Option<usize>,
8840 carry_seats: bool,
8844 pauses: Option<(&'a Pause, &'a Pause)>,
8847}
8848
8849async fn run_one(
8851 job: SeatJob,
8852 sem: Arc<Semaphore>,
8853 ctx: &WaveCtx<'_>,
8854 state: &mut RunState,
8855 attempt: usize,
8856) -> (SeatState, AgentOutcome) {
8857 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8858 .await
8859 .pop()
8860 .expect("one job in, one result out");
8861 (seat, out)
8862}
8863
8864async fn wave(
8870 jobs: Vec<SeatJob>,
8871 sem: Arc<Semaphore>,
8872 ctx: &WaveCtx<'_>,
8873 state: &mut RunState,
8874 attempt: usize,
8875) -> Vec<(usize, SeatState, AgentOutcome)> {
8876 let WaveCtx {
8877 run,
8878 node,
8879 prompts,
8880 cache,
8881 round,
8882 carry_seats: _,
8883 pauses,
8884 } = *ctx;
8885 for job in &jobs {
8886 state.seat_started(node, &job.seat.key, job.timeout, attempt);
8887 }
8888 if let Err(e) = state.save() {
8889 tracing::warn!("could not persist in-progress seats: {e:#}");
8894 }
8895 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8911 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8915 let cache_guard = if let Some(cache_dir) = cache {
8916 if jobs_had_a_writer {
8917 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8918 let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8919 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8920 {
8921 Ok(Some(g)) => Some(g),
8922 Ok(None) => {
8925 state.resource_block = Some(crate::run::ResourceBlock {
8926 node: node.to_owned(),
8927 kind: "shared build cache".to_owned(),
8928 detail: "the run is parking".to_owned(),
8929 at: Timestamp::now(),
8930 });
8931 return block_wave(
8932 jobs,
8933 state,
8934 node,
8935 attempt,
8936 "parked while waiting for the shared build cache",
8937 );
8938 }
8939 Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
8942 Err(e) => {
8943 let why = format!("{e:#}");
8947 state.resource_block = Some(crate::run::ResourceBlock {
8948 node: node.to_owned(),
8949 kind: "shared build cache".to_owned(),
8950 detail: why.clone(),
8951 at: Timestamp::now(),
8952 });
8953 state.event(
8954 "resource",
8955 format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
8956 );
8957 return block_wave(jobs, state, node, attempt, &why);
8958 }
8959 }
8960 } else {
8961 None
8962 }
8963 } else {
8964 None
8965 };
8966 state.seats_spawning(node, attempt);
8969 if let Err(e) = state.save() {
8970 tracing::warn!("could not persist the start of a wave: {e:#}");
8971 }
8972 let slot_limit = resource_limit
8976 + jobs
8977 .iter()
8978 .map(|j| j.timeout)
8979 .max()
8980 .unwrap_or_default()
8981 .saturating_mul(jobs.len() as u32);
8982 let mut set = tokio::task::JoinSet::new();
8983 let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
8986 let overlay = prompts.overlay(node);
8987 for (i, mut job) in jobs.into_iter().enumerate() {
8988 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
8989 if cache.is_some() {
8990 job.prompt.push('\n');
8991 job.prompt
8992 .push_str(&prompt::build_cache_note(node, job.allow_write));
8993 }
8994 let sem = Arc::clone(&sem);
8995 let run = run.to_owned();
8996 let node = node.to_owned();
8997 let attachments = if node == "implement" {
9000 state.attachments.clone()
9001 } else {
9002 Vec::new()
9003 };
9004 let cache = cache
9015 .filter(|_| job.allow_write && cache_guard.is_some())
9016 .map(Path::to_path_buf);
9017 let spawned_tx = spawned_tx.clone();
9018 set.spawn(async move {
9019 let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9023 Ok(p) => p,
9024 Err(_) => {
9025 return (
9026 i,
9027 job.seat,
9028 AgentOutcome::ResourceBlocked(format!(
9029 "no parallel slot became free within {}s",
9030 slot_limit.as_secs()
9031 )),
9032 );
9033 }
9034 };
9035 spawned_tx.send(job.seat.key.clone()).ok();
9036 let mut seat = job.seat;
9037 let out = agent::invoke(
9038 &job.spec,
9039 &mut seat,
9040 &Invocation {
9041 cwd: &job.cwd,
9042 prompt: &job.prompt,
9043 timeout: job.timeout,
9044 allow_write: job.allow_write,
9045 unsandboxed: false,
9046 sessions: job.sessions,
9047 artifacts: &job.artifacts,
9048 stem: &job.stem,
9049 run: &run,
9050 node: &node,
9051 cache_dir: cache.as_deref(),
9052 attachments: &attachments,
9053 writable: &[],
9054 },
9055 )
9056 .await;
9057 let out = match out {
9058 Ok(o) if o.usable() => AgentOutcome::Ok(o),
9059 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9060 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9068 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9069 Ok(o) => AgentOutcome::Failed(format!(
9070 "exited with {:?} and no usable output",
9071 o.exit_code
9072 )),
9073 Err(e) => AgentOutcome::Failed(e.to_string()),
9074 };
9075 (i, seat, out)
9076 });
9077 }
9078 drop(spawned_tx);
9079 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9080 loop {
9081 let joined = tokio::select! {
9082 Some(key) = spawned_rx.recv() => {
9083 state.seat_spawned(&key);
9085 if let Err(e) = state.save() {
9086 tracing::warn!("could not persist a seat's start: {e:#}");
9087 }
9088 continue;
9089 }
9090 joined = set.join_next() => joined,
9091 };
9092 let Some(joined) = joined else {
9093 break;
9094 };
9095 let (i, seat, out) = match joined {
9096 Ok(v) => v,
9097 Err(e) => {
9101 tracing::error!("agent task panicked: {e}");
9102 continue;
9103 }
9104 };
9105 state.seat_finished(&seat.key);
9106 if let AgentOutcome::ResourceBlocked(why) = &out
9107 && state.resource_block.is_none()
9108 {
9109 state.resource_block = Some(crate::run::ResourceBlock {
9110 node: node.to_owned(),
9111 kind: "parallel slot".to_owned(),
9112 detail: why.clone(),
9113 at: Timestamp::now(),
9114 });
9115 state.event("resource", format!("{node}: no agent was started - {why}"));
9116 }
9117 record_jobs(state, node, round, &seat.key, &out);
9118 if let Err(e) = state.save() {
9119 tracing::warn!("could not persist a seat's completion: {e:#}");
9120 }
9121 if collected.len() <= i {
9122 collected.resize_with(i + 1, || None);
9123 }
9124 collected[i] = Some((i, seat, out));
9125 }
9126 if state
9132 .active
9133 .values()
9134 .any(|a| a.node == node && a.attempt == attempt)
9135 {
9136 state
9137 .active
9138 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9139 if let Err(e) = state.save() {
9140 tracing::warn!("could not persist the end of a wave: {e:#}");
9141 }
9142 }
9143 if let Some(cache_dir) = cache
9150 && jobs_had_a_writer
9151 {
9152 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9153 }
9154 if let Some(guard) = cache_guard {
9155 guard.release();
9156 }
9157 collected.into_iter().flatten().collect()
9158}
9159
9160fn record_jobs(
9171 state: &mut RunState,
9172 node: &str,
9173 round: Option<usize>,
9174 seat: &str,
9175 out: &AgentOutcome,
9176) {
9177 let commands: &[agent::CommandEvidence] = match out {
9178 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9179 AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9180 };
9181 let checked_at = Timestamp::now();
9182 for c in commands {
9183 state.jobs.push(JobRecord {
9184 node: node.to_owned(),
9185 round,
9186 seat: seat.to_owned(),
9187 id: c.id.clone(),
9188 description: c.description.clone(),
9189 checked_at,
9190 status: match c.exit_code {
9191 Some(0) => JobStatus::Completed,
9192 Some(_) => JobStatus::Failed,
9193 None => JobStatus::Unknown,
9194 },
9195 exit_code: c.exit_code,
9196 result_summary: c.result_summary.clone(),
9197 source: c.source.clone(),
9198 });
9199 }
9200}
9201
9202fn round_is_clean(
9223 blocking: usize,
9224 e2e_ok: bool,
9225 answered: usize,
9226 expected: usize,
9227 quota_missing: usize,
9228 policy: IncompleteReviewPolicy,
9229) -> bool {
9230 if blocking != 0 || !e2e_ok {
9231 return false;
9232 }
9233 if answered == expected || policy == IncompleteReviewPolicy::Warn {
9234 return true;
9235 }
9236 answered > 0 && expected - answered <= quota_missing
9237}
9238
9239fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9263 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9264 return Some(RunStatus::Gating);
9265 }
9266 let last = reviews.last()?;
9267 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9268 if reviews.len() < max_rounds && !stagnant {
9269 return None;
9270 }
9271 if last.incomplete() && last.blocking == 0 {
9272 return Some(RunStatus::Blocked);
9273 }
9274 if last.e2e_status() == E2eStatus::ResourceBlocked {
9275 return None;
9276 }
9277 Some(if last.e2e.iter().all(CommandOutcome::ok) {
9278 RunStatus::Gating
9279 } else {
9280 RunStatus::Blocked
9281 })
9282}
9283
9284pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9299 if nudged {
9300 (full / 4).max(Duration::from_secs(120)).min(full)
9301 } else {
9302 full
9303 }
9304}
9305
9306#[allow(clippy::too_many_arguments)]
9335async fn ask_json_wave<T>(
9336 jobs: Vec<SeatJob>,
9337 sem: Arc<Semaphore>,
9338 retries: usize,
9339 roster: &[AgentSpec],
9340 ctx: &WaveCtx<'_>,
9341 losses: &mut Vec<QuotaLoss>,
9342 state: &mut RunState,
9343 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9344) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9345where
9346 T: serde::de::DeserializeOwned + Send + 'static,
9347{
9348 ask_wave_with(
9349 jobs,
9350 sem,
9351 retries,
9352 roster,
9353 ctx,
9354 losses,
9355 state,
9356 &|text: &str| {
9357 let v = verdict::extract_json::<T>(text)?;
9358 validate(&v)?;
9359 Ok(v)
9360 },
9361 )
9362 .await
9363}
9364
9365#[allow(clippy::too_many_arguments)]
9374async fn ask_wave_with<T>(
9375 jobs: Vec<SeatJob>,
9376 sem: Arc<Semaphore>,
9377 retries: usize,
9378 roster: &[AgentSpec],
9379 ctx: &WaveCtx<'_>,
9380 losses: &mut Vec<QuotaLoss>,
9381 state: &mut RunState,
9382 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9383) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9384where
9385 T: Send + 'static,
9386{
9387 let n = jobs.len();
9388 let originals: Vec<SeatJob> = jobs;
9389 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9390 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9391 let mut nudges: Vec<usize> = vec![0; n];
9398 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9402 let mut tried: Vec<BTreeSet<String>> = specs
9403 .iter()
9404 .map(|s| BTreeSet::from([s.id.clone()]))
9405 .collect();
9406 let starts: Vec<usize> = specs
9407 .iter()
9408 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9409 .collect();
9410 let carry = ctx.carry_seats && !roster.is_empty();
9411 let carried: Vec<BTreeSet<String>> = originals
9414 .iter()
9415 .map(|j| {
9416 state
9417 .seat_history
9418 .get(&j.seat.key)
9419 .filter(|_| carry)
9420 .map(|h| h.failed.clone())
9421 .unwrap_or_default()
9422 })
9423 .collect();
9424 let mut prev: Vec<Option<FailClass>> = originals
9425 .iter()
9426 .map(|j| {
9427 state
9428 .seat_history
9429 .get(&j.seat.key)
9430 .filter(|_| carry)
9431 .and_then(|h| h.last_fail.clone())
9432 })
9433 .collect();
9434 let next_agent =
9435 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9436 let others: BTreeSet<String> = specs
9437 .iter()
9438 .enumerate()
9439 .filter(|(j, _)| *j != i)
9440 .map(|(_, s)| s.id.clone())
9441 .collect();
9442 pick_successor(
9443 roster,
9444 starts[i],
9445 tried,
9446 carry.then(|| &carried[i]),
9447 &others,
9448 )
9449 .cloned()
9450 };
9451 let mut fresh: Vec<Option<String>> = vec![None; n];
9452 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9453 let mut pending: Vec<usize> = (0..n).collect();
9454
9455 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9460 for round in 0..max_rounds {
9461 if pending.is_empty() {
9462 break;
9463 }
9464 let mut batch = Vec::with_capacity(pending.len());
9465 let mut renudged: Vec<&str> = Vec::new();
9466 for &i in &pending {
9467 let src = &originals[i];
9468 let full: &str = match &src.handover {
9473 Some(h) if specs[i].id != src.spec.id => h,
9474 _ => &src.prompt,
9475 };
9476 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9479 (
9480 full.to_owned(),
9481 src.timeout,
9482 format!("{}-{word}-{}", src.stem, specs[i].id),
9483 )
9484 } else if nudges[i] == 0 {
9485 (full.to_owned(), src.timeout, src.stem.clone())
9486 } else {
9487 renudged.push(src.seat.key.as_str());
9488 let why = done[i]
9489 .as_ref()
9490 .and_then(|r| r.as_ref().err().map(ToString::to_string))
9491 .unwrap_or_else(|| "no parsable answer".to_owned());
9492 let nudge = prompt::nudge(&why);
9493 let nudged = has_context(&specs[i], &seats[i], src.sessions);
9494 let prompt = if nudged {
9495 nudge
9496 } else {
9497 format!("{full}\n\n---\n\n{nudge}")
9498 };
9499 (
9500 prompt,
9501 retry_budget(src.timeout, nudged),
9502 format!("{}-retry{}", src.stem, nudges[i]),
9503 )
9504 };
9505 batch.push(SeatJob {
9506 spec: specs[i].clone(),
9507 seat: seats[i].clone(),
9508 cwd: src.cwd.clone(),
9509 prompt,
9510 timeout,
9511 allow_write: src.allow_write,
9512 sessions: src.sessions,
9513 artifacts: src.artifacts.clone(),
9514 stem,
9515 handover: None,
9516 });
9517 }
9518
9519 if !renudged.is_empty() {
9520 state.event(
9521 ctx.node,
9522 format!("retry {round}: re-asking {}", renudged.join(", ")),
9523 );
9524 }
9525 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9526 let mut still = Vec::new();
9527 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9528 seats[i] = seat;
9529 if let AgentOutcome::ResourceBlocked(why) = &out {
9532 done[i] = Some(Err(anyhow::anyhow!("{why}")));
9533 continue;
9534 }
9535 let class = FailClass::of(&out);
9536 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9539 && nudges[i] < retries
9540 && !roster.is_empty();
9541 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9542 let next = should_hand_over(prev[i].as_ref(), &cur)
9543 .then(|| next_agent(i, &tried[i], &specs))
9544 .flatten();
9545 if carry {
9546 let h = state
9547 .seat_history
9548 .entry(originals[i].seat.key.clone())
9549 .or_default();
9550 h.failed.insert(specs[i].id.clone());
9551 h.last_fail = Some(cur.clone());
9552 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9553 h.last_ok = None;
9554 }
9555 if let Err(e) = state.save() {
9558 tracing::warn!("could not persist a seat's failure history: {e:#}");
9559 }
9560 }
9561 if let Some(next) = next {
9562 record_handover(
9563 state,
9564 ctx.node,
9565 &originals[i].seat.key,
9566 &specs[i].id,
9567 &next.id,
9568 &cur,
9569 &fail_reason(&out),
9570 );
9571 tried[i].insert(next.id.clone());
9572 prev[i] = Some(cur.clone());
9573 seats[i] =
9574 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9575 specs[i] = next;
9576 fresh[i] = Some(cur.stem_word().to_owned());
9577 nudges[i] = 0;
9578 done[i] = Some(Err(anyhow::anyhow!(
9579 "handed over after: {}",
9580 fail_reason(&out)
9581 )));
9582 still.push(i);
9583 continue;
9584 }
9585 }
9586 if carry
9587 && !nudge_first
9588 && let Some(cur) = class.clone()
9589 {
9590 let h = state
9592 .seat_history
9593 .entry(originals[i].seat.key.clone())
9594 .or_default();
9595 h.failed.insert(specs[i].id.clone());
9596 h.last_fail = Some(cur);
9597 }
9598 let parsed = match out {
9599 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9600 AgentOutcome::Quota(o) => {
9601 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9602 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9603 }
9604 AgentOutcome::Dropped(o) => {
9608 let why = o
9609 .dropped
9610 .as_ref()
9611 .map(|d| d.why.as_str())
9612 .unwrap_or("the CLI ended the stream without delivering its answer");
9613 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9614 }
9615 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9616 Err(anyhow::anyhow!(e))
9617 }
9618 };
9619 let quota = class == Some(FailClass::Quota);
9620 let failed = parsed.is_err();
9621 done[i] = Some(parsed);
9622 if carry && !failed {
9623 let h = state
9624 .seat_history
9625 .entry(originals[i].seat.key.clone())
9626 .or_default();
9627 h.failed.remove(&specs[i].id);
9628 h.last_ok = Some(specs[i].id.clone());
9629 h.last_fail = None;
9630 }
9631 let agent_failure = class.is_some()
9639 && !nudge_first
9640 && !roster.is_empty()
9641 && next_agent(i, &tried[i], &specs).is_some();
9642 if failed && !quota && !agent_failure && nudges[i] < retries {
9643 nudges[i] += 1;
9644 still.push(i);
9645 }
9646 }
9647 pending = still;
9648 }
9649
9650 for (i, q) in last_quota.into_iter().enumerate() {
9653 if let Some(reset) = q {
9654 losses.push(QuotaLoss {
9655 seat: originals[i].seat.key.clone(),
9656 node: ctx.node.to_owned(),
9657 at: Timestamp::now(),
9658 reset,
9659 });
9660 }
9661 }
9662
9663 seats
9664 .into_iter()
9665 .zip(done)
9666 .zip(nudges)
9667 .map(|((seat, res), attempts)| {
9668 (
9669 seat,
9670 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9671 attempts,
9672 )
9673 })
9674 .collect()
9675}
9676
9677fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9679 results
9680 .iter()
9681 .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9682}
9683
9684fn block_wave(
9688 jobs: Vec<SeatJob>,
9689 state: &mut RunState,
9690 node: &str,
9691 attempt: usize,
9692 why: &str,
9693) -> Vec<(usize, SeatState, AgentOutcome)> {
9694 state.resource_wait = None;
9695 state
9696 .active
9697 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9698 if let Err(e) = state.save() {
9699 tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9700 }
9701 jobs.into_iter()
9702 .enumerate()
9703 .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9704 .collect()
9705}
9706
9707async fn acquire_cache_lease(
9720 state: &mut RunState,
9721 cache_dir: &Path,
9722 owner: &crate::cache::Owner,
9723 budget: Duration,
9724 context: &str,
9725 cancel: &(dyn Fn() -> bool + Sync),
9726) -> Result<Option<crate::cache::Guard>> {
9727 let home = crate::run::home();
9728 let started = Instant::now();
9729 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9730 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9731 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9732 Err(e) => {
9733 state.event(
9734 "verify",
9735 format!("{context}: could not check the shared build cache: {e:#}"),
9736 );
9737 if let Err(e2) = state.save() {
9738 tracing::warn!("could not persist a cache-check failure: {e2:#}");
9739 }
9740 return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9741 }
9742 };
9743 state.event(
9744 "verify",
9745 format!(
9746 "{context}: waiting for the shared build cache at {} ({})",
9747 cache_dir.display(),
9748 busy.describe()
9749 ),
9750 );
9751 state.resource_wait = Some(crate::run::ResourceWait {
9754 node: context.to_owned(),
9755 kind: "shared build cache".to_owned(),
9756 holder: busy.describe(),
9757 since: Timestamp::now(),
9758 limit_secs: budget.as_secs(),
9759 });
9760 if let Err(e) = state.save() {
9761 tracing::warn!("could not persist a cache wait: {e:#}");
9762 }
9763 let remaining = budget.saturating_sub(started.elapsed());
9764 let waited = crate::cache::wait_for_unless(
9765 &home,
9766 cache_dir,
9767 owner,
9768 remaining,
9769 Duration::from_secs(5),
9770 cancel,
9771 )
9772 .await;
9773 state.resource_wait = None;
9774 match waited {
9775 Ok(g) => Ok(g),
9776 Err(e) => {
9777 state.event("verify", format!("{context}: {e:#}"));
9778 if let Err(e2) = state.save() {
9779 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9780 }
9781 Err(e)
9782 }
9783 }
9784}
9785
9786#[derive(Debug)]
9791struct CacheCheckError(String);
9792
9793impl std::fmt::Display for CacheCheckError {
9794 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9795 f.write_str(&self.0)
9796 }
9797}
9798
9799impl std::error::Error for CacheCheckError {}
9800
9801#[allow(clippy::too_many_arguments)]
9822async fn with_cache_lease<'s, F, Fut>(
9823 state: &'s mut RunState,
9824 cache_dir: Option<&Path>,
9825 node: &str,
9826 seat: &str,
9827 worktree: &Path,
9828 head: &str,
9829 budget: Duration,
9830 context: &str,
9831 body: F,
9832) -> (Vec<CommandOutcome>, bool)
9833where
9834 F: FnOnce(&'s mut RunState, Duration) -> Fut,
9835 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9836{
9837 let Some(cache_dir) = cache_dir else {
9838 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9839 return (outcomes, retried);
9840 };
9841 let home = crate::run::home();
9842 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9843 let started = Instant::now();
9844 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9845 .await
9846 .and_then(|g| {
9847 g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9848 }) {
9849 Ok(g) => g,
9850 Err(e) => {
9851 return (
9852 vec![CommandOutcome {
9853 command: "(waiting for the shared build cache)".to_owned(),
9854 code: None,
9855 output_tail: e.to_string(),
9856 duration_ms: started.elapsed().as_millis() as u64,
9857 resource_blocked: true,
9858 }],
9859 false,
9860 );
9861 }
9862 };
9863 let identity = crate::cache::Identity::new(worktree, head);
9864 if let Err(e) = crate::cache::ensure_fresh(
9865 &home,
9866 cache_dir,
9867 &identity,
9868 &state.config.verify.cargo_manifests(),
9869 ) {
9870 state.event(
9878 "verify",
9879 format!(
9880 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9881 worktree.display(),
9882 short(head)
9883 ),
9884 );
9885 guard.release();
9886 return (
9887 vec![CommandOutcome {
9888 command: "(confirming the shared build cache is fresh)".to_owned(),
9889 code: None,
9890 output_tail: e.to_string(),
9891 duration_ms: started.elapsed().as_millis() as u64,
9892 resource_blocked: true,
9893 }],
9894 false,
9895 );
9896 }
9897 let remaining = budget.saturating_sub(started.elapsed());
9898 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9899 if !timed_out_pids.is_empty() {
9904 wait_for_timed_out_children_to_die(&timed_out_pids).await;
9905 }
9906 guard.release();
9907 (outcomes, retried)
9908}
9909
9910async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9922 wait_for_pids_with(
9923 pids,
9924 crate::proc::pid_alive,
9925 LEASE_RELEASE_POLL,
9926 LEASE_RELEASE_MAX_WAIT,
9927 )
9928 .await;
9929}
9930
9931async fn wait_for_pids_with<F: Fn(u32) -> bool>(
9937 pids: &[u32],
9938 alive: F,
9939 poll: Duration,
9940 max_wait: Duration,
9941) {
9942 let deadline = Instant::now() + max_wait;
9943 loop {
9944 if pids.iter().all(|&pid| !alive(pid)) {
9945 return;
9946 }
9947 if Instant::now() >= deadline {
9948 return;
9949 }
9950 tokio::time::sleep(poll).await;
9951 }
9952}
9953
9954fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
9961 outcomes.iter().any(|o| o.resource_blocked)
9962}
9963
9964enum GateFix {
9966 Retry,
9968 Stop,
9971 Defer,
9974}
9975
9976fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
9984 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
9985 red.peek().is_some()
9986 && red.all(|o| {
9987 !o.resource_blocked
9988 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
9989 && !o.output_tail.trim().is_empty()
9990 })
9991}
9992
9993fn e2e_outcome_label(o: &CommandOutcome) -> String {
9997 if o.ok() {
9998 return "pass".to_owned();
9999 }
10000 let reason = if o.build_failed() {
10001 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10002 } else {
10003 format!("FAIL ({:?})", o.code)
10004 };
10005 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10006}
10007
10008async fn run_e2e_with_retry(
10016 state: &mut RunState,
10017 shell: &[String],
10018 commands: &[String],
10019 worktree: &Path,
10020 timeout: Duration,
10021 context: &str,
10022) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10023 let (mut e2e, mut timed_out_pids) = run_commands(
10024 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10025 )
10026 .await;
10027 for o in &e2e {
10028 state.event(
10029 "verify",
10030 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10031 );
10032 }
10033 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10037 if verify_retried {
10038 state.event(
10039 "verify",
10040 format!(
10041 "{context}: verify could not build/link, not a test result — retrying once \
10042 before concluding"
10043 ),
10044 );
10045 let retried = run_commands(
10046 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10047 )
10048 .await;
10049 e2e = retried.0;
10050 timed_out_pids.extend(retried.1);
10053 for o in &e2e {
10054 state.event(
10055 "verify",
10056 format!(
10057 "{context}: retry `{}` -> {}",
10058 o.command,
10059 e2e_outcome_label(o)
10060 ),
10061 );
10062 }
10063 }
10064 (e2e, verify_retried, timed_out_pids)
10065}
10066
10067#[allow(clippy::too_many_arguments)]
10083async fn run_commands(
10084 state: &mut RunState,
10085 node: &str,
10086 task: &str,
10087 attempt: usize,
10088 shell: &[String],
10089 commands: &[String],
10090 cwd: &Path,
10091 timeout: Duration,
10092) -> (Vec<CommandOutcome>, Vec<u32>) {
10093 if commands.is_empty() {
10094 return (Vec::new(), Vec::new());
10099 }
10100 let mut out = Vec::new();
10101 let mut timed_out_pids = Vec::new();
10102 let total = commands.len();
10103 for (idx, command) in commands.iter().enumerate() {
10104 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10105 if let Err(e) = state.save() {
10106 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10107 }
10108 let started = Instant::now();
10109 let mut cmd = tokio::process::Command::new(&shell[0]);
10110 cmd.quiet();
10111 cmd.args(&shell[1..])
10112 .arg(command)
10113 .current_dir(cwd)
10114 .stdin(std::process::Stdio::null())
10115 .stdout(std::process::Stdio::piped())
10116 .stderr(std::process::Stdio::piped())
10117 .kill_on_drop(true);
10118 let spawned = cmd.spawn();
10119 let (code, body) = match spawned {
10120 Ok(child) => {
10121 let pid = child.id();
10126 match tokio::time::timeout(timeout, child.wait_with_output()).await {
10127 Ok(Ok(o)) => {
10128 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10129 body.push_str(&String::from_utf8_lossy(&o.stderr));
10130 (o.status.code(), body)
10131 }
10132 Ok(Err(e)) => (None, format!("failed to run: {e}")),
10133 Err(_) => {
10134 if let Some(pid) = pid {
10135 timed_out_pids.push(pid);
10136 }
10137 (None, format!("timed out after {}s", timeout.as_secs()))
10138 }
10139 }
10140 }
10141 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10142 };
10143 out.push(CommandOutcome {
10144 command: command.clone(),
10145 code,
10146 output_tail: tail(&body, OUTPUT_TAIL),
10147 duration_ms: started.elapsed().as_millis() as u64,
10148 resource_blocked: false,
10149 });
10150 }
10151 state.task_finished(task);
10152 if let Err(e) = state.save() {
10153 tracing::warn!("could not persist the end of {task}: {e:#}");
10154 }
10155 (out, timed_out_pids)
10156}
10157
10158fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10170 let repo = repo.display();
10171 match style {
10172 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10173 MergeStyle::Squash => {
10174 let subject = message
10177 .lines()
10178 .next()
10179 .unwrap_or(branch)
10180 .replace(['\\', '"', '$', '`'], "");
10181 format!(
10182 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10183 )
10184 }
10185 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10186 }
10187}
10188
10189const PR_TITLE_MAX: usize = 240;
10200
10201fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10212 let line = queue::first_line(text)?;
10213 let chars: Vec<char> = line.chars().collect();
10214 let (kept, mark) = if chars.len() <= max {
10215 (line.to_owned(), "")
10216 } else if let Some(end) = sentence_end(&chars, max) {
10217 (chars[..end].iter().collect::<String>(), "")
10218 } else {
10219 let room = max.saturating_sub(3);
10220 let cut = if chars[room].is_whitespace() {
10223 room
10224 } else {
10225 chars[..room]
10226 .iter()
10227 .rposition(|c| c.is_whitespace())
10228 .unwrap_or(room)
10229 };
10230 let head: String = chars[..cut].iter().collect();
10231 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10232 (head.to_owned(), "...")
10233 };
10234 if kept.is_empty() {
10235 return None;
10236 }
10237 if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10238 return None;
10239 }
10240 if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10241 return None;
10242 }
10243 Some(format!("{kept}{mark}"))
10244}
10245
10246fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10250 const MIN: usize = 20;
10251 for i in MIN..max.min(chars.len()) {
10252 if !matches!(chars[i], '.' | '!' | '?') {
10253 continue;
10254 }
10255 let Some(&next) = chars.get(i + 1) else {
10256 continue;
10257 };
10258 if !next.is_whitespace() {
10259 continue;
10260 }
10261 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10262 if after.is_some_and(|c| c.is_ascii_lowercase()) {
10263 continue;
10264 }
10265 let word: String = chars[..i]
10266 .iter()
10267 .rev()
10268 .take_while(|c| !c.is_whitespace())
10269 .collect::<Vec<_>>()
10270 .into_iter()
10271 .rev()
10272 .collect();
10273 let word = word.to_ascii_lowercase();
10274 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10275 continue;
10276 }
10277 let end = chars[..i]
10278 .iter()
10279 .rposition(|c| !c.is_whitespace())
10280 .map_or(i, |p| p + 1);
10281 return Some(end);
10282 }
10283 None
10284}
10285
10286struct PrMessage {
10291 title: String,
10292 body: String,
10293}
10294
10295impl PrMessage {
10296 fn commit_message(&self) -> String {
10300 format!("{}\n\n{}", self.title, self.body)
10301 }
10302}
10303
10304fn title_marker(line: &str) -> Option<&str> {
10306 let line = line.trim();
10307 let head = line.get(..6)?;
10308 head.eq_ignore_ascii_case("title:")
10309 .then(|| line[6..].trim())
10310}
10311
10312fn summary_title(summary: &str) -> Option<String> {
10317 let first = summary.lines().find(|l| !l.trim().is_empty())?;
10318 let raw = title_marker(first)?;
10319 if raw.is_empty() {
10320 return None;
10321 }
10322 let title = queue::title_from(raw, PR_TITLE_MAX);
10323 let lower = title.to_ascii_lowercase();
10324 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10325 return None;
10326 }
10327 Some(title)
10328}
10329
10330const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10332
10333const EXISTING_BRANCH: &str = "(existing branch)";
10335
10336fn is_review_run(state: &RunState) -> bool {
10340 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10341}
10342
10343fn review_title(state: &RunState) -> Option<String> {
10348 english_subject(
10349 state.reviewed_commits.as_ref()?.first()?,
10350 state.config.graph.github_english(),
10351 )
10352}
10353
10354fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10357 let raw = raw.trim();
10358 if raw.is_empty() {
10359 return None;
10360 }
10361 if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10362 return None;
10363 }
10364 if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10365 return None;
10366 }
10367 let title = queue::title_from(raw, PR_TITLE_MAX);
10368 let lower = title.to_ascii_lowercase();
10369 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10370 return None;
10371 }
10372 Some(title)
10373}
10374
10375#[derive(Debug, Clone, PartialEq, Eq)]
10378struct BranchFacts {
10379 commits: Vec<(String, String)>,
10381 stat: String,
10383}
10384
10385const STAT_FILE_LINES: usize = 25;
10387const COMMITS_MAX_CHARS: usize = 20_000;
10389
10390async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10393 let commits = git::commit_log(repo, base, branch).await.ok()?;
10394 if commits.is_empty() {
10395 return None;
10396 }
10397 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10398 let lines: Vec<&str> = stat.lines().collect();
10399 let stat = if lines.len() > STAT_FILE_LINES + 1 {
10400 let omitted = lines.len() - 1 - STAT_FILE_LINES;
10401 let more = format!(" ... {omitted} more file(s)");
10402 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10403 kept.push(&more);
10404 kept.push(lines[lines.len() - 1]);
10405 kept.join("\n")
10406 } else {
10407 lines.join("\n")
10408 };
10409 Some(BranchFacts { commits, stat })
10410}
10411
10412fn markdown_safe(text: &str) -> String {
10415 text.replace("</details>", "</details>")
10416 .replace("\x60\x60\x60", "~~~")
10417}
10418
10419fn neutral_title(state: &RunState, winner: char) -> String {
10420 format!(
10421 "chore: land candidate {} of run {}",
10422 winner.to_ascii_uppercase(),
10423 state.id
10424 )
10425}
10426
10427pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10433 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10434 ""
10435 } else {
10436 pr_title
10437 }
10438}
10439
10440pub fn landing_subject_source(state: &RunState) -> String {
10444 if is_review_run(state) {
10445 let winner = state.candidates.first().map_or('A', |c| c.label);
10446 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10447 }
10448 state.instruction.clone()
10449}
10450
10451fn summary_without_title(summary: &str) -> String {
10454 let mut lines = summary.trim().lines().peekable();
10455 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10456 lines.next();
10457 }
10458 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10459}
10460
10461#[cfg(test)]
10473fn pr_message(state: &RunState, winner: char) -> PrMessage {
10474 pr_message_with(state, winner, None)
10475}
10476
10477#[cfg(test)]
10480fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10481 let raw = pr_message_raw(
10482 state,
10483 winner,
10484 facts,
10485 crate::pr_outcome::FinalHead::Unreported,
10486 ChangeNote::NotAsked,
10487 );
10488 let id = crate::scrub::Identity::current();
10489 PrMessage {
10490 title: crate::scrub::scrub(&raw.title, &id),
10491 body: crate::scrub::scrub(&raw.body, &id),
10492 }
10493}
10494
10495#[derive(Debug, Clone, Copy)]
10497enum ChangeNote<'a> {
10498 NotAsked,
10500 Summary(&'a crate::run::ChangeSummary),
10502 Unavailable(&'a str),
10504}
10505
10506fn pr_message_raw(
10507 state: &RunState,
10508 winner: char,
10509 facts: Option<&BranchFacts>,
10510 head: crate::pr_outcome::FinalHead<'_>,
10511 change: ChangeNote<'_>,
10512) -> PrMessage {
10513 if !matches!(change, ChangeNote::NotAsked) {
10514 let (title, mut body) = match change {
10515 ChangeNote::Summary(c) => (
10516 c.title.clone(),
10517 format!("## Summary\n\n{}\n\n", c.body.trim()),
10518 ),
10519 ChangeNote::Unavailable(why) => (
10520 neutral_title(state, winner),
10521 format!(
10522 "## Summary\n\nNo description of what this change does could be generated ({why}). Review the final diff; internal reports remain in local records.\n\n"
10523 ),
10524 ),
10525 ChangeNote::NotAsked => unreachable!(),
10526 };
10527 body.push_str(&public_pr_evidence(state, head));
10528 body.push_str(&format!(
10529 "\n---\nmagi:run/{} magi:candidate-{}\n",
10530 state.id,
10531 winner.to_ascii_lowercase()
10532 ));
10533 return PrMessage { title, body };
10534 }
10535 pr_preview(state, winner, facts, head)
10536}
10537
10538fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10541 let mut body = crate::pr_outcome::review_rounds(state);
10542 body.push_str(&crate::pr_outcome::final_verification(state, head));
10543 let open = state.last_round_findings();
10544 if !open.is_empty() {
10545 body.push_str("## Open review findings\n\n");
10546 for f in open {
10547 body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10548 }
10549 body.push('\n');
10550 }
10551 if let Some(c) = state.change_summary.as_ref()
10552 && head == crate::pr_outcome::FinalHead::At(&c.head)
10553 {
10554 body.push_str(&format!(
10555 "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10556 c.base, c.head
10557 ));
10558 }
10559 body
10560}
10561
10562fn pr_preview(
10564 state: &RunState,
10565 winner: char,
10566 facts: Option<&BranchFacts>,
10567 head: crate::pr_outcome::FinalHead<'_>,
10568) -> PrMessage {
10569 let summary = state
10570 .candidates
10571 .iter()
10572 .find(|c| c.label == winner)
10573 .map(|c| c.summary.as_str())
10574 .unwrap_or_default();
10575 let review = is_review_run(state);
10578 let title = if review {
10579 facts
10580 .and_then(|f| {
10581 english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10582 })
10583 .or_else(|| review_title(state))
10584 .or_else(|| {
10585 state
10586 .candidates
10587 .iter()
10588 .find(|c| c.label == winner)
10589 .filter(|c| !c.branch.starts_with("magi/"))
10590 .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10591 })
10592 .unwrap_or_else(|| neutral_title(state, winner))
10593 } else {
10594 summary_title(summary).unwrap_or_else(|| {
10595 english_title(
10596 &state.instruction,
10597 PR_TITLE_MAX,
10598 state.config.graph.github_english(),
10599 )
10600 .unwrap_or_else(|| neutral_title(state, winner))
10601 })
10602 };
10603
10604 let mut body = String::new();
10605 let what = summary_without_title(summary);
10606 if !what.is_empty() {
10607 body.push_str("## Summary\n\n");
10608 body.push_str(&what);
10609 body.push_str("\n\n");
10610 }
10611
10612 body.push_str(&crate::pr_outcome::review_rounds(state));
10614 body.push_str(&crate::pr_outcome::final_verification(state, head));
10615 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10616
10617 let open = state.open_findings();
10618 if !open.is_empty() {
10619 body.push_str("## Open review findings\n\n");
10620 for f in &open {
10621 body.push_str(&format!(
10622 "- `{}` [{:?}] {}\n",
10623 f.id, f.severity, "Context retained in local review records."
10624 ));
10625 }
10626 body.push('\n');
10627 }
10628
10629 if let Some(fix) = fix
10630 && !fix.rejected.is_empty()
10631 {
10632 body.push_str("## Declined by the fixer\n\n");
10633 for r in &fix.rejected {
10634 body.push_str(&format!(
10635 "- `{}`: {}\n",
10636 r.id, "Reason retained in local review records."
10637 ));
10638 }
10639 body.push('\n');
10640 }
10641
10642 if review {
10643 body.push_str("## Commits under review\n\n");
10645 if let Some(facts) = facts {
10646 let mut left = COMMITS_MAX_CHARS;
10647 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10648 let mut entry = format!("- {}\n", markdown_safe(subject));
10649 for l in markdown_safe(text).lines() {
10650 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10651 }
10652 if left == 0 {
10653 body.push_str(&format!(
10654 "- ... {} more commit(s)\n",
10655 facts.commits.len() - i
10656 ));
10657 break;
10658 }
10659 if entry.len() > left {
10660 let mut end = left;
10663 while !entry.is_char_boundary(end) {
10664 end -= 1;
10665 }
10666 entry.truncate(end);
10667 entry.push_str("\n ... (truncated)\n");
10668 left = 0;
10669 } else {
10670 left -= entry.len();
10671 }
10672 body.push_str(&entry);
10673 }
10674 if !facts.stat.trim().is_empty() {
10675 body.push_str(&format!(
10676 "\n## Diff stat\n\n```\n{}\n```\n",
10677 markdown_safe(facts.stat.trim())
10678 ));
10679 }
10680 } else {
10681 match &state.reviewed_commits {
10682 Some(subjects) => {
10683 for s in subjects {
10684 body.push_str(&format!("- {}\n", s.trim()));
10685 }
10686 }
10687 None => {
10688 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10691 body.push_str(rest.trim());
10692 body.push('\n');
10693 }
10694 }
10695 }
10696 } else {
10697 let task = state.instruction.trim();
10698 let task = if task.is_empty() {
10699 "(empty task)"
10700 } else {
10701 task
10702 };
10703 body.push_str(&format!(
10704 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10705 task.replace("</details>", "</details>")
10706 ));
10707 }
10708
10709 body.push_str(&format!(
10710 "\n---\nmagi:run/{} magi:candidate-{}\n",
10711 state.id,
10712 winner.to_ascii_lowercase()
10713 ));
10714
10715 PrMessage { title, body }
10716}
10717
10718fn seeded_instruction(state: &RunState) -> String {
10722 match refs::describe(&state.seeds) {
10723 Some(facts) => format!(
10724 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10725 Candidates start from the unmerged branch named above, when there \
10726 is one, and carry any unmerged commit named by sha as a \
10727 cherry-pick. Check that this is what the task meant before \
10728 building on it.",
10729 state.instruction
10730 ),
10731 None => state.instruction.clone(),
10732 }
10733}
10734
10735async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10739 let base = &state.base_branch;
10740 let mut against = base.clone();
10741 if mode == MergeMode::Pr {
10742 let remote = &state.config.merge.remote;
10746 let tracking = format!("{remote}/{base}");
10747 let fetched = git::fetch(repo, remote, base).await;
10748 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10749 against = tracking;
10750 } else {
10751 return false;
10752 }
10753 }
10754 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10755}
10756
10757fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10760 let mut detail = format!(
10761 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10762 nothing to open a pull request for"
10763 );
10764 match refs::describe(&state.seeds) {
10765 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10766 None => detail.push_str(
10767 "\nThe task names no existing branch or commit; if it means to land work \
10768 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10769 ),
10770 }
10771 detail
10772}
10773
10774#[derive(Debug, PartialEq, Eq)]
10777enum PrPlan {
10778 Create,
10779 Adopt { url: String, title: String },
10780 Stop(String),
10781}
10782
10783fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10786 match found {
10787 Ok(land::OpenPr::None) => PrPlan::Create,
10788 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10789 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10790 "several open pull requests exist for this branch, not picking one: {}",
10791 urls.join(" ")
10792 )),
10793 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10794 }
10795}
10796
10797pub(crate) async fn gh_pr_create(
10799 cwd: &Path,
10800 base: &str,
10801 head: &str,
10802 title: &str,
10803 body: &str,
10804) -> Result<String> {
10805 let out = tokio::process::Command::new("gh")
10806 .args([
10807 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10808 ])
10809 .current_dir(cwd)
10810 .quiet()
10811 .stdin(std::process::Stdio::null())
10812 .output()
10813 .await
10814 .context("spawn gh")?;
10815 if out.status.success() {
10816 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10817 } else {
10818 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10819 }
10820}
10821
10822pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10831 if crate::pr_recovery::sources_retained(home, &state.id) {
10832 return Ok(Vec::new());
10833 }
10834 let repo = state.repo.clone();
10835 let root = state.worktree_root();
10836 let winner = state.tally.as_ref().map(|t| t.winner);
10837 let mut removed = Vec::new();
10838
10839 for i in 0..state.candidates.len() {
10840 let c = state.candidates[i].clone();
10841 let is_winner = Some(c.label) == winner;
10842 if is_winner && !drop_winner {
10843 continue;
10844 }
10845 if c.worktree.exists() {
10846 git::worktree_remove(&repo, &c.worktree).await.ok();
10847 removed.push(c.worktree.to_string_lossy().into_owned());
10848 }
10849 let handed_over = state.released_branches.contains(&c.branch);
10852 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10853 git::branch_delete(&repo, &c.branch).await.ok();
10854 removed.push(c.branch.clone());
10855 }
10856 state.candidates[i].folded = true;
10857 }
10858
10859 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10860 let path = name.path();
10861 let keep = !drop_winner
10862 && winner.is_some_and(|w| {
10863 path.file_name()
10864 .is_some_and(|n| n == format!("cand-{w}").as_str())
10865 });
10866 if keep {
10867 continue;
10868 }
10869 git::worktree_remove(&repo, &path).await.ok();
10870 removed.push(path.to_string_lossy().into_owned());
10871 }
10872
10873 remove_if_empty(&root);
10882
10883 if state.enabled_worktree_config && drop_winner {
10884 git::release_worktree_config(&repo).await.ok();
10888 state.enabled_worktree_config = false;
10889 }
10890 state.save_under(home)?;
10891 Ok(removed)
10892}
10893
10894fn remove_if_empty(dir: &Path) {
10905 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10906 std::fs::remove_dir(dir).ok();
10907 }
10908}
10909
10910pub fn worst_open(state: &RunState) -> Option<Severity> {
10912 state
10913 .reviews
10914 .last()?
10915 .reviews
10916 .iter()
10917 .flat_map(|r| r.findings.iter())
10918 .map(|f| f.severity)
10919 .max()
10920}
10921
10922async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10926 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10927 git::acquire_worktree_config(repo).await?;
10928 state.enabled_worktree_config = true;
10929 }
10930 Ok(())
10931}
10932
10933#[cfg(test)]
10934mod tests {
10935 #[test]
10936 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
10937 let leaked = vec!["chore(deps): update a crate".to_owned()];
10938 let own = "fix(daemon): apply a chosen action";
10939 assert!(should_retitle("", own, &leaked));
10940 assert!(should_retitle(
10941 &format!("{REVIEW_PROMPT_OPENING} `x`"),
10942 own,
10943 &leaked
10944 ));
10945 assert!(should_retitle(
10946 "chore: land candidate A of run 1",
10947 own,
10948 &leaked
10949 ));
10950 assert!(should_retitle(
10951 "magi: candidate A (uncommitted work)",
10952 own,
10953 &leaked
10954 ));
10955 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
10956 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
10957 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
10958 }
10959
10960 #[test]
10961 fn pr_merge_plan_creates_adopts_or_stops() {
10962 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
10963 assert_eq!(
10964 pr_merge_plan(Ok(land::OpenPr::One {
10965 url: "u".into(),
10966 title: "t".into()
10967 })),
10968 PrPlan::Adopt {
10969 url: "u".into(),
10970 title: "t".into()
10971 }
10972 );
10973 let PrPlan::Stop(many) =
10974 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
10975 else {
10976 panic!("many must stop");
10977 };
10978 assert!(many.contains('a') && many.contains('b'));
10979 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
10980 panic!("a failed lookup must stop");
10981 };
10982 assert!(err.contains("bad token"));
10983 }
10984
10985 use super::*;
10986 use crate::run::GateStatus;
10987 use std::collections::BTreeMap;
10988 use std::time::Duration;
10989
10990 fn conductor() -> AgentSpec {
10991 AgentSpec {
10992 id: "conductor".to_owned(),
10993 kind: crate::config::AgentKind::Command,
10994 model: None,
10995 command: vec!["true".to_owned()],
10996 extra_args: Vec::new(),
10997 env: BTreeMap::new(),
10998 prompt_delivery: None,
10999 }
11000 }
11001
11002 fn spec(id: &str) -> AgentSpec {
11003 AgentSpec {
11004 id: id.to_owned(),
11005 kind: crate::config::AgentKind::Command,
11006 model: None,
11007 command: vec!["true".to_owned()],
11008 extra_args: Vec::new(),
11009 env: BTreeMap::new(),
11010 prompt_delivery: None,
11011 }
11012 }
11013
11014 fn ids(xs: &[&str]) -> BTreeSet<String> {
11015 xs.iter().map(|s| (*s).to_owned()).collect()
11016 }
11017
11018 #[test]
11019 fn pick_successor_skips_an_agent_another_seat_holds() {
11020 let roster = [spec("a"), spec("b"), spec("c")];
11022 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11023 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11024 }
11025
11026 #[test]
11027 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11028 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11030 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11031 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11032 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11033 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11034 }
11035
11036 #[test]
11037 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11038 let roster = [spec("a"), spec("b")];
11039 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11040 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11041 let carried = ids(&["b"]);
11042 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11043 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11044 }
11045
11046 #[test]
11047 fn pick_successor_returns_none_without_any_untried_successor() {
11048 let roster = [spec("a"), spec("b")];
11049 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11050 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11051 let carried = ids(&["a"]);
11052 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11053 }
11054
11055 #[test]
11056 fn pick_successor_rescue_avoids_another_seats_occupant() {
11057 let roster = [spec("a"), spec("b"), spec("c")];
11059 let carried = ids(&["b", "c"]);
11060 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11061 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11062 }
11063
11064 #[test]
11065 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11066 let roster = [spec("a"), spec("b"), spec("c")];
11067 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11068 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11069 }
11070
11071 #[test]
11072 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11073 let roster = [spec("a"), spec("b"), spec("c")];
11074 let failed = ids(&["a", "b", "c"]);
11075 let mut tried = ids(&["b"]);
11077 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11078 assert_eq!(first.id, "a");
11079 tried.insert(first.id.clone());
11080 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11081 assert_eq!(second.id, "c");
11082 tried.insert(second.id.clone());
11083 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11084 }
11085
11086 #[test]
11087 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11088 let roster = [spec("a"), spec("b")];
11089 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11090 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11091 }
11092
11093 #[test]
11094 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11095 let roster = [spec("a"), spec("b"), spec("c")];
11096 let h = SeatHistory {
11097 failed: ids(&["a"]),
11098 last_ok: Some("b".to_owned()),
11099 last_fail: None,
11100 };
11101 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11102 let gone = SeatHistory {
11104 last_ok: Some("zzz".to_owned()),
11105 ..h.clone()
11106 };
11107 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11108 let failed = SeatHistory {
11109 failed: ids(&["a", "b"]),
11110 last_ok: Some("b".to_owned()),
11111 last_fail: None,
11112 };
11113 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11114 }
11115
11116 #[test]
11117 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11118 let first = SeatState::new("review-1", "alpha", 7);
11119 let next = handover_seat("review-1", "gamma", 7);
11120 assert_ne!(first.claude_session, next.claude_session);
11121 }
11122
11123 #[test]
11124 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11125 let mut state = state_with_summary("x", "y");
11126 let a = handover_seat("review-1", "beta", state.next_seat_seed());
11127 let b = handover_seat("review-1", "beta", state.next_seat_seed());
11128 assert_ne!(a.claude_session, b.claude_session);
11129 }
11130
11131 #[test]
11132 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11133 let roster = [spec("a"), spec("b")];
11134 let h = SeatHistory {
11135 failed: ids(&["a", "b"]),
11136 ..SeatHistory::default()
11137 };
11138 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11139 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11140 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11141 }
11142
11143 #[test]
11150 fn failure_signature_ignores_numbers_and_paths() {
11151 assert_eq!(
11152 failure_signature("exited with Some(2) and no usable output"),
11153 failure_signature("exited with Some(137) and no usable output")
11154 );
11155 assert_eq!(
11156 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11157 failure_signature("cannot open /var/x.txt: denied")
11158 );
11159 assert_ne!(failure_signature("boom"), failure_signature("bang"));
11160 }
11161
11162 #[test]
11163 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11164 let other = FailClass::Other("x".into());
11165 assert!(should_hand_over(None, &FailClass::Quota));
11166 assert!(should_hand_over(Some(&other), &FailClass::Quota));
11167 assert!(should_hand_over(
11168 Some(&FailClass::Timeout),
11169 &FailClass::Timeout
11170 ));
11171 assert!(should_hand_over(None, &other));
11172 assert!(!should_hand_over(Some(&other), &other));
11173 assert!(should_hand_over(
11174 Some(&other),
11175 &FailClass::Other("y".into())
11176 ));
11177 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11179 assert!(should_hand_over(Some(&FailClass::Quota), &other));
11180 }
11181
11182 #[test]
11183 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11184 let a = SeatState::new("judge-1", "alpha", 7);
11185 let b = handover_seat("judge-1", "beta", 7);
11186 assert_ne!(a.claude_session, b.claude_session);
11187 assert_eq!(b.turns, 0);
11188 }
11189
11190 #[test]
11191 fn a_timeout_is_classified_apart_from_other_failures() {
11192 assert_eq!(
11193 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11194 Some(FailClass::Timeout)
11195 );
11196 assert!(matches!(
11197 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11198 Some(FailClass::Other(_))
11199 ));
11200 }
11201
11202 #[test]
11203 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11204 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11205 let tried = BTreeSet::from(["beta".to_owned()]);
11206 let next = next_untried_in_roster(&roster, 1, &tried);
11209 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11210 }
11211
11212 #[test]
11213 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11214 let roster = vec![spec("alpha"), spec("beta")];
11215 let tried = BTreeSet::from(["beta".to_owned()]);
11216 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11220 }
11221
11222 #[test]
11223 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11224 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11225 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11226 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11230 }
11231
11232 #[test]
11233 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11234 let roster = vec![spec("a"), spec("a"), spec("b")];
11235 let tried = BTreeSet::from(["a".to_owned()]);
11236 let next = next_untried_in_roster(&roster, 0, &tried);
11237 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11238 }
11239
11240 #[test]
11241 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11242 let roster = vec![spec("a"), spec("b")];
11243 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11244 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11245 }
11246
11247 #[test]
11248 fn remove_if_empty_only_ever_takes_a_bare_directory() {
11249 let dir = tempfile::tempdir().unwrap();
11250 let bay = dir.path().join("ffff");
11251
11252 remove_if_empty(&bay);
11254 assert!(!bay.exists());
11255
11256 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11259 remove_if_empty(&bay);
11260 assert!(bay.exists(), "non-empty directory must survive");
11261
11262 std::fs::remove_dir(bay.join("cand-A")).unwrap();
11264 remove_if_empty(&bay);
11265 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11266 }
11267
11268 #[test]
11277 fn a_full_panel_that_found_nothing_is_clean() {
11278 assert!(round_is_clean(
11279 0,
11280 true,
11281 2,
11282 2,
11283 0,
11284 IncompleteReviewPolicy::Block
11285 ));
11286 }
11287
11288 #[test]
11289 fn a_missing_seat_is_never_clean_under_the_default_policy() {
11290 assert!(!round_is_clean(
11291 0,
11292 true,
11293 1,
11294 2,
11295 0,
11296 IncompleteReviewPolicy::Block
11297 ));
11298 }
11299
11300 #[test]
11301 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11302 assert!(!round_is_clean(
11303 1,
11304 true,
11305 1,
11306 2,
11307 0,
11308 IncompleteReviewPolicy::Warn
11309 ));
11310 }
11311
11312 #[test]
11313 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11314 assert!(round_is_clean(
11315 0,
11316 true,
11317 1,
11318 2,
11319 0,
11320 IncompleteReviewPolicy::Warn
11321 ));
11322 }
11323
11324 #[test]
11325 fn a_full_panel_with_an_open_finding_is_not_clean() {
11326 assert!(!round_is_clean(
11327 1,
11328 true,
11329 2,
11330 2,
11331 0,
11332 IncompleteReviewPolicy::Block
11333 ));
11334 }
11335
11336 #[test]
11337 fn a_full_panel_with_a_red_e2e_is_not_clean() {
11338 assert!(!round_is_clean(
11339 0,
11340 false,
11341 2,
11342 2,
11343 0,
11344 IncompleteReviewPolicy::Block
11345 ));
11346 }
11347
11348 #[test]
11355 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11356 assert!(round_is_clean(
11359 0,
11360 true,
11361 1,
11362 2,
11363 1,
11364 IncompleteReviewPolicy::Block
11365 ));
11366 }
11367
11368 #[test]
11369 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11370 assert!(!round_is_clean(
11373 0,
11374 true,
11375 1,
11376 2,
11377 0,
11378 IncompleteReviewPolicy::Block
11379 ));
11380 }
11381
11382 #[test]
11383 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11384 assert!(!round_is_clean(
11385 1,
11386 true,
11387 1,
11388 2,
11389 1,
11390 IncompleteReviewPolicy::Block
11391 ));
11392 assert!(!round_is_clean(
11393 0,
11394 false,
11395 1,
11396 2,
11397 1,
11398 IncompleteReviewPolicy::Block
11399 ));
11400 }
11401
11402 #[test]
11403 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11404 assert!(!round_is_clean(
11408 0,
11409 true,
11410 0,
11411 2,
11412 2,
11413 IncompleteReviewPolicy::Block
11414 ));
11415 }
11416
11417 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11418 CommandOutcome {
11419 command: "test".to_owned(),
11420 code,
11421 output_tail: String::new(),
11422 duration_ms: 0,
11423 resource_blocked,
11424 }
11425 }
11426
11427 #[test]
11428 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11429 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11430 assert!(
11431 !verify_inconclusive(&[outcome(Some(1), false)]),
11432 "an ordinary failure is still evidence about the patch"
11433 );
11434 assert!(verify_inconclusive(&[outcome(None, true)]));
11435 assert!(
11436 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11437 "one inconclusive outcome taints the whole batch"
11438 );
11439 assert!(!verify_inconclusive(&[]));
11440 }
11441
11442 #[tokio::test]
11443 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11444 let calls = std::sync::atomic::AtomicUsize::new(0);
11448 let started = Instant::now();
11449 wait_for_pids_with(
11450 &[123],
11451 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11452 Duration::from_millis(5),
11453 Duration::from_secs(5),
11454 )
11455 .await;
11456 assert!(
11457 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11458 "must keep checking rather than deciding on the first answer"
11459 );
11460 assert!(
11461 started.elapsed() < Duration::from_secs(1),
11462 "must return the moment it is confirmed dead, not wait out the ceiling"
11463 );
11464 }
11465
11466 #[tokio::test]
11467 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11468 let started = Instant::now();
11469 wait_for_pids_with(
11470 &[123],
11471 |_| true, Duration::from_millis(5),
11473 Duration::from_millis(30),
11474 )
11475 .await;
11476 let elapsed = started.elapsed();
11477 assert!(
11478 elapsed >= Duration::from_millis(30),
11479 "must not give up before its own ceiling: {elapsed:?}"
11480 );
11481 assert!(
11482 elapsed < Duration::from_secs(1),
11483 "must not wait past its own ceiling either: {elapsed:?}"
11484 );
11485 }
11486
11487 #[tokio::test]
11488 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11489 let started = Instant::now();
11490 wait_for_pids_with(
11491 &[],
11492 |_| true,
11493 Duration::from_secs(5),
11494 Duration::from_secs(5),
11495 )
11496 .await;
11497 assert!(
11498 started.elapsed() < Duration::from_millis(200),
11499 "an empty pid list has nothing to confirm"
11500 );
11501 }
11502
11503 fn review_round(
11509 clean: bool,
11510 blocking: usize,
11511 answered: usize,
11512 expected: usize,
11513 progressed: bool,
11514 e2e_ok: bool,
11515 ) -> ReviewRound {
11516 ReviewRound {
11517 round: 1,
11518 head: "h".to_owned(),
11519 verified_head: None,
11520 verified_at: None,
11521 reviews: Vec::new(),
11522 e2e: vec![CommandOutcome {
11523 command: "test".to_owned(),
11524 code: Some(if e2e_ok { 0 } else { 1 }),
11525 output_tail: String::new(),
11526 duration_ms: 0,
11527 resource_blocked: false,
11528 }],
11529 verify_retried: false,
11530 e2e_deferred: false,
11531 e2e_defer_reason: None,
11532 fix: None,
11533 blocking,
11534 answered,
11535 expected,
11536 clean,
11537 progressed,
11538 vote_split: false,
11539 reconsideration: Vec::new(),
11540 verdict: None,
11541 }
11542 }
11543
11544 #[test]
11545 fn review_conclusion_is_none_when_nothing_has_run() {
11546 assert_eq!(review_conclusion(&[], 3), None);
11547 }
11548
11549 #[test]
11550 fn review_conclusion_is_none_while_rounds_remain() {
11551 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11552 assert_eq!(review_conclusion(&rounds, 3), None);
11553 }
11554
11555 #[test]
11556 fn review_conclusion_is_gating_once_a_round_is_clean() {
11557 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11558 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11559 }
11560
11561 #[test]
11562 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11563 let rounds = vec![
11564 review_round(false, 1, 2, 2, true, true),
11565 review_round(false, 1, 2, 2, true, true),
11566 ];
11567 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11568 }
11569
11570 #[test]
11571 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11572 let rounds = vec![
11573 review_round(false, 1, 2, 2, true, true),
11574 review_round(false, 1, 2, 2, true, false),
11575 ];
11576 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11577 }
11578
11579 #[test]
11580 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11581 let mut blocked = review_round(false, 1, 2, 2, true, false);
11588 blocked.e2e[0].resource_blocked = true;
11589 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11590 assert_eq!(review_conclusion(&rounds, 2), None);
11591 }
11592
11593 #[test]
11594 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11595 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11597 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11598 }
11599
11600 #[test]
11601 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11602 let rounds = vec![
11603 review_round(false, 1, 2, 2, false, true),
11604 review_round(false, 1, 2, 2, false, true),
11605 ];
11606 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11607 }
11608
11609 fn secs(n: u64) -> Duration {
11610 Duration::from_secs(n)
11611 }
11612
11613 #[tokio::test]
11616 async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11617 ask_test_home();
11618 let tmp = tempfile::tempdir().unwrap();
11619 let repo = tmp.path().join("repo");
11620 std::fs::create_dir_all(&repo).unwrap();
11621 init_repo(&repo);
11622 let g = |args: &[&str]| {
11623 let out = std::process::Command::new("git")
11624 .args(args)
11625 .current_dir(&repo)
11626 .quiet()
11627 .output()
11628 .unwrap();
11629 assert!(
11630 out.status.success(),
11631 "{}",
11632 String::from_utf8_lossy(&out.stderr)
11633 );
11634 };
11635 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11636 g(&["checkout", "-b", "historical"]);
11637 std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11638 g(&["add", "."]);
11639 g(&["commit", "-m", "old alternative"]);
11640 let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11641 g(&["checkout", "-b", "magi/task-selected", &base]);
11642 std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11643 g(&["add", "."]);
11644 g(&["commit", "-m", "measurement infrastructure"]);
11645 let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11646 std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11647 g(&["add", "."]);
11648 g(&["commit", "-m", "dependent implementation"]);
11649 let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11650 g(&["checkout", "main"]);
11651 std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11652 g(&["add", "."]);
11653 g(&["commit", "-m", "unrelated base change"]);
11654 let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11655 let origin = tmp.path().join("origin.git");
11656 g(&[
11657 "clone",
11658 "--bare",
11659 repo.to_str().unwrap(),
11660 origin.to_str().unwrap(),
11661 ]);
11662 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11663 g(&["fetch", "origin"]);
11664 let selection = crate::pr_recovery::Selection {
11665 url: "https://example.test/project/pull/1".into(),
11666 branch: "magi/task-selected".into(),
11667 head: head.clone(),
11668 instruction: "preserve measured behavior".into(),
11669 approved_at: jiff::Timestamp::now(),
11670 };
11671 let mut template = runner_at(RunStatus::Reviewing);
11672 template.state.repo = repo.clone();
11673 template.state.base_commit = current.clone();
11674 template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11675 template.state.origin = Some(crate::run::Origin::queue("selected"));
11676 template.state.pr_head = Some(selection.branch.clone());
11677 template.state.pr_recovery = Some(selection.clone());
11678 let mut runner =
11679 Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11680 .await
11681 .unwrap();
11682 let wt = runner.state.winner().unwrap().worktree.clone();
11683 assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11684 assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11685 assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11686 assert!(runner.state.seeds.is_empty());
11687 assert_eq!(runner.state.instruction, "preserve measured behavior");
11688 runner.sync_to_base().await.unwrap();
11689 assert_eq!(
11690 std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11691 "prerequisite"
11692 );
11693 assert_eq!(
11694 std::fs::read_to_string(wt.join("implementation")).unwrap(),
11695 "measured implementation"
11696 );
11697 assert_eq!(
11698 std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11699 "current main change"
11700 );
11701 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11702 assert_eq!(
11703 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11704 head
11705 );
11706
11707 let mut partial = runner_at(RunStatus::Reviewing);
11709 partial.state.repo = repo.clone();
11710 partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11711 partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11712 run: Some("exit 1".into()),
11713 ..Default::default()
11714 }];
11715 let selection = runner.state.pr_recovery.clone().unwrap();
11716 partial.state.pr_recovery = Some(selection.clone());
11717 partial.state.pr_head = Some(selection.branch.clone());
11718 partial.state.origin = Some(crate::run::Origin::queue("selected"));
11719 let id = partial.state.id.clone();
11720 let error = match Runner::open_selected(
11721 &repo,
11722 partial.state,
11723 partial.roles,
11724 1,
11725 runner.state.base_commit.clone(),
11726 selection,
11727 )
11728 .await
11729 {
11730 Ok(_) => panic!("setup must fail"),
11731 Err(e) => e,
11732 };
11733 assert_eq!(
11734 error
11735 .downcast_ref::<crate::pr_recovery::StartupFailed>()
11736 .unwrap()
11737 .run,
11738 id
11739 );
11740 let failed = RunState::load(&id).unwrap();
11741 assert_eq!(failed.status, RunStatus::Blocked);
11742 assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11743 assert!(Runner::resume(&id).is_err());
11744 runner.state.status = RunStatus::Ready;
11745 runner.state.driver_exited = true;
11746 runner.state.save().unwrap();
11747 let mut retry = runner_at(RunStatus::Reviewing);
11748 retry.state.repo = repo.clone();
11749 retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11750 let mut reselected = runner.state.pr_recovery.clone().unwrap();
11751 reselected.approved_at = jiff::Timestamp::now();
11752 retry.state.origin = Some(crate::run::Origin::queue("selected"));
11753 retry.state.pr_head = Some(reselected.branch.clone());
11754 retry.state.pr_recovery = Some(reselected.clone());
11755 crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11756 .await
11757 .unwrap();
11758 let restarted = Runner::open_selected(
11759 &repo,
11760 retry.state,
11761 retry.roles,
11762 1,
11763 runner.state.base_commit.clone(),
11764 reselected,
11765 )
11766 .await
11767 .unwrap();
11768 assert_eq!(
11769 git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
11770 .await
11771 .unwrap(),
11772 head
11773 );
11774 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11775 assert_eq!(
11776 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11777 head
11778 );
11779 }
11780
11781 #[tokio::test]
11782 async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
11783 ask_test_home();
11784 let tmp = tempfile::tempdir().unwrap();
11785 let repo = tmp.path().join("repo");
11786 std::fs::create_dir_all(&repo).unwrap();
11787 init_repo(&repo);
11788 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11789 git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
11790 std::fs::write(repo.join("referenced"), "required commit").unwrap();
11791 git::git(&repo, &["add", "."]).await.unwrap();
11792 git::git(&repo, &["commit", "-m", "required seed"])
11793 .await
11794 .unwrap();
11795 let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
11796 git::git(&repo, &["checkout", "main"]).await.unwrap();
11797 let mut runner = runner_at(RunStatus::Prep);
11798 runner.state.repo = repo.clone();
11799 runner.state.base_commit = base.clone();
11800 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
11801 runner.state.config.graph.advise = false;
11802 runner.state.config.blind.commit_msg_hook = false;
11803 runner.state.seeds = vec![refs::Seed {
11804 token: seed.clone(),
11805 sha: seed.clone(),
11806 kind: refs::SeedKind::Unmerged,
11807 branch: false,
11808 detail: String::new(),
11809 }];
11810 runner.roles.implementers = vec![spec("one"), spec("two")];
11811 runner.roles.judges = vec![spec("judge")];
11812 let label = blind::assign_labels(2, runner.state.seed)[0];
11813 let branch = runner.state.branch_for(label);
11814 let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
11815 git::worktree_add_branch(&repo, &worktree, &branch, &base)
11816 .await
11817 .unwrap();
11818 runner.state.candidates.push(Candidate {
11819 index: 0,
11820 label,
11821 agent: "one".into(),
11822 branch,
11823 worktree,
11824 summary: String::new(),
11825 stat: String::new(),
11826 files: 0,
11827 commits: 0,
11828 empty: false,
11829 failed: None,
11830 verified_noop: None,
11831 duration_ms: 0,
11832 folded: false,
11833 });
11834 runner.state.save().unwrap();
11835 runner.state = RunState::load(&runner.state.id).unwrap();
11837 assert_eq!(runner.state.prep_complete, Some(false));
11838 runner.prep().await.unwrap();
11839 assert_eq!(runner.state.prep_complete, Some(true));
11840 assert_eq!(runner.state.candidates.len(), 2);
11841 for candidate in &runner.state.candidates {
11842 assert_eq!(
11843 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11844 git::tree_of(&repo, &seed).await.unwrap()
11845 );
11846 assert_eq!(
11847 std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
11848 "required commit"
11849 );
11850 }
11851 assert!(runner.state.worktree_root().join("judge-1").exists());
11852 let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
11853 runner.prep().await.unwrap();
11854 assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
11855
11856 runner.state.prep_complete = Some(false);
11857 runner.state.config.worktree.setup = vec![crate::config::SetupStep {
11858 run: Some(format!(
11859 "test -f '{}'",
11860 tmp.path().join("dependency-ready").display()
11861 )),
11862 ..Default::default()
11863 }];
11864 assert!(runner.prep().await.is_err());
11865 assert!(runner.state.prep_failure.is_some());
11866 std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
11867 runner.state = RunState::load(&runner.state.id).unwrap();
11868 assert!(
11869 runner.prep().await.is_err(),
11870 "correcting the cause alone must not auto-retry"
11871 );
11872 let failed_at = runner.state.events.last().unwrap().at;
11873 assert!(
11874 runner.retry_preparation(failed_at).is_err(),
11875 "old approvals cannot unlock the hold"
11876 );
11877 let mut task = crate::queue::Task::new(
11878 "retry".into(),
11879 "retry setup".into(),
11880 repo.clone(),
11881 crate::queue::Source::Human,
11882 );
11883 task.start(runner.state.id.clone());
11884 task.hold_machine(Some("setup failed".into()));
11885 task.release_operator("cli", None);
11886 runner
11887 .retry_preparation(task.resumption.as_ref().unwrap().at)
11888 .unwrap();
11889 runner.prep().await.unwrap();
11890 assert!(runner.state.prep_failure.is_none());
11891 assert_eq!(runner.state.prep_complete, Some(true));
11892 assert_eq!(runner.state.candidates.len(), 2);
11893 assert!(runner.state.worktree_root().join("judge-1").exists());
11894 for candidate in &runner.state.candidates {
11895 assert_eq!(
11896 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11897 git::tree_of(&repo, &seed).await.unwrap()
11898 );
11899 }
11900 }
11901
11902 async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
11903 let mut heads = Vec::new();
11904 for candidate in candidates {
11905 heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
11906 }
11907 heads
11908 }
11909
11910 fn init_repo(dir: &Path) {
11911 let run = |args: &[&str]| {
11912 let out = std::process::Command::new("git")
11913 .args(args)
11914 .current_dir(dir)
11915 .quiet()
11916 .output()
11917 .expect("spawn git");
11918 assert!(
11919 out.status.success(),
11920 "git {args:?} failed: {}",
11921 String::from_utf8_lossy(&out.stderr)
11922 );
11923 };
11924 run(&["init", "-b", "main"]);
11925 run(&["config", "user.name", "magi test"]);
11926 run(&["config", "user.email", "magi@example.com"]);
11927 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11928 run(&["add", "-A"]);
11929 run(&["commit", "-m", "init"]);
11930 }
11931
11932 fn ask_test_home() {
11940 crate::run::pin_test_home();
11941 }
11942
11943 fn runner_at(status: RunStatus) -> Runner {
11946 let mut state = RunState::new(
11947 PathBuf::from("/nonexistent/repo"),
11948 "main".to_owned(),
11949 "deadbeef".to_owned(),
11950 "task".to_owned(),
11951 Config::default(),
11952 );
11953 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11956 state.id = format!(
11957 "20000101-000000-{:04x}",
11958 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
11959 );
11960 state.status = status;
11961 Runner {
11962 state,
11963 roles: ResolvedRoles {
11964 implementers: Vec::new(),
11965 judges: Vec::new(),
11966 reviewers: Vec::new(),
11967 fixer: None,
11968 conductor: conductor(),
11969 implementer_roster: Vec::new(),
11970 judge_roster: Vec::new(),
11971 reviewer_roster: Vec::new(),
11972 },
11973 sem: Arc::new(Semaphore::new(1)),
11974 pause: Pause::new(),
11975 interrupt: Pause::new(),
11976 }
11977 }
11978
11979 fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
11980 crate::run::MergeRepair {
11981 question: "repair-question".into(),
11982 head: "old-head".into(),
11983 pr: "https://github.com/o/r/pull/42".into(),
11984 reviews: vec![review_round(false, 1, 1, 1, false, true)],
11985 gate: vec![outcome(Some(0), false)],
11986 gate_head: Some("old-head".into()),
11987 gate_fixes: Vec::new(),
11988 pre_gate: Vec::new(),
11989 phase,
11990 failure: None,
11991 }
11992 }
11993
11994 #[tokio::test]
11995 async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
11996 crate::run::pin_test_home();
11997 let mut runner = runner_at(RunStatus::Landing);
11998 runner.state.merge_repairs.push(merge_repair_request(
11999 crate::run::MergeRepairPhase::Requested,
12000 ));
12001 runner.state.save().unwrap();
12002 let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
12003 assert!(
12004 runner
12005 .execute()
12006 .await
12007 .unwrap_err()
12008 .to_string()
12009 .contains("already running")
12010 );
12011 assert_eq!(
12012 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12013 crate::run::MergeRepairPhase::Requested
12014 );
12015 drop(claim);
12016 let mut disk = runner.state.clone();
12017 disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12018 disk.save().unwrap();
12019 assert!(
12020 runner
12021 .execute()
12022 .await
12023 .unwrap_err()
12024 .to_string()
12025 .contains("decision changed")
12026 );
12027 assert_eq!(
12028 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12029 crate::run::MergeRepairPhase::Complete
12030 );
12031 }
12032
12033 #[test]
12034 fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12035 crate::run::pin_test_home();
12036 let mut runner = runner_at(RunStatus::Landing);
12037 let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12038 runner.state.reviews = request.reviews.clone();
12039 runner.state.gate = request.gate.clone();
12040 runner.state.gate_ran = true;
12041 runner.state.land_approval = Some(crate::run::LandApproval {
12042 question: "old".into(),
12043 head: "old-head".into(),
12044 });
12045 runner.state.merge_repairs.push(request);
12046 runner.begin_merge_repair_review(0).unwrap();
12047 assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12048 assert!(!runner.state.gate_status().ok());
12049 assert!(runner.state.land_approval.is_none());
12050 let disk = RunState::load(&runner.state.id).unwrap();
12051 assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12052 assert!(disk.merge_repairs[0].gate[0].ok());
12053 assert_eq!(
12054 disk.merge_repairs[0].phase,
12055 crate::run::MergeRepairPhase::Reviewing
12056 );
12057 assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12058 assert!(disk.reviews.is_empty());
12059 }
12060
12061 #[tokio::test]
12062 async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12063 crate::run::pin_test_home();
12064 for phase in [
12065 crate::run::MergeRepairPhase::Fixing,
12066 crate::run::MergeRepairPhase::Requested,
12067 ] {
12068 let mut runner = runner_at(RunStatus::Landing);
12069 runner.state.config.graph.land_rounds = 0;
12070 runner.state.merge_repairs.push(merge_repair_request(phase));
12071 runner.state.save().unwrap();
12072 assert!(!runner.repair_merge_review().await.unwrap());
12073 assert_eq!(runner.state.status, RunStatus::Blocked);
12074 assert_eq!(
12075 runner.state.merge_repairs[0].phase,
12076 crate::run::MergeRepairPhase::Failed
12077 );
12078 let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12079 assert!(
12080 reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12081 "interrupted"
12082 } else {
12083 "budget exhausted"
12084 })
12085 );
12086 assert!(runner.state.merge.is_none());
12087 }
12088 }
12089
12090 #[test]
12094 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12095 crate::run::pin_test_home();
12096 let mut runner = runner_at(RunStatus::Implementing);
12097 let interrupt = Pause::new();
12098 runner.watch_interrupt(interrupt.clone());
12099
12100 interrupt.park_because("task a1b2 asked to run first");
12101
12102 assert!(runner.park_here().expect("park_here"));
12103 assert!(runner.state.parked);
12104 let last = runner.state.events.last().expect("a park event");
12105 assert_eq!(last.node, "park");
12106 assert!(
12107 last.message.contains("task a1b2 asked to run first"),
12108 "expected the interrupt reason in {:?}",
12109 last.message
12110 );
12111 }
12112
12113 #[test]
12116 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12117 crate::run::pin_test_home();
12118 let mut runner = runner_at(RunStatus::Implementing);
12119 runner.state.begin_pass();
12120 let shutdown = Pause::new();
12121 runner.on_pause(shutdown.clone());
12122 let upgrade = ParkCause::Upgrade {
12123 from: "0.121.0".to_owned(),
12124 to: Some("0.121.1".to_owned()),
12125 };
12126 shutdown.park_for(upgrade.clone());
12127 shutdown.park_for(ParkCause::Operator);
12128
12129 assert!(runner.park_here().expect("park_here"));
12130 assert_eq!(
12131 runner.state.passes[0].end,
12132 Some(crate::run::PassEnd::Parked { cause: upgrade })
12133 );
12134 let last = runner.state.events.last().expect("a park event");
12135 assert!(last.message.contains("0.121.0"), "{}", last.message);
12136 }
12137
12138 #[test]
12146 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12147 crate::run::pin_test_home();
12148 let mut runner = runner_at(RunStatus::Implementing);
12149 let shutdown = Pause::new();
12150 runner.on_pause(shutdown.clone());
12151 let interrupt = Pause::new();
12152 runner.watch_interrupt(interrupt.clone());
12153
12154 assert!(!runner.park_here().expect("park_here"));
12156 assert!(!runner.state.parked);
12157
12158 interrupt.park_because("test");
12160 assert!(!shutdown.parked());
12161 assert!(runner.park_here().expect("park_here"));
12162 }
12163
12164 #[tokio::test]
12178 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12179 crate::run::pin_test_home();
12180 let mut runner = runner_at(RunStatus::Implementing);
12181 let interrupt = Pause::new();
12182 runner.watch_interrupt(interrupt.clone());
12183
12184 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12185 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12186
12187 let node = async move {
12191 started_tx.send(()).expect("send started");
12192 finish_rx.await.expect("recv finish");
12193 "node finished"
12194 };
12195
12196 let interrupter = async move {
12197 started_rx.await.expect("recv started");
12198 interrupt.park_because("higher-priority task waiting");
12200 tokio::task::yield_now().await;
12204 finish_tx.send(()).expect("send finish");
12205 };
12206
12207 let (node_result, ()) = tokio::join!(node, interrupter);
12208 assert_eq!(
12209 node_result, "node finished",
12210 "the in-flight call ran to completion"
12211 );
12212
12213 assert!(runner.park_here().expect("park_here"));
12216 assert!(runner.state.parked);
12217 }
12218
12219 #[test]
12225 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12226 crate::run::pin_test_home();
12227 let mut runner = runner_at(RunStatus::Judging);
12228 runner.state.config.agents = vec![conductor()];
12232 runner.state.candidates = vec![Candidate {
12233 index: 0,
12234 label: 'A',
12235 agent: "alpha".to_owned(),
12236 branch: "magi/x/A".to_owned(),
12237 worktree: PathBuf::from("/nonexistent/worktree"),
12238 summary: "did the thing".to_owned(),
12239 stat: "1 file changed".to_owned(),
12240 files: 1,
12241 commits: 1,
12242 empty: false,
12243 failed: None,
12244 verified_noop: None,
12245 duration_ms: 1234,
12246 folded: false,
12247 }];
12248 let run_id = runner.state.id.clone();
12249
12250 let interrupt = Pause::new();
12251 runner.watch_interrupt(interrupt.clone());
12252 interrupt.park_because("task c3d4 asked to run first");
12253 assert!(runner.park_here().expect("park_here"));
12254
12255 let resumed = Runner::resume(&run_id).expect("resume");
12256 assert_eq!(resumed.state.candidates.len(), 1);
12257 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12258 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12259 assert_eq!(resumed.state.status, runner.state.status);
12260 assert!(
12261 resumed.state.parked,
12262 "still parked until `execute` actually walks the graph again"
12263 );
12264 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12265 }
12266
12267 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12269 let mut q = ask::Question::new(
12270 run.to_owned(),
12271 "implement".to_owned(),
12272 "impl-A".to_owned(),
12273 "Which storage backend should the cache use?".to_owned(),
12274 String::new(),
12275 vec!["SQLite".to_owned(), "Redis".to_owned()],
12276 );
12277 store.put(&mut q).unwrap();
12278 q
12279 }
12280
12281 #[test]
12282 fn a_failed_runs_open_question_is_abandoned() {
12283 ask_test_home();
12284 let store = ask::Questions::open();
12285 let mut runner = runner_at(RunStatus::Failed);
12286 let run = runner.state.id.clone();
12287 let q = ask_open_question(&store, &run);
12288
12289 runner.settle_questions();
12290
12291 let back = store.get(&q.id).unwrap();
12292 assert!(
12293 !back.status.open(),
12294 "the seat that asked died with the run; nobody is left to read an answer"
12295 );
12296 assert!(
12297 back.detail.contains(&run) && back.detail.contains("failed"),
12298 "the reason names what the run became, not just that it is gone: {}",
12299 back.detail
12300 );
12301 }
12302
12303 #[test]
12304 fn a_merged_runs_open_question_is_abandoned_too() {
12305 ask_test_home();
12306 let store = ask::Questions::open();
12307 for status in [RunStatus::Merged, RunStatus::Ready] {
12310 let mut runner = runner_at(status);
12311 let run = runner.state.id.clone();
12312 let q = ask_open_question(&store, &run);
12313
12314 runner.settle_questions();
12315
12316 let back = store.get(&q.id).unwrap();
12317 assert!(
12318 !back.status.open(),
12319 "{status:?} run's question must not outlive the run"
12320 );
12321 }
12322 }
12323
12324 #[test]
12325 fn a_still_resumable_runs_open_question_is_left_alone() {
12326 ask_test_home();
12327 let store = ask::Questions::open();
12328 for status in [RunStatus::Blocked, RunStatus::Stalled] {
12334 let mut runner = runner_at(status);
12335 let run = runner.state.id.clone();
12336 let q = ask_open_question(&store, &run);
12337
12338 runner.settle_questions();
12339
12340 let back = store.get(&q.id).unwrap();
12341 assert!(
12342 back.status.open(),
12343 "{status:?} is still alive; the question must still be waiting"
12344 );
12345 }
12346 }
12347
12348 #[test]
12349 fn settle_questions_never_touches_an_already_answered_question() {
12350 ask_test_home();
12351 let store = ask::Questions::open();
12352 let mut runner = runner_at(RunStatus::Failed);
12353 let run = runner.state.id.clone();
12354 let mut q = ask_open_question(&store, &run);
12355 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12356 .unwrap();
12357 store.put(&mut q).unwrap();
12358
12359 runner.settle_questions();
12364 runner.settle_questions();
12365
12366 let back = store.get(&q.id).unwrap();
12367 assert_eq!(
12368 back.status,
12369 ask::QuestionStatus::Answered,
12370 "a real answer is a decision on record, never overwritten by a sweep"
12371 );
12372 }
12373
12374 #[tokio::test]
12385 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12386 crate::run::pin_test_home();
12387 let tmp = tempfile::tempdir().expect("tempdir");
12388 let repo = tmp.path().join("repo");
12389 std::fs::create_dir_all(&repo).unwrap();
12390 init_repo(&repo);
12391
12392 let mut config = Config::default();
12393 config.graph.worktree_root = Some(tmp.path().join("wt"));
12394
12395 let mut state = RunState::new(
12396 repo.clone(),
12397 "main".to_owned(),
12398 "deadbeef".to_owned(),
12399 "task".to_owned(),
12400 config,
12401 );
12402 let root = state.worktree_root();
12403 let wt_a = root.join("cand-A");
12404 let wt_b = root.join("cand-B");
12405 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12406 .await
12407 .expect("worktree A");
12408 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12409 .await
12410 .expect("worktree B");
12411
12412 state.candidates = vec![
12413 Candidate {
12414 index: 0,
12415 label: 'A',
12416 agent: "alpha".to_owned(),
12417 branch: "magi/x/A".to_owned(),
12418 worktree: wt_a.clone(),
12419 summary: String::new(),
12420 stat: String::new(),
12421 files: 0,
12422 commits: 0,
12423 empty: false,
12424 failed: None,
12425 verified_noop: None,
12426 duration_ms: 0,
12427 folded: false,
12428 },
12429 Candidate {
12430 index: 1,
12431 label: 'B',
12432 agent: "beta".to_owned(),
12433 branch: "magi/x/B".to_owned(),
12434 worktree: wt_b.clone(),
12435 summary: String::new(),
12436 stat: String::new(),
12437 files: 0,
12438 commits: 0,
12439 empty: false,
12440 failed: None,
12441 verified_noop: None,
12442 duration_ms: 0,
12443 folded: false,
12444 },
12445 ];
12446 state.tally = Some(Tally {
12447 first_choice: BTreeMap::from([('A', 1)]),
12448 borda: BTreeMap::new(),
12449 winner: 'A',
12450 rankings: 1,
12451 unanimous_initial: true,
12452 deliberated: false,
12453 changed_votes: 0,
12454 unanimous_final: true,
12455 tie_break: None,
12456 judges: 1,
12457 present: 1,
12458 quorum: 1,
12459 met_quorum: true,
12460 uncontested: None,
12461 });
12462 state.status = RunStatus::Ready;
12463
12464 fold_run(&mut state, false, &crate::run::home())
12465 .await
12466 .expect("fold_run");
12467
12468 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12469 assert!(
12470 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12471 "the unmerged winner's branch survives"
12472 );
12473 assert!(
12474 !state.candidates[0].folded,
12475 "the winner is not marked folded"
12476 );
12477
12478 assert!(!wt_b.exists(), "the loser's worktree is removed");
12479 assert!(
12480 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12481 "the loser's branch is removed"
12482 );
12483 assert!(state.candidates[1].folded, "the loser is marked folded");
12484 }
12485
12486 #[tokio::test]
12489 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12490 let tmp = tempfile::tempdir().expect("tempdir");
12491 let repo = tmp.path().join("repo");
12492 std::fs::create_dir_all(&repo).unwrap();
12493 init_repo(&repo);
12494 let home = tmp.path().join("home");
12495
12496 let mut config = Config::default();
12497 config.graph.worktree_root = Some(tmp.path().join("wt"));
12498 let mut state = RunState::new(
12499 repo.clone(),
12500 "main".to_owned(),
12501 "deadbeef".to_owned(),
12502 "task".to_owned(),
12503 config,
12504 );
12505 git::git(&repo, &["branch", "magi/x/A", "main"])
12507 .await
12508 .expect("branch");
12509 state.candidates = vec![Candidate {
12510 index: 0,
12511 label: 'A',
12512 agent: "alpha".to_owned(),
12513 branch: "magi/x/A".to_owned(),
12514 worktree: state.worktree_root().join("cand-A"),
12515 summary: String::new(),
12516 stat: String::new(),
12517 files: 0,
12518 commits: 0,
12519 empty: false,
12520 failed: None,
12521 verified_noop: None,
12522 duration_ms: 0,
12523 folded: true,
12524 }];
12525 state.released_to = Some("20260901-000000-new1".to_owned());
12526 state.released_branches = vec!["magi/x/A".to_owned()];
12527
12528 fold_run(&mut state, true, &home).await.expect("fold_run");
12529
12530 assert!(
12531 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12532 "the handed-over branch survives a fold"
12533 );
12534 }
12535
12536 #[tokio::test]
12540 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12541 let tmp = tempfile::tempdir().expect("tempdir");
12542 let repo = tmp.path().join("repo");
12543 std::fs::create_dir_all(&repo).unwrap();
12544 init_repo(&repo);
12545 let run = |args: &[&str]| {
12546 let out = std::process::Command::new("git")
12547 .quiet()
12548 .args(args)
12549 .current_dir(&repo)
12550 .output()
12551 .expect("spawn git");
12552 assert!(out.status.success(), "git {args:?}");
12553 };
12554 run(&["branch", "magi/x/A"]);
12555 run(&["checkout", "-q", "-b", "magi/x/B"]);
12556 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12557 run(&["add", "-A"]);
12558 run(&["commit", "-q", "-m", "work"]);
12559 run(&["checkout", "-q", "main"]);
12560
12561 let probe = RunState::new(
12565 repo.clone(),
12566 "main".to_owned(),
12567 "deadbeef".to_owned(),
12568 "task".to_owned(),
12569 Config::default(),
12570 );
12571 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12572 let bare = tmp.path().join("origin.git");
12573 let out = std::process::Command::new("git")
12574 .quiet()
12575 .args(["init", "-q", "--bare"])
12576 .arg(&bare)
12577 .output()
12578 .expect("spawn git");
12579 assert!(out.status.success(), "git init --bare");
12580 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12581 run(&["push", "-q", "origin", "main"]);
12582
12583 let mut state = RunState::new(
12584 repo.clone(),
12585 "main".to_owned(),
12586 "deadbeef".to_owned(),
12587 "task".to_owned(),
12588 Config::default(),
12589 );
12590 state.seeds = vec![refs::Seed {
12591 token: "magi/27b2/A".to_owned(),
12592 kind: refs::SeedKind::Unresolved,
12593 sha: String::new(),
12594 branch: true,
12595 detail: "no branch or commit named magi/27b2/A".to_owned(),
12596 }];
12597
12598 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12599 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12600 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12601 let detail = empty_candidate_detail(&state, "main");
12602 assert!(detail.starts_with("empty candidate"), "{detail}");
12603 assert!(detail.contains("magi/27b2/A"), "{detail}");
12604 }
12605
12606 #[tokio::test]
12615 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12616 let tmp = tempfile::tempdir().expect("tempdir");
12617 let repo = tmp.path().join("repo");
12618 std::fs::create_dir_all(&repo).unwrap();
12619 init_repo(&repo);
12620
12621 let mut config = Config::default();
12622 config.merge.mode = MergeMode::Local;
12623
12624 let mut state = RunState::new(
12625 repo.clone(),
12626 "main".to_owned(),
12627 "deadbeef".to_owned(),
12628 "task".to_owned(),
12629 config,
12630 );
12631 state.candidates = vec![Candidate {
12632 index: 0,
12633 label: 'A',
12634 agent: "alpha".to_owned(),
12635 branch: "does-not-exist".to_owned(),
12636 worktree: repo.clone(),
12637 summary: String::new(),
12638 stat: String::new(),
12639 files: 0,
12640 commits: 0,
12641 empty: false,
12642 failed: None,
12643 verified_noop: None,
12644 duration_ms: 0,
12645 folded: false,
12646 }];
12647 state.tally = Some(Tally {
12648 first_choice: BTreeMap::from([('A', 1)]),
12649 borda: BTreeMap::new(),
12650 winner: 'A',
12651 rankings: 1,
12652 unanimous_initial: true,
12653 deliberated: false,
12654 changed_votes: 0,
12655 unanimous_final: true,
12656 tie_break: None,
12657 judges: 0,
12658 present: 0,
12659 quorum: 0,
12660 met_quorum: true,
12661 uncontested: Some("only candidate A produced a change".to_owned()),
12662 });
12663 state.reviews = vec![ReviewRound {
12664 round: 1,
12665 head: "deadbeef".to_owned(),
12666 verified_head: None,
12667 verified_at: None,
12668 reviews: Vec::new(),
12669 e2e: Vec::new(),
12670 fix: None,
12671 blocking: 0,
12672 answered: 0,
12673 expected: 0,
12674 clean: true,
12675 verify_retried: false,
12676 e2e_deferred: false,
12677 e2e_defer_reason: None,
12678 progressed: false,
12679 vote_split: false,
12680 reconsideration: Vec::new(),
12681 verdict: None,
12682 }];
12683 state.gate = vec![CommandOutcome {
12684 command: "test".to_owned(),
12685 code: Some(0),
12686 output_tail: String::new(),
12687 duration_ms: 0,
12688 resource_blocked: false,
12689 }];
12690 state.gate_ran = true;
12691 state.status = RunStatus::Ready;
12696 state.merge = Some(MergeOutcome {
12697 mode: MergeMode::Local,
12698 ok: false,
12699 detail: "already concluded".to_owned(),
12700 empty: false,
12701 });
12702
12703 let mut runner = Runner {
12704 state,
12705 roles: ResolvedRoles {
12706 implementers: Vec::new(),
12707 judges: Vec::new(),
12708 reviewers: Vec::new(),
12709 fixer: None,
12710 conductor: conductor(),
12711 implementer_roster: Vec::new(),
12712 judge_roster: Vec::new(),
12713 reviewer_roster: Vec::new(),
12714 },
12715 sem: Arc::new(Semaphore::new(1)),
12716 pause: Pause::new(),
12717 interrupt: Pause::new(),
12718 };
12719
12720 runner.merge().await.expect("merge");
12721
12722 assert_eq!(
12723 runner.state.status,
12724 RunStatus::Ready,
12725 "a concluded run's status must not change on reentry"
12726 );
12727 assert_eq!(
12728 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12729 Some("already concluded"),
12730 "merge must not run again once the node already recorded an outcome"
12731 );
12732 }
12733
12734 #[tokio::test]
12743 async fn merge_refuses_a_gate_that_has_not_actually_run() {
12744 let tmp = tempfile::tempdir().expect("tempdir");
12745 let repo = tmp.path().join("repo");
12746 std::fs::create_dir_all(&repo).unwrap();
12747 init_repo(&repo);
12748
12749 let mut config = Config::default();
12750 config.merge.mode = MergeMode::Local;
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::new();
12813 state.gate_ran = false;
12814 state.status = RunStatus::Gating;
12815
12816 let mut runner = Runner {
12817 state,
12818 roles: ResolvedRoles {
12819 implementers: Vec::new(),
12820 judges: Vec::new(),
12821 reviewers: Vec::new(),
12822 fixer: None,
12823 conductor: conductor(),
12824 implementer_roster: Vec::new(),
12825 judge_roster: Vec::new(),
12826 reviewer_roster: Vec::new(),
12827 },
12828 sem: Arc::new(Semaphore::new(1)),
12829 pause: Pause::new(),
12830 interrupt: Pause::new(),
12831 };
12832
12833 runner.merge().await.expect("merge");
12834
12835 assert!(
12836 runner.state.merge.is_none(),
12837 "an empty gate must never be read as a passing one: {:?}",
12838 runner.state.merge
12839 );
12840 }
12841
12842 #[tokio::test]
12849 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12850 let tmp = tempfile::tempdir().expect("tempdir");
12851 let repo = tmp.path().join("repo");
12852 std::fs::create_dir_all(&repo).unwrap();
12853 init_repo(&repo);
12854
12855 let config = Config::default();
12857
12858 let mut state = RunState::new(
12859 repo.clone(),
12860 "main".to_owned(),
12861 "deadbeef".to_owned(),
12862 "task".to_owned(),
12863 config,
12864 );
12865 state.candidates = vec![Candidate {
12866 index: 0,
12867 label: 'A',
12868 agent: "alpha".to_owned(),
12869 branch: "does-not-exist".to_owned(),
12870 worktree: repo.clone(),
12871 summary: String::new(),
12872 stat: String::new(),
12873 files: 0,
12874 commits: 0,
12875 empty: false,
12876 failed: None,
12877 verified_noop: None,
12878 duration_ms: 0,
12879 folded: false,
12880 }];
12881 state.tally = Some(Tally {
12882 first_choice: BTreeMap::from([('A', 1)]),
12883 borda: BTreeMap::new(),
12884 winner: 'A',
12885 rankings: 1,
12886 unanimous_initial: true,
12887 deliberated: false,
12888 changed_votes: 0,
12889 unanimous_final: true,
12890 tie_break: None,
12891 judges: 0,
12892 present: 0,
12893 quorum: 0,
12894 met_quorum: true,
12895 uncontested: Some("only candidate A produced a change".to_owned()),
12896 });
12897 state.reviews = vec![ReviewRound {
12898 round: 1,
12899 head: "deadbeef".to_owned(),
12900 verified_head: None,
12901 verified_at: None,
12902 reviews: Vec::new(),
12903 e2e: Vec::new(),
12904 fix: None,
12905 blocking: 0,
12906 answered: 0,
12907 expected: 0,
12908 clean: true,
12909 verify_retried: false,
12910 e2e_deferred: false,
12911 e2e_defer_reason: None,
12912 progressed: false,
12913 vote_split: false,
12914 reconsideration: Vec::new(),
12915 verdict: None,
12916 }];
12917
12918 let mut runner = Runner {
12919 state,
12920 roles: ResolvedRoles {
12921 implementers: Vec::new(),
12922 judges: Vec::new(),
12923 reviewers: Vec::new(),
12924 fixer: None,
12925 conductor: conductor(),
12926 implementer_roster: Vec::new(),
12927 judge_roster: Vec::new(),
12928 reviewer_roster: Vec::new(),
12929 },
12930 sem: Arc::new(Semaphore::new(1)),
12931 pause: Pause::new(),
12932 interrupt: Pause::new(),
12933 };
12934
12935 runner.gate().await.expect("gate");
12936 assert!(
12937 runner.state.gate_ran,
12938 "zero configured commands is still a real attempt, not an unrun gate"
12939 );
12940 assert!(runner.state.gate.is_empty());
12941 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12942 assert_ne!(
12943 runner.state.status,
12944 RunStatus::Blocked,
12945 "a gate with nothing to check must not read as failed"
12946 );
12947
12948 runner.merge().await.expect("merge");
12949 assert_eq!(
12950 runner.state.status,
12951 RunStatus::Ready,
12952 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12953 );
12954 }
12955
12956 #[tokio::test]
12967 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12968 crate::run::pin_test_home();
12969 let home = crate::run::home();
12970
12971 let tmp = tempfile::tempdir().expect("tempdir");
12972 let repo = tmp.path().join("repo");
12973 std::fs::create_dir_all(&repo).unwrap();
12974 init_repo(&repo);
12975 let cache_dir = tmp.path().join("target");
12978
12979 let mut config = Config::default();
12980 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12981 config.graph.timeout_verify = Some(2);
12984
12985 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12986 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12987 .expect("no io error acquiring directly")
12988 {
12989 crate::cache::AcquireOutcome::Acquired(g) => g,
12990 crate::cache::AcquireOutcome::Busy(b) => {
12991 panic!("expected the direct acquire to win the lease first: {b:?}")
12992 }
12993 };
12994
12995 let mut state = RunState::new(
12996 repo.clone(),
12997 "main".to_owned(),
12998 "deadbeef".to_owned(),
12999 "task".to_owned(),
13000 config,
13001 );
13002 state.candidates = vec![Candidate {
13003 index: 0,
13004 label: 'A',
13005 agent: "alpha".to_owned(),
13006 branch: "does-not-exist".to_owned(),
13007 worktree: repo.clone(),
13008 summary: String::new(),
13009 stat: String::new(),
13010 files: 0,
13011 commits: 0,
13012 empty: false,
13013 failed: None,
13014 verified_noop: None,
13015 duration_ms: 0,
13016 folded: false,
13017 }];
13018 state.tally = Some(Tally {
13019 first_choice: BTreeMap::from([('A', 1)]),
13020 borda: BTreeMap::new(),
13021 winner: 'A',
13022 rankings: 1,
13023 unanimous_initial: true,
13024 deliberated: false,
13025 changed_votes: 0,
13026 unanimous_final: true,
13027 tie_break: None,
13028 judges: 0,
13029 present: 0,
13030 quorum: 0,
13031 met_quorum: true,
13032 uncontested: Some("only candidate A produced a change".to_owned()),
13033 });
13034 state.reviews = vec![ReviewRound {
13035 round: 1,
13036 head: "deadbeef".to_owned(),
13037 verified_head: None,
13038 verified_at: None,
13039 reviews: Vec::new(),
13040 e2e: Vec::new(),
13041 fix: None,
13042 blocking: 0,
13043 answered: 0,
13044 expected: 0,
13045 clean: true,
13046 verify_retried: false,
13047 e2e_deferred: false,
13048 e2e_defer_reason: None,
13049 progressed: false,
13050 vote_split: false,
13051 reconsideration: Vec::new(),
13052 verdict: None,
13053 }];
13054
13055 let mut runner = Runner {
13056 state,
13057 roles: ResolvedRoles {
13058 implementers: Vec::new(),
13059 judges: Vec::new(),
13060 reviewers: Vec::new(),
13061 fixer: None,
13062 conductor: conductor(),
13063 implementer_roster: Vec::new(),
13064 judge_roster: Vec::new(),
13065 reviewer_roster: Vec::new(),
13066 },
13067 sem: Arc::new(Semaphore::new(1)),
13068 pause: Pause::new(),
13069 interrupt: Pause::new(),
13070 };
13071
13072 let started = std::time::Instant::now();
13073 runner.gate().await.expect("gate");
13074 assert!(
13075 started.elapsed() < Duration::from_secs(1),
13076 "a gate with nothing to run must never wait on a lease it never needed"
13077 );
13078 assert!(
13079 runner.state.gate_ran,
13080 "zero commands is still a real, immediate attempt"
13081 );
13082 assert!(runner.state.gate.is_empty());
13083 assert_ne!(
13084 runner.state.status,
13085 RunStatus::Blocked,
13086 "must not read as resource-blocked on a lease it never asked for"
13087 );
13088 }
13089
13090 #[tokio::test]
13100 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
13101 crate::run::pin_test_home();
13102
13103 let tmp = tempfile::tempdir().expect("tempdir");
13104 let repo = tmp.path().join("repo");
13105 std::fs::create_dir_all(&repo).unwrap();
13106 init_repo(&repo);
13107
13108 let mut config = Config::default();
13109 config.verify.gate = vec![
13110 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
13111 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
13112 .to_owned(),
13113 ];
13114
13115 let mut state = RunState::new(
13116 repo.clone(),
13117 "main".to_owned(),
13118 "deadbeef".to_owned(),
13119 "task".to_owned(),
13120 config,
13121 );
13122 let run_id = state.id.clone();
13123 state.candidates = vec![Candidate {
13124 index: 0,
13125 label: 'A',
13126 agent: "alpha".to_owned(),
13127 branch: "does-not-exist".to_owned(),
13128 worktree: repo.clone(),
13129 summary: String::new(),
13130 stat: String::new(),
13131 files: 0,
13132 commits: 0,
13133 empty: false,
13134 failed: None,
13135 verified_noop: None,
13136 duration_ms: 0,
13137 folded: false,
13138 }];
13139 state.tally = Some(Tally {
13140 first_choice: BTreeMap::from([('A', 1)]),
13141 borda: BTreeMap::new(),
13142 winner: 'A',
13143 rankings: 1,
13144 unanimous_initial: true,
13145 deliberated: false,
13146 changed_votes: 0,
13147 unanimous_final: true,
13148 tie_break: None,
13149 judges: 0,
13150 present: 0,
13151 quorum: 0,
13152 met_quorum: true,
13153 uncontested: Some("only candidate A produced a change".to_owned()),
13154 });
13155 state.reviews = vec![ReviewRound {
13156 round: 1,
13157 head: "deadbeef".to_owned(),
13158 verified_head: None,
13159 verified_at: None,
13160 reviews: Vec::new(),
13161 e2e: Vec::new(),
13162 fix: None,
13163 blocking: 0,
13164 answered: 0,
13165 expected: 0,
13166 clean: true,
13167 verify_retried: false,
13168 e2e_deferred: false,
13169 e2e_defer_reason: None,
13170 progressed: false,
13171 vote_split: false,
13172 reconsideration: Vec::new(),
13173 verdict: None,
13174 }];
13175
13176 let mut runner = Runner {
13177 state,
13178 roles: ResolvedRoles {
13179 implementers: Vec::new(),
13180 judges: Vec::new(),
13181 reviewers: Vec::new(),
13182 fixer: None,
13183 conductor: conductor(),
13184 implementer_roster: Vec::new(),
13185 judge_roster: Vec::new(),
13186 reviewer_roster: Vec::new(),
13187 },
13188 sem: Arc::new(Semaphore::new(1)),
13189 pause: Pause::new(),
13190 interrupt: Pause::new(),
13191 };
13192
13193 let started_marker = repo.join("started.marker");
13194 let release_marker = repo.join("release.marker");
13195 let poller = tokio::spawn(async move {
13196 for _ in 0..100 {
13201 if started_marker.exists()
13202 && let Ok(s) = crate::run::RunState::load(&run_id)
13203 && let Some(a) = s.active.get("gate")
13204 {
13205 std::fs::write(&release_marker, b"go").expect("release marker");
13206 return Some(a.clone());
13207 }
13208 tokio::time::sleep(Duration::from_millis(50)).await;
13209 }
13210 None
13211 });
13212
13213 runner.gate().await.expect("gate");
13214 let captured = poller.await.expect("poller task");
13215 let captured = captured.expect(
13216 "the poller never saw a `gate` task entry in run.json while the command was \
13217 still blocked on its own release marker",
13218 );
13219
13220 assert_eq!(captured.task.as_deref(), Some("gate"));
13221 assert_eq!(captured.node, "gate");
13222 assert_eq!(captured.index, Some(1));
13223 assert_eq!(captured.total, Some(1));
13224 assert!(
13225 captured
13226 .command
13227 .as_deref()
13228 .is_some_and(|c| c.contains("started.marker")),
13229 "{captured:?}"
13230 );
13231
13232 assert!(
13233 runner.state.active.is_empty(),
13234 "the entry must be cleared once the command actually finished: {:?}",
13235 runner.state.active
13236 );
13237 assert!(runner.state.gate_ran);
13238 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
13239 }
13240
13241 #[tokio::test]
13244 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
13245 use crate::verdict::{Finding, ReviewVote, Severity};
13246 crate::run::pin_test_home();
13247 let tmp = tempfile::tempdir().expect("tempdir");
13248 let repo = tmp.path().join("repo");
13249 std::fs::create_dir_all(&repo).unwrap();
13250 init_repo(&repo);
13251
13252 for (severity, vote, expect) in [
13253 (Severity::Major, ReviewVote::Reject, true),
13254 (Severity::Minor, ReviewVote::Reject, false),
13255 (Severity::Major, ReviewVote::Approve, false),
13256 ] {
13257 let mut round = review_round(false, 1, 1, 1, false, true);
13258 round.reviews = vec![ReviewRecord {
13259 reviewer: 1,
13260 agent: "alpha".to_owned(),
13261 summary: String::new(),
13262 findings: vec![Finding {
13263 id: "R1-1-1".to_owned(),
13264 severity,
13265 file: None,
13266 line: None,
13267 title: "t".to_owned(),
13268 detail: String::new(),
13269 }],
13270 vote: Some(vote),
13271 failed: None,
13272 duration_ms: 0,
13273 attempts: 0,
13274 }];
13275 let mut state = RunState::new(
13276 repo.clone(),
13277 "main".to_owned(),
13278 "deadbeef".to_owned(),
13279 "task".to_owned(),
13280 Config::default(),
13281 );
13282 state.reviews = vec![round];
13283 let mut runner = Runner {
13284 state,
13285 roles: ResolvedRoles {
13286 implementers: Vec::new(),
13287 judges: Vec::new(),
13288 reviewers: Vec::new(),
13289 fixer: None,
13290 conductor: conductor(),
13291 implementer_roster: Vec::new(),
13292 judge_roster: Vec::new(),
13293 reviewer_roster: Vec::new(),
13294 },
13295 sem: Arc::new(Semaphore::new(1)),
13296 pause: Pause::new(),
13297 interrupt: Pause::new(),
13298 };
13299 let shell = runner.state.config.shell();
13300 runner
13301 .stop_reviewing("round budget spent", &shell, &repo)
13302 .await
13303 .expect("stop_reviewing");
13304 assert_eq!(runner.state.status, RunStatus::Gating);
13305 assert_eq!(
13306 runner.state.contested_handoff.is_some(),
13307 expect,
13308 "{severity:?} + {vote:?}"
13309 );
13310 }
13311 }
13312
13313 #[tokio::test]
13326 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
13327 crate::run::pin_test_home();
13328 let home = crate::run::home();
13329
13330 let tmp = tempfile::tempdir().expect("tempdir");
13331 let repo = tmp.path().join("repo");
13332 std::fs::create_dir_all(&repo).unwrap();
13333 init_repo(&repo);
13334 let head = crate::git::rev_parse(&repo, "HEAD")
13335 .await
13336 .expect("rev-parse");
13337 let cache_dir = tmp.path().join("target");
13340
13341 let mut config = Config::default();
13342 config.verify.e2e = vec![format!(
13343 "CARGO_TARGET_DIR='{}' test -f README.md",
13344 cache_dir.display()
13345 )];
13346 config.graph.review_rounds = 1;
13347 config.graph.timeout_verify = Some(2);
13350
13351 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13352 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13353 .expect("no io error acquiring directly")
13354 {
13355 crate::cache::AcquireOutcome::Acquired(g) => g,
13356 crate::cache::AcquireOutcome::Busy(b) => {
13357 panic!("expected the direct acquire to win the lease first: {b:?}")
13358 }
13359 };
13360
13361 let mut state = RunState::new(
13362 repo.clone(),
13363 "main".to_owned(),
13364 head.clone(),
13365 "task".to_owned(),
13366 config,
13367 );
13368 state.candidates = vec![Candidate {
13369 index: 0,
13370 label: 'A',
13371 agent: "alpha".to_owned(),
13372 branch: "does-not-exist".to_owned(),
13373 worktree: repo.clone(),
13374 summary: String::new(),
13375 stat: String::new(),
13376 files: 0,
13377 commits: 0,
13378 empty: false,
13379 failed: None,
13380 verified_noop: None,
13381 duration_ms: 0,
13382 folded: false,
13383 }];
13384 state.tally = Some(Tally {
13385 first_choice: BTreeMap::from([('A', 1)]),
13386 borda: BTreeMap::new(),
13387 winner: 'A',
13388 rankings: 1,
13389 unanimous_initial: true,
13390 deliberated: false,
13391 changed_votes: 0,
13392 unanimous_final: true,
13393 tie_break: None,
13394 judges: 0,
13395 present: 0,
13396 quorum: 0,
13397 met_quorum: true,
13398 uncontested: Some("only candidate A produced a change".to_owned()),
13399 });
13400 state.reviews = vec![ReviewRound {
13404 round: 1,
13405 head: head.clone(),
13406 verified_head: None,
13407 verified_at: None,
13408 reviews: Vec::new(),
13409 e2e: Vec::new(),
13410 fix: None,
13411 blocking: 1,
13412 answered: 1,
13413 expected: 1,
13414 clean: false,
13415 verify_retried: false,
13416 e2e_deferred: true,
13417 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13418 progressed: false,
13419 vote_split: false,
13420 reconsideration: Vec::new(),
13421 verdict: None,
13422 }];
13423
13424 let mut runner = Runner {
13425 state,
13426 roles: ResolvedRoles {
13427 implementers: Vec::new(),
13428 judges: Vec::new(),
13429 reviewers: Vec::new(),
13430 fixer: None,
13431 conductor: conductor(),
13432 implementer_roster: Vec::new(),
13433 judge_roster: Vec::new(),
13434 reviewer_roster: Vec::new(),
13435 },
13436 sem: Arc::new(Semaphore::new(1)),
13437 pause: Pause::new(),
13438 interrupt: Pause::new(),
13439 };
13440
13441 let shell = runner.state.config.shell();
13442 runner
13443 .stop_reviewing("round budget spent", &shell, &repo)
13444 .await
13445 .expect("stop_reviewing");
13446
13447 let last = runner.state.reviews.last().expect("round record");
13448 assert_eq!(
13449 last.e2e_status(),
13450 E2eStatus::ResourceBlocked,
13451 "the shared cache is still held; the attempt must read as blocked, not deferred or \
13452 failed: {last:?}"
13453 );
13454 assert_eq!(
13455 last.verified_head.as_deref(),
13456 Some(head.as_str()),
13457 "which commit this attempt targeted is known even though nothing finished checking \
13458 it"
13459 );
13460 let first_attempt_at = last
13461 .verified_at
13462 .expect("when this attempt ran is known too");
13463 assert_ne!(
13464 runner.state.status,
13465 RunStatus::Blocked,
13466 "contention is evidence about the machine, not the patch — it must not settle the \
13467 run as blocked: {:?}",
13468 runner.state.status
13469 );
13470 assert!(
13471 !runner
13472 .state
13473 .events
13474 .iter()
13475 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13476 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13477 runner.state.events
13478 );
13479
13480 runner
13485 .stop_reviewing("round budget spent", &shell, &repo)
13486 .await
13487 .expect("stop_reviewing retry");
13488 assert_eq!(
13489 runner.state.reviews.len(),
13490 1,
13491 "no new round was started: {:?}",
13492 runner.state.reviews
13493 );
13494 let last = runner.state.reviews.last().expect("round record");
13495 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13496 assert!(
13497 last.verified_at.expect("still known") > first_attempt_at,
13498 "a second reentry must be a fresh attempt, not a stale copy of the first"
13499 );
13500 assert_ne!(runner.state.status, RunStatus::Blocked);
13501
13502 held.release();
13503 }
13504
13505 #[tokio::test]
13517 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13518 crate::run::pin_test_home();
13519 let home = crate::run::home();
13520
13521 let tmp = tempfile::tempdir().expect("tempdir");
13522 let repo = tmp.path().join("repo");
13523 std::fs::create_dir_all(&repo).unwrap();
13524 init_repo(&repo);
13525 let head = crate::git::rev_parse(&repo, "HEAD")
13526 .await
13527 .expect("rev-parse");
13528 let cache_dir = tmp.path().join("target");
13529
13530 let mut config = Config::default();
13531 config.verify.e2e = vec![format!(
13532 "CARGO_TARGET_DIR='{}' test -f README.md",
13533 cache_dir.display()
13534 )];
13535 config.graph.review_rounds = 1;
13536 config.graph.timeout_verify = Some(2);
13537
13538 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13539 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13540 .expect("no io error acquiring directly")
13541 {
13542 crate::cache::AcquireOutcome::Acquired(g) => g,
13543 crate::cache::AcquireOutcome::Busy(b) => {
13544 panic!("expected the direct acquire to win the lease first: {b:?}")
13545 }
13546 };
13547
13548 let mut state = RunState::new(
13549 repo.clone(),
13550 "main".to_owned(),
13551 head.clone(),
13552 "task".to_owned(),
13553 config,
13554 );
13555 state.candidates = vec![Candidate {
13556 index: 0,
13557 label: 'A',
13558 agent: "alpha".to_owned(),
13559 branch: "does-not-exist".to_owned(),
13560 worktree: repo.clone(),
13561 summary: String::new(),
13562 stat: String::new(),
13563 files: 0,
13564 commits: 0,
13565 empty: false,
13566 failed: None,
13567 verified_noop: None,
13568 duration_ms: 0,
13569 folded: false,
13570 }];
13571 state.tally = Some(Tally {
13572 first_choice: BTreeMap::from([('A', 1)]),
13573 borda: BTreeMap::new(),
13574 winner: 'A',
13575 rankings: 1,
13576 unanimous_initial: true,
13577 deliberated: false,
13578 changed_votes: 0,
13579 unanimous_final: true,
13580 tie_break: None,
13581 judges: 0,
13582 present: 0,
13583 quorum: 0,
13584 met_quorum: true,
13585 uncontested: Some("only candidate A produced a change".to_owned()),
13586 });
13587 state.reviews = vec![ReviewRound {
13591 round: 1,
13592 head: head.clone(),
13593 verified_head: Some(head.clone()),
13594 verified_at: Some(jiff::Timestamp::now()),
13595 reviews: Vec::new(),
13596 e2e: vec![CommandOutcome {
13597 command: format!(
13598 "CARGO_TARGET_DIR='{}' test -f README.md",
13599 cache_dir.display()
13600 ),
13601 code: None,
13602 output_tail: "waiting for the shared build cache".to_owned(),
13603 duration_ms: 0,
13604 resource_blocked: true,
13605 }],
13606 fix: None,
13607 blocking: 1,
13608 answered: 1,
13609 expected: 1,
13610 clean: false,
13611 verify_retried: false,
13612 e2e_deferred: false,
13613 e2e_defer_reason: None,
13614 progressed: false,
13615 vote_split: false,
13616 reconsideration: Vec::new(),
13617 verdict: None,
13618 }];
13619
13620 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13621 let mut runner = Runner {
13622 state,
13623 roles: ResolvedRoles {
13624 implementers: Vec::new(),
13625 judges: Vec::new(),
13626 reviewers: Vec::new(),
13627 fixer: None,
13628 conductor: conductor(),
13629 implementer_roster: Vec::new(),
13630 judge_roster: Vec::new(),
13631 reviewer_roster: Vec::new(),
13632 },
13633 sem: Arc::new(Semaphore::new(1)),
13634 pause: Pause::new(),
13635 interrupt: Pause::new(),
13636 };
13637
13638 runner.review_loop().await.expect("review_loop");
13643
13644 assert_eq!(
13645 runner.state.reviews.len(),
13646 1,
13647 "no new round was started on top of the unresolved one: {:?}",
13648 runner.state.reviews
13649 );
13650 let last = &runner.state.reviews[0];
13651 assert_eq!(
13652 last.e2e_status(),
13653 E2eStatus::ResourceBlocked,
13654 "still contended: {last:?}"
13655 );
13656 assert!(
13657 last.verified_at.expect("still known") > first_attempt_at,
13658 "review_loop must have actually retried the check, not left it exactly as found"
13659 );
13660 assert_ne!(
13661 runner.state.status,
13662 RunStatus::Blocked,
13663 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13664 runner.state.status
13665 );
13666
13667 held.release();
13668 }
13669
13670 #[tokio::test]
13671 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13672 crate::run::pin_test_home();
13673 let tmp = tempfile::tempdir().expect("tempdir");
13674 let repo = tmp.path().join("repo");
13675 std::fs::create_dir_all(&repo).unwrap();
13676 init_repo(&repo);
13677
13678 let mut config = Config::default();
13679 config.merge.mode = MergeMode::Pr;
13680 config.graph.land = true;
13681 config.graph.land_approval = false;
13682
13683 let mut state = RunState::new(
13684 repo.clone(),
13685 "main".to_owned(),
13686 "deadbeef".to_owned(),
13687 "task".to_owned(),
13688 config,
13689 );
13690 state.candidates = vec![Candidate {
13691 index: 0,
13692 label: 'A',
13693 agent: "alpha".to_owned(),
13694 branch: "does-not-exist".to_owned(),
13695 worktree: repo.clone(),
13696 summary: String::new(),
13697 stat: String::new(),
13698 files: 0,
13699 commits: 0,
13700 empty: false,
13701 failed: None,
13702 verified_noop: None,
13703 duration_ms: 0,
13704 folded: false,
13705 }];
13706 state.tally = Some(Tally {
13707 first_choice: BTreeMap::from([('A', 1)]),
13708 borda: BTreeMap::new(),
13709 winner: 'A',
13710 rankings: 1,
13711 unanimous_initial: true,
13712 deliberated: false,
13713 changed_votes: 0,
13714 unanimous_final: true,
13715 tie_break: None,
13716 judges: 0,
13717 present: 0,
13718 quorum: 0,
13719 met_quorum: true,
13720 uncontested: Some("only candidate A produced a change".to_owned()),
13721 });
13722 state.reviews = vec![ReviewRound {
13723 round: 1,
13724 head: "deadbeef".to_owned(),
13725 verified_head: None,
13726 verified_at: None,
13727 reviews: Vec::new(),
13728 e2e: Vec::new(),
13729 fix: None,
13730 blocking: 0,
13731 answered: 0,
13732 expected: 0,
13733 clean: true,
13734 verify_retried: false,
13735 e2e_deferred: false,
13736 e2e_defer_reason: None,
13737 progressed: false,
13738 vote_split: false,
13739 reconsideration: Vec::new(),
13740 verdict: None,
13741 }];
13742 state.gate = vec![CommandOutcome {
13743 command: "test".to_owned(),
13744 code: Some(0),
13745 output_tail: String::new(),
13746 duration_ms: 0,
13747 resource_blocked: false,
13748 }];
13749 state.gate_ran = true;
13750 state.status = RunStatus::Landing;
13754 state.merge = Some(MergeOutcome {
13755 mode: MergeMode::Pr,
13756 ok: true,
13757 detail: "https://example.invalid/x/y/pull/1".to_owned(),
13758 empty: false,
13759 });
13760
13761 ask_test_home();
13765 state.save().expect("save landing run");
13768 let store = ask::Questions::open();
13769 let q = ask_open_question(&store, &state.id);
13770
13771 let mut runner = Runner {
13772 state,
13773 roles: ResolvedRoles {
13774 implementers: Vec::new(),
13775 judges: Vec::new(),
13776 reviewers: Vec::new(),
13777 fixer: None,
13778 conductor: conductor(),
13779 implementer_roster: Vec::new(),
13780 judge_roster: Vec::new(),
13781 reviewer_roster: Vec::new(),
13782 },
13783 sem: Arc::new(Semaphore::new(1)),
13784 pause: Pause::new(),
13785 interrupt: Pause::new(),
13786 };
13787
13788 runner.execute().await.expect("execute");
13793
13794 assert_eq!(
13795 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13796 Some("https://example.invalid/x/y/pull/1"),
13797 "reentry must not push again or open a second pull request over the \
13798 one `land` is already watching"
13799 );
13800 assert_ne!(
13801 runner.state.status,
13802 RunStatus::Landing,
13803 "land could not actually reach the fake pull request, so it must \
13804 have given up rather than left the run silently parked forever"
13805 );
13806 assert_eq!(runner.state.status, RunStatus::Blocked);
13810 assert!(
13811 store.get(&q.id).unwrap().status.open(),
13812 "Blocked is still alive; settle_questions must have been a no-op here"
13813 );
13814 }
13815
13816 fn state_with_round(round: ReviewRound) -> RunState {
13817 let mut s = RunState::new(
13818 PathBuf::from("/repo"),
13819 "main".to_owned(),
13820 "abc1234".to_owned(),
13821 "add retries".to_owned(),
13822 Config::default(),
13823 );
13824 s.reviews = vec![round];
13825 s
13826 }
13827
13828 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13829 crate::verdict::Finding {
13830 id: id.to_owned(),
13831 severity,
13832 file: None,
13833 line: None,
13834 title: title.to_owned(),
13835 detail: String::new(),
13836 }
13837 }
13838
13839 #[test]
13840 fn pr_body_names_open_findings_and_declined_ones() {
13841 let round = ReviewRound {
13842 round: 2,
13843 head: "deadbee".to_owned(),
13844 verified_head: None,
13845 verified_at: None,
13846 reviews: vec![ReviewRecord {
13847 attempts: 0,
13848 reviewer: 1,
13849 agent: "alpha".to_owned(),
13850 summary: String::new(),
13851 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13852 vote: None,
13853 failed: None,
13854 duration_ms: 0,
13855 }],
13856 e2e: vec![CommandOutcome {
13857 command: "cargo test".to_owned(),
13858 code: Some(0),
13859 output_tail: String::new(),
13860 duration_ms: 0,
13861 resource_blocked: false,
13862 }],
13863 verify_retried: false,
13864 e2e_deferred: false,
13865 e2e_defer_reason: None,
13866 fix: Some(FixRecord {
13867 agent: "alpha".to_owned(),
13868 addressed: Vec::new(),
13869 rejected: vec![crate::verdict::Rejection {
13870 id: "R1-1-1".to_owned(),
13871 why: "not reachable from any caller".to_owned(),
13872 }],
13873 notes: String::new(),
13874 committed: true,
13875 failed: None,
13876 duration_ms: 0,
13877 continuation: None,
13878 }),
13879 blocking: 0,
13880 answered: 1,
13881 expected: 1,
13882 clean: false,
13883 progressed: true,
13884 vote_split: false,
13885 reconsideration: Vec::new(),
13886 verdict: None,
13887 };
13888 let state = state_with_round(round);
13889 let body = pr_message(&state, 'A').body;
13890
13891 assert!(body.contains("add retries"), "the task must still be there");
13892 assert!(body.contains("R2-1-1"), "{body}");
13893 assert!(!body.contains("unused import"), "{body}");
13894 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13895 assert!(
13896 !body.contains("not reachable from any caller"),
13897 "the reason it was declined: {body}"
13898 );
13899 }
13900
13901 #[test]
13902 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13903 let round = ReviewRound {
13904 round: 1,
13905 head: "deadbee".to_owned(),
13906 verified_head: None,
13907 verified_at: None,
13908 reviews: vec![ReviewRecord {
13909 attempts: 0,
13910 reviewer: 1,
13911 agent: "alpha".to_owned(),
13912 summary: String::new(),
13913 findings: Vec::new(),
13914 vote: None,
13915 failed: None,
13916 duration_ms: 0,
13917 }],
13918 e2e: Vec::new(),
13919 verify_retried: false,
13920 e2e_deferred: false,
13921 e2e_defer_reason: None,
13922 fix: None,
13923 blocking: 0,
13924 answered: 1,
13925 expected: 1,
13926 clean: true,
13927 progressed: false,
13928 vote_split: false,
13929 reconsideration: Vec::new(),
13930 verdict: None,
13931 };
13932 let state = state_with_round(round);
13933 let body = pr_message(&state, 'A').body;
13934 assert!(!body.contains("Open review findings"), "{body}");
13935 assert!(!body.contains("Declined"), "{body}");
13936 }
13937
13938 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13939 let mut state = RunState::new(
13940 PathBuf::from("/repo"),
13941 "main".to_owned(),
13942 "abc1234".to_owned(),
13943 instruction.to_owned(),
13944 Config::default(),
13945 );
13946 state.candidates.push(Candidate {
13947 index: 0,
13948 label: 'A',
13949 agent: "alpha".to_owned(),
13950 branch: "magi/x/A".to_owned(),
13951 worktree: PathBuf::from("/wt"),
13952 summary: summary.to_owned(),
13953 stat: String::new(),
13954 files: 1,
13955 commits: 1,
13956 empty: false,
13957 failed: None,
13958 verified_noop: None,
13959 folded: false,
13960 duration_ms: 0,
13961 });
13962 state
13963 }
13964
13965 fn review_state(subjects: &[&str]) -> RunState {
13966 let mut state = state_with_summary(
13967 "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",
13968 "",
13969 );
13970 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13971 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13972 state
13973 }
13974
13975 #[tokio::test]
13978 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13979 ask_test_home();
13980 let tmp = tempfile::tempdir().unwrap();
13981 let repo = tmp.path().join("repo");
13982 std::fs::create_dir_all(&repo).unwrap();
13983 init_repo(&repo);
13984 let origin = tmp.path().join("origin.git");
13985 let g = |dir: &Path, args: &[&str]| {
13986 let out = std::process::Command::new("git")
13987 .args(args)
13988 .current_dir(dir)
13989 .quiet()
13990 .output()
13991 .expect("spawn git");
13992 assert!(
13993 out.status.success(),
13994 "git {args:?}: {}",
13995 String::from_utf8_lossy(&out.stderr)
13996 );
13997 };
13998 g(
13999 tmp.path(),
14000 &[
14001 "clone",
14002 "--bare",
14003 "-q",
14004 repo.to_str().unwrap(),
14005 origin.to_str().unwrap(),
14006 ],
14007 );
14008 g(
14009 &repo,
14010 &["remote", "add", "origin", origin.to_str().unwrap()],
14011 );
14012 let c1 = git::rev_parse(&repo, "main").await.unwrap();
14013
14014 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14016 g(&repo, &["add", "-A"]);
14017 g(
14018 &repo,
14019 &["commit", "-q", "-m", "chore(deps): update a crate"],
14020 );
14021 g(&repo, &["push", "-q", "origin", "main"]);
14022 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14023 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14024 g(&repo, &["add", "-A"]);
14025 g(
14026 &repo,
14027 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14028 );
14029 g(&repo, &["checkout", "-q", "main"]);
14030
14031 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14032 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14033 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14035 assert_eq!(fallback, c1);
14036
14037 let mut state = review_state(&[
14038 "chore(deps): update a crate",
14039 "fix(daemon): apply a chosen action",
14040 ]);
14041 state.repo = repo.clone();
14042 state.base_branch = "main".to_owned();
14043 state.base_commit = c1;
14044 refresh_reviewed_commits(&mut state, "feat/own").await;
14045 assert_eq!(
14046 state.reviewed_commits,
14047 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14048 );
14049 assert_eq!(
14050 review_title(&state).as_deref(),
14051 Some("fix(daemon): apply a chosen action")
14052 );
14053 assert_eq!(
14054 leaked_subjects(&state, "feat/own").await,
14055 Some(vec!["chore(deps): update a crate".to_owned()])
14056 );
14057 state.config.merge.remote = "nowhere".to_owned();
14060 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14061 }
14062
14063 struct SummaryFixture {
14067 _tmp: tempfile::TempDir,
14068 repo: PathBuf,
14069 reply: PathBuf,
14070 runner: Runner,
14071 }
14072
14073 fn summary_fixture() -> SummaryFixture {
14074 ask_test_home();
14075 let tmp = tempfile::tempdir().unwrap();
14076 let repo = tmp.path().join("repo");
14077 std::fs::create_dir_all(&repo).unwrap();
14078 init_repo(&repo);
14079 let origin = tmp.path().join("origin.git");
14080 let g = |args: &[&str]| {
14081 let out = std::process::Command::new("git")
14082 .args(args)
14083 .current_dir(&repo)
14084 .quiet()
14085 .output()
14086 .expect("spawn git");
14087 assert!(
14088 out.status.success(),
14089 "git {args:?}: {}",
14090 String::from_utf8_lossy(&out.stderr)
14091 );
14092 };
14093 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14094 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14095 g(&["push", "-q", "origin", "main"]);
14096 g(&["checkout", "-q", "-b", "feat/own"]);
14097 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14098 g(&["add", "-A"]);
14099 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14100 let reply = tmp.path().join("reply.txt");
14101 std::fs::write(
14102 &reply,
14103 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14104 )
14105 .unwrap();
14106 let mut runner = runner_at(RunStatus::Gating);
14107 let mut state = review_state(&["feat(own): add the own file"]);
14108 state.repo = repo.clone();
14109 state.base_branch = "main".to_owned();
14110 state.candidates[0].branch = "feat/own".to_owned();
14111 state.candidates[0].worktree = repo.clone();
14112 let mut author = spec("alpha");
14113 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14114 author
14115 .env
14116 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14117 state.config.agents = vec![author];
14118 runner.state = state;
14119 SummaryFixture {
14120 _tmp: tmp,
14121 repo,
14122 reply,
14123 runner,
14124 }
14125 }
14126
14127 async fn posted(f: &mut SummaryFixture) -> PrMessage {
14128 let winner = f.runner.state.candidates[0].clone();
14129 f.runner
14130 .guarded_pr_message(&winner, None, true)
14131 .await
14132 .unwrap()
14133 .expect("an English description never parks")
14134 }
14135
14136 fn clean_round_with_minor_findings() -> ReviewRound {
14137 let mut round = passing_round(2, None);
14138 round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14139 round.reviews = vec![ReviewRecord {
14140 attempts: 0,
14141 reviewer: 1,
14142 agent: "alpha".into(),
14143 summary: "内部レビュー".into(),
14144 findings: vec![
14145 finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14146 finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14147 ],
14148 vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14149 failed: None,
14150 duration_ms: 0,
14151 }];
14152 round
14153 }
14154
14155 #[tokio::test]
14156 async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14157 for review_only in [false, true] {
14158 let mut f = summary_fixture();
14159 if !review_only {
14160 f.runner.state.candidates[0].agent = "alpha".into();
14161 }
14162 f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14163 assert!(f.runner.state.reviews[0].clean);
14164 assert!(f.runner.state.reviews[0].fix.is_none());
14165 assert!(f.runner.state.open_findings().is_empty());
14166 assert_eq!(
14167 crate::pr_outcome::required_finding_ids(&f.runner.state),
14168 ["R2-1-1", "R2-1-2"]
14169 );
14170 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14171 let evidence = crate::pr_outcome::summary_evidence(
14172 &f.runner.state,
14173 crate::pr_outcome::FinalHead::At(&head),
14174 );
14175 assert!(
14176 evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14177 );
14178 let omitted = posted(&mut f).await;
14180 assert!(omitted.body.contains("No description"));
14181 assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14182 assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14183 assert!(f.runner.state.change_summary.is_none());
14184 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nAdds the own file. R2-1-1 [Minor]: improve documentation for callers. R2-1-2 [Minor]: consider the remaining edge case.").unwrap();
14185 let described = posted(&mut f).await;
14186 assert!(described.body.contains("improve documentation for callers"));
14187 assert!(described.body.contains("remaining edge case"));
14188 assert!(described.body.contains("## Open review findings"));
14189 assert!(!described.body.contains("未修正"));
14190 }
14191 }
14192
14193 async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14194 let mut f = summary_fixture();
14195 if !review_only {
14196 f.runner.state.candidates[0].agent = "alpha".into();
14197 }
14198 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14199 let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14200 fixed
14201 .fix
14202 .as_mut()
14203 .unwrap()
14204 .rejected
14205 .push(crate::verdict::Rejection {
14206 id: "R1-1-1".into(),
14207 why: "呼び出されないコードのため辞退".into(),
14208 });
14209 let mut clean = clean_round_with_minor_findings();
14210 clean.verified_head = Some(head.clone());
14211 f.runner.state.reviews = vec![fixed, clean];
14212 ok_gate(&mut f.runner.state, Some(&head));
14213 std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14214 f.runner.state.config.agents.push(scripted("beta", reply));
14215 f.runner.state.config.roles.language_judge =
14216 Some(crate::config::AgentChoice::One("beta".into()));
14217 f
14218 }
14219
14220 #[tokio::test]
14221 async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14222 for review_only in [false, true] {
14223 let mut f = rewrite_fixture_with_findings(review_only,
14224 "TITLE: fix: public description\nR1-1-1: declined because no caller reaches this code. R2-1-1 [Minor]: documentation remains incomplete. R2-1-2 [Minor]: an edge case needs follow-up.").await;
14225 let m = posted(&mut f).await;
14226 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14227 let expected =
14228 public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14229 assert!(m.body.contains(&expected));
14230 assert!(m.body.contains("## Review rounds"));
14231 assert!(
14232 m.body
14233 .contains("Gate (`verify.gate`): passed on the final commit")
14234 );
14235 assert!(
14236 m.body
14237 .contains("Tests (`verify.e2e`): passed on the final commit")
14238 );
14239 let c = f.runner.state.change_summary.as_ref().unwrap();
14240 assert!(
14241 m.body
14242 .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14243 );
14244 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14245 assert!(crate::pr_outcome::covers_required_findings(
14246 &f.runner.state,
14247 &m.body
14248 ));
14249 assert!(m.body.contains("documentation remains incomplete"));
14250 assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14251 assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14252 assert!(f.runner.state.github_text.is_none());
14253 }
14254 }
14255
14256 #[tokio::test]
14257 async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14258 for review_only in [false, true] {
14259 for reply in [
14260 "TITLE: fix: public description\nA short English description.",
14261 "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14262 ] {
14263 let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14264 let winner = f.runner.state.candidates[0].clone();
14265 let posted = f
14266 .runner
14267 .guarded_pr_message(&winner, None, true)
14268 .await
14269 .unwrap();
14270 assert!(posted.is_none());
14271 assert!(f.runner.state.parked);
14272 let withheld = f.runner.state.github_text.as_ref().unwrap();
14273 assert!(
14274 withheld.rewrite.is_none(),
14275 "an incomplete rewrite must not be selectable"
14276 );
14277 let q = ask::Questions::open()
14278 .get(withheld.question.as_ref().unwrap())
14279 .unwrap();
14280 assert!(
14281 !q.choices
14282 .contains(&crate::github_text::USE_REWRITE.to_owned())
14283 );
14284 assert!(
14285 q.detail.contains("R1-1-1")
14286 && q.detail.contains("R2-1-1")
14287 && q.detail.contains("R2-1-2")
14288 );
14289 }
14290 }
14291 }
14292
14293 #[tokio::test]
14294 async fn japanese_reports_stay_local_in_both_public_run_modes() {
14295 for review_only in [false, true] {
14296 let mut f = summary_fixture();
14297 if !review_only {
14298 f.runner.state.candidates[0].agent = "alpha".into();
14299 }
14300 f.runner.state.instruction = "内部タスクの説明".into();
14301 f.runner.state.candidates[0].summary = "内部の候補報告".into();
14302 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14303 f.runner.state.reviews = vec![
14304 round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14305 round_with_notes(2, Some("二回目の内部修正報告です。")),
14306 passing_round(3, Some(&head)),
14307 ];
14308 f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14309 ok_gate(&mut f.runner.state, Some(&head));
14310 let captured = f.repo.join("public-prompt.txt");
14311 let author = &mut f.runner.state.config.agents[0];
14312 author.command = vec![
14313 "sh".into(),
14314 "-c".into(),
14315 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14316 ];
14317 author.env.insert(
14318 "PROMPT_FILE".into(),
14319 captured.to_string_lossy().into_owned(),
14320 );
14321 let m = posted(&mut f).await;
14322 assert!(m.body.contains("Adds `own.txt`"));
14323 assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14324 assert!(m.body.contains("R1-1-1"));
14325 assert!(
14326 m.body
14327 .contains("Gate (`verify.gate`): passed on the final commit")
14328 );
14329 assert!(
14330 m.body
14331 .contains("Tests (`verify.e2e`): passed on the final commit")
14332 );
14333 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14334 assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14335 let prompt = std::fs::read_to_string(captured).unwrap();
14336 assert!(prompt.contains(&format!(
14337 "git diff {} {head}",
14338 f.runner.state.change_summary.as_ref().unwrap().base
14339 )));
14340 assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14341 assert_eq!(
14342 f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14343 "二回目の内部修正報告です。"
14344 );
14345 }
14346 }
14347
14348 #[tokio::test]
14349 async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14350 for review_only in [false, true] {
14351 let mut f = summary_fixture();
14352 if !review_only {
14353 f.runner.state.candidates[0].agent = "alpha".into();
14354 }
14355 f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14356 f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14357 std::fs::write(&f.reply, "no usable description").unwrap();
14358 let failed = posted(&mut f).await;
14359 assert!(
14360 failed
14361 .body
14362 .contains("No description of what this change does could be generated")
14363 );
14364 assert!(failed.body.is_ascii());
14365 assert!(f.runner.state.github_text.is_none());
14366 assert!(f.runner.state.change_summary.is_none());
14367 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14368 let evidence = crate::pr_outcome::summary_evidence(
14369 &f.runner.state,
14370 crate::pr_outcome::FinalHead::At(&head),
14371 );
14372 let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14373 assert!(
14374 crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14375 .exists()
14376 );
14377 f.runner.state =
14378 serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14379 std::fs::write(
14380 &f.reply,
14381 "TITLE: feat(own): add the own file\nAdds the committed own file.",
14382 )
14383 .unwrap();
14384 let retried = posted(&mut f).await;
14385 assert!(retried.body.contains("Adds the committed own file."));
14386 assert_eq!(
14387 f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14388 "日本語の修正報告"
14389 );
14390 }
14391 }
14392
14393 #[tokio::test]
14394 async fn same_head_verification_and_findings_invalidate_public_prose() {
14395 let mut f = summary_fixture();
14396 posted(&mut f).await;
14397 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14398 let original = f
14399 .runner
14400 .state
14401 .change_summary
14402 .as_ref()
14403 .unwrap()
14404 .evidence
14405 .clone();
14406 ok_gate(&mut f.runner.state, Some(&head));
14407 std::fs::write(
14408 &f.reply,
14409 "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14410 )
14411 .unwrap();
14412 let verified = posted(&mut f).await;
14413 assert!(verified.body.contains("now verified"));
14414 assert_ne!(
14415 f.runner.state.change_summary.as_ref().unwrap().evidence,
14416 original
14417 );
14418 let mut round = round_with_notes(1, Some("内部報告"));
14419 round.reviews.push(ReviewRecord {
14420 attempts: 0,
14421 reviewer: 1,
14422 agent: "alpha".into(),
14423 summary: "内部レビュー".into(),
14424 findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14425 vote: None,
14426 failed: None,
14427 duration_ms: 0,
14428 });
14429 round
14430 .fix
14431 .as_mut()
14432 .unwrap()
14433 .rejected
14434 .push(crate::verdict::Rejection {
14435 id: "R1-1-1".into(),
14436 why: "呼び出し元から到達できない".into(),
14437 });
14438 f.runner.state.reviews = vec![round];
14439 let omitted = posted(&mut f).await;
14441 assert!(omitted.body.contains("No description"));
14442 assert!(omitted.body.contains("R1-1-1"));
14443 assert!(!omitted.body.contains("到達"));
14444 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: declined because the code is unreachable from callers; confirm that assumption when adding callers.").unwrap();
14445 let described = posted(&mut f).await;
14446 assert!(described.body.contains("unreachable from callers"));
14447 assert!(described.body.contains("R1-1-1"));
14448 assert!(!described.body.contains("到達"));
14449 let evidence = f
14450 .runner
14451 .state
14452 .change_summary
14453 .as_ref()
14454 .unwrap()
14455 .evidence
14456 .clone();
14457 f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14458 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14459 assert!(posted(&mut f).await.body.contains("rationale updated"));
14460 assert_ne!(
14461 f.runner.state.change_summary.as_ref().unwrap().evidence,
14462 evidence
14463 );
14464 f.runner
14466 .state
14467 .change_summary
14468 .as_mut()
14469 .unwrap()
14470 .evidence
14471 .clear();
14472 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14473 assert!(posted(&mut f).await.body.contains("current specification"));
14474 }
14475
14476 #[tokio::test]
14477 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14478 let mut f = summary_fixture();
14479 let m = posted(&mut f).await;
14480 assert_eq!(m.title, "feat(own): add the own file");
14481 assert!(
14482 m.body
14483 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14484 "{}",
14485 m.body
14486 );
14487 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14488 assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14489 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14490 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14491 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14492 let kept = f.runner.state.change_summary.as_ref().unwrap();
14493 assert_eq!(
14494 (kept.head.as_str(), kept.base.as_str()),
14495 (head.as_str(), base.as_str())
14496 );
14497 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14498 }
14499
14500 #[tokio::test]
14501 async fn review_only_summary_receives_the_configured_github_language() {
14502 let mut f = summary_fixture();
14503 f.runner.state.config.graph.github_language = "ja".into();
14504 let captured = f.repo.join("summary-prompt.txt");
14505 let author = &mut f.runner.state.config.agents[0];
14506 author.command = vec![
14507 "sh".into(),
14508 "-c".into(),
14509 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14510 ];
14511 author.env.insert(
14512 "PROMPT_FILE".into(),
14513 captured.to_string_lossy().into_owned(),
14514 );
14515 std::fs::write(
14516 &f.reply,
14517 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14518 )
14519 .unwrap();
14520 let m = posted(&mut f).await;
14521 let prompt = std::fs::read_to_string(captured).unwrap();
14522 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14523 assert!(!prompt.contains("in English"));
14524 assert_eq!(m.title, "feat(own): ファイルを追加");
14525 assert!(m.body.contains("own.txt を追加します。"));
14526 }
14527
14528 #[tokio::test]
14529 async fn review_fixes_come_after_the_whole_change_summary() {
14530 let mut f = summary_fixture();
14531 f.runner.state.reviews = vec![
14532 round_with_notes(1, Some("handled the PATH case")),
14533 round_with_notes(2, None),
14534 ];
14535 let m = posted(&mut f).await;
14536 let summary = m.body.find("## Summary").expect("summary");
14537 let fixes = m.body.find("## Review rounds").expect("review rounds");
14538 assert!(summary < fixes, "{}", m.body);
14539 assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14540 assert!(m.body.contains("## Final verification"), "{}", m.body);
14541 }
14542
14543 #[tokio::test]
14544 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14545 let mut f = summary_fixture();
14546 let first = posted(&mut f).await;
14547 let first_head = f.runner.state.change_summary.clone().unwrap().head;
14548 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14550 let again = posted(&mut f).await;
14551 assert_eq!((again.title, again.body), (first.title, first.body));
14552
14553 let g = |args: &[&str]| {
14554 let out = std::process::Command::new("git")
14555 .args(args)
14556 .current_dir(&f.repo)
14557 .quiet()
14558 .output()
14559 .expect("spawn git");
14560 assert!(out.status.success(), "git {args:?}");
14561 };
14562 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14563 g(&["add", "-A"]);
14564 g(&["commit", "-q", "-m", "fix(own): handle more"]);
14565 let second = posted(&mut f).await;
14566 let kept = f.runner.state.change_summary.clone().unwrap();
14567 assert_ne!(kept.head, first_head);
14568 assert!(second.body.contains("Not read yet."), "{}", second.body);
14569 assert!(!second.body.contains("so the first summary is read"));
14570 assert_eq!(second.title, "feat(own): other");
14571 }
14572
14573 #[tokio::test]
14574 async fn an_unavailable_summary_is_stated_and_never_invented() {
14575 let mut f = summary_fixture();
14576 std::fs::write(&f.reply, "no title line here").unwrap();
14577 let m = posted(&mut f).await;
14578 assert!(
14579 m.body
14580 .contains("No description of what this change does could be generated"),
14581 "{}",
14582 m.body
14583 );
14584 assert!(m.body.contains("internal reports remain in local records"));
14585 assert!(f.runner.state.change_summary.is_none());
14586 assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14587 let winner = f.runner.state.candidates[0].clone();
14589 let preview = f
14590 .runner
14591 .guarded_pr_message(&winner, None, false)
14592 .await
14593 .unwrap()
14594 .unwrap();
14595 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14596 }
14597
14598 #[test]
14599 fn pr_message_review_single_commit_uses_its_subject() {
14600 let state = review_state(&["feat(nats): per-role user"]);
14601 let m = pr_message(&state, 'A');
14602 assert_eq!(m.title, "feat(nats): per-role user");
14603 assert!(!m.body.contains("Review the work already"), "{}", m.body);
14604 assert!(m.body.contains("## Commits under review"), "{}", m.body);
14605 }
14606
14607 #[test]
14608 fn pr_message_review_multi_commit_takes_the_oldest() {
14609 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14610 let m = pr_message(&state, 'A');
14611 assert_eq!(m.title, "feat: the change");
14612 for s in ["feat: the change", "fix: typo", "fix: again"] {
14613 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14614 }
14615 }
14616
14617 #[test]
14618 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14619 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14620 let mut state = review_state(&[first, "fix: later fixup"]);
14621 state.candidates[0].branch = "機能/ログイン".to_owned();
14622 let m = pr_message(&state, 'A');
14623 assert!(
14624 m.title.starts_with("chore: land candidate A of run"),
14625 "{}",
14626 m.title
14627 );
14628 }
14629 }
14630
14631 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14632 BranchFacts {
14633 commits: commits
14634 .iter()
14635 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14636 .collect(),
14637 stat: stat.to_owned(),
14638 }
14639 }
14640
14641 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14642 let mut r = review_round(true, 0, 1, 1, true, true);
14643 r.round = round;
14644 r.fix = notes.map(|n| FixRecord {
14645 agent: "fixer".to_owned(),
14646 addressed: Vec::new(),
14647 rejected: Vec::new(),
14648 notes: n.to_owned(),
14649 committed: true,
14650 failed: None,
14651 duration_ms: 0,
14652 continuation: None,
14653 });
14654 r
14655 }
14656
14657 #[test]
14658 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14659 let state = review_state(&["ignored"]);
14660 let f = facts(
14661 &[
14662 (
14663 "fix(login): resolve PATH on macOS",
14664 "Login shells skip rc files.",
14665 ),
14666 ("fix: address review", ""),
14667 ],
14668 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14669 );
14670 let m = pr_message_with(&state, 'A', Some(&f));
14671 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14672 assert!(
14673 m.body
14674 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
14675 "{}",
14676 m.body
14677 );
14678 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14679 assert!(m.body.contains("## Diff stat"), "{}", m.body);
14680 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14681 for banned in [
14682 "Review the work already",
14683 "no task statement",
14684 "Original task",
14685 ] {
14686 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14687 }
14688 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14689 }
14690
14691 #[test]
14692 fn pr_message_review_truncates_a_huge_first_commit_body() {
14693 let state = review_state(&["ignored"]);
14694 let f = facts(
14695 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14696 "s",
14697 );
14698 let m = pr_message_with(&state, 'A', Some(&f));
14699 assert!(m.body.len() < 30_000, "{}", m.body.len());
14700 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14701 assert!(
14702 m.body.contains("- ... 1 more commit(s)"),
14703 "{}",
14704 m.body.len()
14705 );
14706 assert!(m.body.ends_with("magi:candidate-a\n"));
14707 }
14708
14709 #[test]
14710 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14711 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14712 assert_eq!(m.title, "feat: x");
14713 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14714 assert!(!m.body.contains("Diff stat"), "{}", m.body);
14715 assert!(!m.body.contains("no task statement"), "{}", m.body);
14716 }
14717
14718 #[test]
14719 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14720 let mut state = review_state(&["日本語の件名"]);
14721 state.candidates[0].branch = "fix/macos-login-path".to_owned();
14722 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14723 state.candidates[0].branch = "機能/ログイン".to_owned();
14724 assert!(
14725 pr_message(&state, 'A')
14726 .title
14727 .starts_with("chore: land candidate A")
14728 );
14729 }
14730
14731 #[test]
14732 fn pr_message_review_fixes_survive_a_clean_final_round() {
14733 let mut state = review_state(&["feat: x"]);
14734 state.reviews = vec![
14735 round_with_notes(1, Some("handled the PATH case")),
14736 round_with_notes(2, None),
14737 ];
14738 let body = pr_message(&state, 'A').body;
14739 assert!(
14740 body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14741 "{body}"
14742 );
14743 assert!(body.contains("### Round 1"), "{body}");
14744
14745 state.reviews = vec![
14746 round_with_notes(1, Some("first fix")),
14747 round_with_notes(2, Some("")),
14748 round_with_notes(3, Some("second fix")),
14749 round_with_notes(4, None),
14750 ];
14751 let body = pr_message(&state, 'A').body;
14752 assert!(
14753 body.contains("### Round 1") && !body.contains("first fix"),
14754 "{body}"
14755 );
14756 assert!(
14757 body.contains("### Round 3") && !body.contains("second fix"),
14758 "{body}"
14759 );
14760 assert!(body.contains("### Round 2"), "{body}");
14761 }
14762
14763 const FINAL: &str = "abcdef1234567890";
14764
14765 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14766 let mut r = round_with_notes(round, None);
14767 r.verified_head = verified.map(str::to_owned);
14768 r
14769 }
14770
14771 fn ok_gate(state: &mut RunState, head: Option<&str>) {
14772 state.gate = vec![CommandOutcome {
14773 command: "gate".to_owned(),
14774 code: Some(0),
14775 output_tail: String::new(),
14776 duration_ms: 0,
14777 resource_blocked: false,
14778 }];
14779 state.gate_ran = true;
14780 state.gate_head = head.map(str::to_owned);
14781 }
14782
14783 fn body_at(state: &RunState) -> String {
14784 pr_message_raw(
14785 state,
14786 'A',
14787 None,
14788 crate::pr_outcome::FinalHead::At(FINAL),
14789 ChangeNote::NotAsked,
14790 )
14791 .body
14792 }
14793
14794 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14795
14796 #[test]
14797 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14798 let mut state = review_state(&["feat: x"]);
14799 state.reviews = vec![
14800 round_with_notes(1, Some(SANDBOXED)),
14801 passing_round(2, Some(FINAL)),
14802 ];
14803 ok_gate(&mut state, Some(FINAL));
14804 let body = body_at(&state);
14805 assert!(!body.contains("Handled the PATH case."), "{body}");
14806 assert!(!body.contains("could not commit"), "{body}");
14807 assert!(!body.contains("could not run"), "{body}");
14808 assert!(
14809 body.contains("The fix is committed on the branch"),
14810 "{body}"
14811 );
14812 assert!(!body.contains("sandbox"), "{body}");
14813 assert!(
14814 body.contains("Tests (`verify.e2e`): passed on the final commit"),
14815 "{body}"
14816 );
14817 assert!(
14818 body.contains("Gate (`verify.gate`): passed on the final commit"),
14819 "{body}"
14820 );
14821 assert!(
14823 state.reviews[0]
14824 .fix
14825 .as_ref()
14826 .unwrap()
14827 .notes
14828 .contains("could not commit")
14829 );
14830 }
14831
14832 #[test]
14833 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14834 let mut state = review_state(&["feat: x"]);
14835 let mut r1 = round_with_notes(1, Some(SANDBOXED));
14836 r1.fix.as_mut().unwrap().committed = false;
14837 let mut r2 = passing_round(2, Some(FINAL));
14838 r2.e2e[0].code = Some(1);
14839 state.reviews = vec![r1, r2];
14840 let body = body_at(&state);
14841 assert!(!body.contains("could not commit"), "{body}");
14842 assert!(!body.contains("could not run"), "{body}");
14843 assert!(body.contains("This round produced no commit"), "{body}");
14844 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14845 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14846 assert!(!body.contains("passed"), "{body}");
14847 }
14848
14849 #[test]
14850 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14851 let mut state = review_state(&["feat: x"]);
14852 state.reviews = vec![
14853 round_with_notes(1, Some(SANDBOXED)),
14854 passing_round(2, Some("0123456789abcdef")),
14855 ];
14856 ok_gate(&mut state, Some("0123456789abcdef"));
14857 let body = body_at(&state);
14858 assert!(!body.contains("HTTP tests could not run"), "{body}");
14859 assert!(!body.contains("could not commit"), "{body}");
14861 assert!(
14862 body.contains("passed on an earlier commit (`0123456`)"),
14863 "{body}"
14864 );
14865 assert!(!body.contains("passed on the final commit"), "{body}");
14866
14867 ok_gate(&mut state, None);
14869 let body = body_at(&state);
14870 assert!(
14871 body.contains("passed on a commit that was not recorded"),
14872 "{body}"
14873 );
14874 }
14875
14876 #[test]
14877 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14878 let state = state_with_summary(
14879 "add retries to the client",
14880 "TITLE: feat: retries\n\nDid it.",
14881 );
14882 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14883 assert_eq!(m.title, "feat: retries");
14884 assert!(
14885 m.body.contains("<summary>Original task</summary>"),
14886 "{}",
14887 m.body
14888 );
14889 assert!(!m.body.contains("Commits under review"), "{}", m.body);
14890 assert!(
14891 m.body
14892 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14893 "{}",
14894 m.body
14895 );
14896 }
14897
14898 #[test]
14899 fn pr_message_review_bounds_a_long_english_subject() {
14900 let long = format!("feat: {}", "word ".repeat(100));
14901 let m = pr_message(&review_state(&[&long]), 'A');
14902 assert!(m.title.starts_with("feat: word"), "{}", m.title);
14903 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14904 }
14905
14906 #[test]
14907 fn pr_message_implementation_run_is_unchanged_by_review_support() {
14908 let state = state_with_summary("add retries\n\ndetails", "- did some things");
14909 let m = pr_message(&state, 'A');
14910 assert_eq!(m.title, "add retries");
14911 assert!(m.body.contains("<summary>Original task</summary>"));
14912 assert!(!m.body.contains("Commits under review"));
14913 assert_eq!(landing_subject_source(&state), state.instruction);
14914 }
14915
14916 #[test]
14917 fn review_run_squash_subject_is_the_change_not_the_prompt() {
14918 let state = review_state(&["feat: the change", "fix: typo"]);
14919 let source = landing_subject_source(&state);
14920 assert_eq!(land::merge_subject("", &source), "feat: the change");
14921 assert_eq!(
14922 land::merge_subject("magi: candidate A (uncommitted work)", &source),
14923 "feat: the change"
14924 );
14925 assert_eq!(
14927 land::merge_subject("feat: renamed by hand", &source),
14928 "feat: renamed by hand"
14929 );
14930 let blank = review_state(&["日本語"]);
14931 assert!(
14932 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14933 );
14934 }
14935
14936 #[test]
14937 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
14938 let state = review_state(&["feat: the change"]);
14939 let source = landing_subject_source(&state);
14940 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
14941 assert_eq!(
14942 land::merge_subject(landing_title(&state, old), &source),
14943 "feat: the change"
14944 );
14945 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
14946 let task = state_with_summary("add retries", "");
14947 assert_eq!(landing_title(&task, old), old);
14948 }
14949
14950 #[test]
14951 fn pr_message_describes_the_change_not_the_task() {
14952 let state = state_with_summary(
14953 "今回やってほしいこと: results projector を直す",
14954 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
14955 );
14956 let m = pr_message(&state, 'A');
14957 assert_eq!(m.title, "fix(web): batch the runs list reads");
14958 assert!(
14959 m.body.starts_with("## Summary\n\n- reads run.json once"),
14960 "{}",
14961 m.body
14962 );
14963 assert!(!m.body.contains("TITLE:"), "{}", m.body);
14964 let task_at = m.body.find("今回やってほしいこと").unwrap();
14965 let details_at = m.body.find("<details>").unwrap();
14966 assert!(
14967 details_at < task_at,
14968 "the task lives inside <details>: {}",
14969 m.body
14970 );
14971 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
14972 assert!(m.body.contains("magi:candidate-a"));
14973 }
14974
14975 #[test]
14976 fn pr_message_falls_back_to_the_task_without_a_title_line() {
14977 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
14978 let m = pr_message(&state, 'A');
14979 assert_eq!(m.title, "add retries");
14980 assert!(
14981 m.body.contains("## Summary\n\n- did some things"),
14982 "{}",
14983 m.body
14984 );
14985
14986 let none = RunState::new(
14987 PathBuf::from("/repo"),
14988 "main".to_owned(),
14989 "abc1234".to_owned(),
14990 "add retries".to_owned(),
14991 Config::default(),
14992 );
14993 let m = pr_message(&none, 'A');
14994 assert_eq!(m.title, "add retries");
14995 assert!(!m.body.contains("## Summary"), "{}", m.body);
14996 }
14997
14998 #[test]
14999 fn pr_message_refuses_the_candidate_commit_subject() {
15000 for bad in [
15001 "TITLE: magi: candidate A (uncommitted work)",
15002 "TITLE: chore: stuff (uncommitted work)",
15003 "TITLE: ",
15004 ] {
15005 let state = state_with_summary("add retries", bad);
15006 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15007 }
15008 }
15009
15010 #[test]
15011 fn pr_message_bounds_a_very_long_task_and_title() {
15012 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15013 let state = state_with_summary(&long, "- nothing");
15014 let m = pr_message(&state, 'A');
15015 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15016 assert!(!m.title.contains('\n'));
15017
15018 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15019 let m = pr_message(&state, 'A');
15020 assert!(m.title.starts_with("feat: "));
15021 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15022 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15023 }
15024
15025 fn long_title_of(instruction: &str) -> String {
15026 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15027 }
15028
15029 #[test]
15030 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15031 let first = "Make the landing path keep a readable title for long tasks";
15032 let line = format!(
15033 "{first}. {}",
15034 "And then keep going with more words ".repeat(20)
15035 );
15036 let t = long_title_of(&line);
15037 assert_eq!(t, first);
15038 assert!(!t.starts_with("chore: land"));
15039 }
15040
15041 #[test]
15042 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15043 let line = "word ".repeat(200);
15044 let t = long_title_of(&line);
15045 assert!(t.ends_with("word..."), "{t}");
15046 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15047 }
15048
15049 #[test]
15050 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15051 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15052 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15053 }
15054 }
15055
15056 #[test]
15057 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15058 let mut state = state_with_summary("再試行を追加する", "- nothing");
15059 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15060 state.config.graph.github_language = "ja".into();
15061 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15062 }
15063
15064 #[test]
15065 fn pr_message_title_limit_is_exact() {
15066 let at = "a".repeat(PR_TITLE_MAX);
15067 assert_eq!(long_title_of(&at), at);
15068 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15069 assert!(over.ends_with("..."), "{over}");
15070 assert_eq!(over.chars().count(), PR_TITLE_MAX);
15071 }
15072
15073 #[test]
15074 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15075 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15076 let t = long_title_of(&line);
15077 assert!(t.ends_with("..."), "{t}");
15078 assert!(t.is_ascii(), "{t}");
15079 }
15080
15081 #[test]
15082 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15083 let line = format!(
15084 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15085 "plus more filler words ".repeat(20)
15086 );
15087 let t = long_title_of(&line);
15088 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15089 }
15090
15091 #[test]
15092 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15093 let line = format!("\n\n# {}", "title words ".repeat(40));
15094 let state = state_with_summary(&line, "- nothing");
15095 let m = pr_message(&state, 'A');
15096 assert!(m.title.starts_with("title words"), "{}", m.title);
15097 assert_eq!(
15098 land::merge_subject(&m.title, &landing_subject_source(&state)),
15099 m.title
15100 );
15101 assert_eq!(
15103 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15104 "feat: renamed by hand"
15105 );
15106 }
15107
15108 #[tokio::test]
15109 async fn github_text_rewrite_is_bounded_and_falls_back() {
15110 crate::run::pin_test_home();
15111 for (reply, accepted) in [
15112 (
15113 "TITLE: fix: retries\nAdd retries for failed requests.",
15114 true,
15115 ),
15116 (
15117 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15118 false,
15119 ),
15120 (
15121 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15122 true,
15123 ),
15124 (
15125 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15126 true,
15127 ),
15128 (
15129 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15130 true,
15131 ),
15132 (
15133 "TITLE: fix: retries\n日本語の説明です。 token=secret",
15134 false,
15135 ),
15136 ("TITLE: fix: retries\n", false),
15137 ] {
15138 let dir = tempfile::tempdir().unwrap();
15139 let mut runner = runner_at(RunStatus::Gating);
15140 runner.state = state_with_summary(
15141 "add retries",
15142 "TITLE: fix: retries\n日本語の説明を書きます。",
15143 );
15144 runner.state.repo = dir.path().to_owned();
15145 runner.state.candidates[0].worktree = dir.path().to_owned();
15146 let mut author = spec("alpha");
15147 author.command = vec![
15148 "sh".into(),
15149 "-c".into(),
15150 "printf '%s' \"$REWRITE_REPLY\"".into(),
15151 ];
15152 author.env.insert("REWRITE_REPLY".into(), reply.into());
15153 runner.state.config.agents = vec![author];
15154 seed_public_summary(&mut runner);
15155 let winner = runner.state.candidates[0].clone();
15156 let first = runner
15157 .guarded_pr_message(&winner, None, true)
15158 .await
15159 .unwrap();
15160 let message = if accepted {
15161 first.expect("a passing rewrite never asks")
15162 } else {
15163 assert!(first.is_none());
15165 assert!(runner.state.parked);
15166 let store = ask::Questions::open();
15167 let asked: Vec<_> = store
15168 .list()
15169 .into_iter()
15170 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15171 .collect();
15172 assert_eq!(asked.len(), 1);
15173 assert!(!asked[0].detail.contains("secret"));
15174 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15175 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15176 assert_eq!(
15177 asked[0]
15178 .choices
15179 .contains(&crate::github_text::USE_REWRITE.to_owned()),
15180 rewrite.is_some()
15181 );
15182 store
15185 .update(&asked[0].id, |q| {
15186 q.abandon("test");
15187 Ok(())
15188 })
15189 .unwrap();
15190 let again = runner
15191 .guarded_pr_message(&winner, None, true)
15192 .await
15193 .unwrap()
15194 .expect("an abandoned question falls back");
15195 let n = store
15196 .list()
15197 .into_iter()
15198 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15199 .count();
15200 assert_eq!(n, 1, "asked once per rejected text");
15201 again
15202 };
15203 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15204 assert!(!message.body.contains("secret"));
15205 assert!(!message.body.contains("prefix"));
15206 assert!(!message.title.contains("secret"));
15207 assert_eq!(
15208 message.body.contains("Add retries for failed requests."),
15209 accepted
15210 );
15211 assert_eq!(
15212 runner.state.seats["github-text-rewrite"].turns, 1,
15213 "only one rewrite invocation"
15214 );
15215 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15216 if accepted {
15217 assert!(runner.state.github_text.is_none());
15218 assert!(!runner.state.parked);
15219 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15220 assert!(message.body.contains("## Final verification"));
15221 assert!(message.body.contains("Evidence: final diff"));
15222 assert!(
15223 message
15224 .body
15225 .contains(&format!("magi:run/{}", runner.state.id))
15226 );
15227 }
15228 if !accepted {
15229 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15230 assert!(
15231 message
15232 .body
15233 .contains(&format!("magi:run/{}", runner.state.id))
15234 );
15235 }
15236 }
15237 }
15238
15239 fn scripted(id: &str, reply: &str) -> AgentSpec {
15240 let mut a = spec(id);
15241 a.command = vec![
15242 "sh".into(),
15243 "-c".into(),
15244 "printf '%s' \"$REWRITE_REPLY\"".into(),
15245 ];
15246 a.env.insert("REWRITE_REPLY".into(), reply.into());
15247 a
15248 }
15249
15250 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15251 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15252
15253 fn seed_public_summary(runner: &mut Runner) {
15255 let repo = &runner.state.repo;
15256 let g = |args: &[&str]| {
15257 let out = std::process::Command::new("git")
15258 .args(args)
15259 .current_dir(repo)
15260 .quiet()
15261 .output()
15262 .unwrap();
15263 assert!(out.status.success(), "git {args:?}");
15264 String::from_utf8_lossy(&out.stdout).trim().to_owned()
15265 };
15266 g(&["init", "-q", "-b", "main"]);
15267 g(&[
15268 "-c",
15269 "user.name=Test",
15270 "-c",
15271 "user.email=test@example.invalid",
15272 "commit",
15273 "-q",
15274 "--allow-empty",
15275 "-m",
15276 "feat: fixture",
15277 ]);
15278 let head = g(&["rev-parse", "HEAD"]);
15279 g(&["update-ref", "refs/remotes/origin/main", &head]);
15280 let evidence = crate::pr_outcome::summary_evidence(
15281 &runner.state,
15282 crate::pr_outcome::FinalHead::At(&head),
15283 );
15284 let generated = &runner.state.candidates[0].summary;
15285 runner.state.change_summary = Some(crate::run::ChangeSummary {
15286 base: head.clone(),
15287 head,
15288 evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15289 title: summary_title(generated).unwrap(),
15290 body: summary_without_title(generated),
15291 });
15292 }
15293
15294 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15295 let mut runner = runner_at(RunStatus::Gating);
15296 runner.state = state_with_summary("add retries", JA_SUMMARY);
15297 if review_only {
15298 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15299 }
15300 runner.state.repo = dir.to_owned();
15301 runner.state.candidates[0].worktree = dir.to_owned();
15302 runner.state.config.agents = agents;
15303 seed_public_summary(&mut runner);
15304 runner
15305 }
15306
15307 fn gate_questions(runner: &Runner) -> usize {
15308 ask::Questions::open()
15309 .list()
15310 .into_iter()
15311 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15312 .count()
15313 }
15314
15315 #[tokio::test]
15316 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15317 crate::run::pin_test_home();
15318 let dir = tempfile::tempdir().unwrap();
15319 let mut runner = rewrite_runner(
15320 dir.path(),
15321 true,
15322 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15323 );
15324 runner.state.config.roles.language_judge =
15325 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15326 let winner = runner.state.candidates[0].clone();
15327 let msg = runner
15328 .guarded_pr_message(&winner, None, true)
15329 .await
15330 .unwrap()
15331 .expect("an English rewrite posts without asking");
15332 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15333 assert!(msg.body.contains("<details>kept</details>"));
15334 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15335 assert_eq!(gate_questions(&runner), 0);
15336 assert!(runner.state.github_text.is_none());
15337 assert!(!runner.state.seats.contains_key("impl-A"));
15338 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15339 }
15340
15341 #[tokio::test]
15342 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15343 crate::run::pin_test_home();
15344 let dir = tempfile::tempdir().unwrap();
15345 let mut runner = rewrite_runner(
15346 dir.path(),
15347 false,
15348 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15349 );
15350 runner.state.config.roles.language_judge =
15351 Some(crate::config::AgentChoice::One("beta".into()));
15352 runner.state.seats.insert(
15353 "impl-A".into(),
15354 SeatState::new("impl-A", "alpha", runner.state.seed),
15355 );
15356 let winner = runner.state.candidates[0].clone();
15357 let msg = runner
15358 .guarded_pr_message(&winner, None, true)
15359 .await
15360 .unwrap()
15361 .expect("rewritten");
15362 assert!(msg.body.contains("<details>kept</details>"));
15363 assert_eq!(runner.state.seats["impl-A"].turns, 0);
15364 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15365 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15366 }
15367
15368 #[tokio::test]
15369 async fn rewrite_reuses_a_resumable_implementer_session() {
15370 crate::run::pin_test_home();
15371 let dir = tempfile::tempdir().unwrap();
15372 let mut runner = rewrite_runner(
15373 dir.path(),
15374 false,
15375 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15376 );
15377 runner.state.config.roles.language_judge =
15378 Some(crate::config::AgentChoice::One("beta".into()));
15379 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15380 seat.turns = 1;
15381 runner.state.seats.insert("impl-A".into(), seat);
15382 let winner = runner.state.candidates[0].clone();
15383 runner
15384 .guarded_pr_message(&winner, None, true)
15385 .await
15386 .unwrap()
15387 .expect("rewritten");
15388 assert_eq!(runner.state.seats["impl-A"].turns, 2);
15389 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15390 }
15391
15392 #[tokio::test]
15393 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15394 crate::run::pin_test_home();
15395 let dir = tempfile::tempdir().unwrap();
15396 let mut broken = spec("bad");
15397 broken.command = vec!["false".into()];
15398 let mut runner = rewrite_runner(
15399 dir.path(),
15400 true,
15401 vec![
15402 broken,
15403 scripted("noformat", "Add retries."),
15404 scripted("good", EN_REWRITE),
15405 ],
15406 );
15407 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15408 "bad".into(),
15409 "noformat".into(),
15410 "good".into(),
15411 ]));
15412 let winner = runner.state.candidates[0].clone();
15413 let msg = runner
15414 .guarded_pr_message(&winner, None, true)
15415 .await
15416 .unwrap()
15417 .expect("the third agent rewrites");
15418 assert!(msg.body.contains("`src/net.rs`"));
15419 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15420 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15421 }
15422
15423 #[tokio::test]
15424 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15425 crate::run::pin_test_home();
15426 for (agents, role, saved) in [
15428 (
15429 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15430 Some("beta"),
15431 true,
15432 ),
15433 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15434 (Vec::new(), None, false),
15435 ] {
15436 let dir = tempfile::tempdir().unwrap();
15437 let mut runner = rewrite_runner(dir.path(), true, agents);
15438 runner.state.config.roles.language_judge =
15439 role.map(|r| crate::config::AgentChoice::One(r.into()));
15440 let winner = runner.state.candidates[0].clone();
15441 let first = runner
15442 .guarded_pr_message(&winner, None, true)
15443 .await
15444 .unwrap();
15445 assert!(first.is_none(), "withheld text parks the run");
15446 assert!(runner.state.parked);
15447 assert_eq!(gate_questions(&runner), 1);
15448 let g = runner.state.github_text.as_ref().unwrap();
15449 assert_eq!(g.rewrite.is_some(), saved);
15450 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15451 }
15452 }
15453
15454 #[tokio::test]
15455 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15456 crate::run::pin_test_home();
15457 let dir = tempfile::tempdir().unwrap();
15458 let mut runner = runner_at(RunStatus::Gating);
15459 runner.state =
15460 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15461 runner.state.repo = dir.path().to_owned();
15462 runner.state.candidates[0].worktree = dir.path().to_owned();
15463 let mut author = spec("alpha");
15464 author.command = vec![
15465 "sh".into(),
15466 "-c".into(),
15467 "printf '%s' \"$REWRITE_REPLY\"".into(),
15468 ];
15469 author.env.insert(
15470 "REWRITE_REPLY".into(),
15471 format!(
15472 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15473 "English sentence. ".repeat(600)
15474 ),
15475 );
15476 runner.state.config.agents = vec![author];
15477 seed_public_summary(&mut runner);
15478 let winner = runner.state.candidates[0].clone();
15479 assert!(
15480 runner
15481 .guarded_pr_message(&winner, None, true)
15482 .await
15483 .unwrap()
15484 .is_none()
15485 );
15486 let record = runner.state.github_text.clone().unwrap();
15487 let rewrite = record.rewrite.clone().unwrap();
15488 assert_eq!(rewrite.failed, ["body-language"]);
15489 assert!(
15490 rewrite
15491 .body
15492 .ends_with(&format!("magi:run/{}", runner.state.id))
15493 );
15494 let store = ask::Questions::open();
15495 let q = store.get(record.question.as_ref().unwrap()).unwrap();
15496 assert!(
15497 q.choices
15498 .contains(&crate::github_text::USE_REWRITE.to_owned())
15499 );
15500 for label in [
15501 "ORIGINAL",
15502 "FALLBACK",
15503 "REWRITE",
15504 "Rules still failed: body-language",
15505 "truncated",
15506 ] {
15507 assert!(q.detail.contains(label), "{label}");
15508 }
15509 let artifact = std::fs::read_to_string(crate::run::artifact_path(
15510 &runner.state,
15511 &crate::github_text::artifact_name(&record.fingerprint),
15512 ))
15513 .unwrap();
15514 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15515 runner.state =
15517 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15518 store
15519 .update(&q.id, |q| {
15520 q.say("まだ日本語")?;
15521 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15522 })
15523 .unwrap();
15524 assert!(
15525 runner
15526 .guarded_pr_message(&winner, None, true)
15527 .await
15528 .unwrap()
15529 .is_none()
15530 );
15531 let retry = runner.state.github_text.as_ref().unwrap();
15532 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15533 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15534 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15535 assert!(
15536 q.posting_comparison
15537 .as_ref()
15538 .unwrap()
15539 .matches_detail(&q.detail)
15540 );
15541 store
15542 .update(&q.id, |q| {
15543 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15544 })
15545 .unwrap();
15546 let message = runner
15547 .guarded_pr_message(&winner, None, true)
15548 .await
15549 .unwrap()
15550 .unwrap();
15551 assert_eq!(message.title, rewrite.title);
15552 assert_eq!(message.body, rewrite.body);
15553 assert_eq!(
15554 runner.state.github_text.as_ref().unwrap().choice,
15555 Some(GithubTextChoice::Rewrite)
15556 );
15557 let again = runner
15558 .guarded_pr_message(&winner, None, true)
15559 .await
15560 .unwrap()
15561 .unwrap();
15562 assert_eq!(again.title, message.title);
15563 assert_eq!(again.body, message.body);
15564 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15565 assert!(
15566 !runner
15567 .state
15568 .events
15569 .iter()
15570 .any(|e| e.message.contains(&rewrite.title)
15571 || e.message.contains("English sentence."))
15572 );
15573 }
15574
15575 #[tokio::test]
15576 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15577 crate::run::pin_test_home();
15578 let dir = tempfile::tempdir().unwrap();
15579 let mut runner = runner_at(RunStatus::Gating);
15580 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15581 runner.state.repo = dir.path().to_owned();
15582 runner.state.candidates[0].worktree = dir.path().to_owned();
15583 seed_public_summary(&mut runner);
15584 let winner = runner.state.candidates[0].clone();
15585 assert!(
15586 runner
15587 .guarded_pr_message(&winner, None, true)
15588 .await
15589 .unwrap()
15590 .is_none()
15591 );
15592 let record = runner.state.github_text.as_ref().unwrap();
15593 assert!(record.rewrite.is_none());
15594 let mut old = serde_json::to_value(record).unwrap();
15596 old.as_object_mut().unwrap().remove("rewrite");
15597 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15598 assert!(old.rewrite.is_none());
15599 let id = record.question.clone().unwrap();
15600 ask::Questions::open()
15601 .update(&id, |q| {
15602 q.choices.push(crate::github_text::USE_REWRITE.into());
15603 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15604 })
15605 .unwrap();
15606 let message = runner
15607 .guarded_pr_message(&winner, None, true)
15608 .await
15609 .unwrap()
15610 .unwrap();
15611 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15612 assert_eq!(
15613 runner.state.github_text.as_ref().unwrap().choice,
15614 Some(GithubTextChoice::Fallback)
15615 );
15616 }
15617
15618 #[tokio::test]
15619 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15620 crate::run::pin_test_home();
15621 let dir = tempfile::tempdir().unwrap();
15622 let mut runner = runner_at(RunStatus::Gating);
15623 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15624 runner.state.repo = dir.path().to_owned();
15625 runner.state.candidates[0].worktree = dir.path().to_owned();
15626 seed_public_summary(&mut runner);
15627 let winner = runner.state.candidates[0].clone();
15628 assert!(
15629 runner
15630 .guarded_pr_message(&winner, None, true)
15631 .await
15632 .unwrap()
15633 .is_none()
15634 );
15635 let store = ask::Questions::open();
15636 let find = |run: &str| {
15637 store
15638 .list()
15639 .into_iter()
15640 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15641 .unwrap()
15642 };
15643 let run = runner.state.id.clone();
15644 let q = find(&run);
15645 let fp = runner
15646 .state
15647 .github_text
15648 .as_ref()
15649 .unwrap()
15650 .fingerprint
15651 .clone();
15652 let name = crate::github_text::artifact_name(&fp);
15653 let path = crate::run::artifact_path(&runner.state, &name);
15654 assert!(q.detail.contains(&path.display().to_string()));
15655 assert!(q.detail.contains("title-language: title"));
15656 let saved = std::fs::read_to_string(&path).expect("artifact written");
15657 assert!(saved.contains("日本語のタイトル"));
15658 assert!(q.cwd.is_none());
15659 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15660 let say = |id: &str, text: &str, choice: &str| {
15662 store
15663 .update(id, |q| {
15664 q.thread.push(ask::Turn {
15665 who: ask::Who::Operator,
15666 body: text.to_owned(),
15667 at: jiff::Timestamp::now(),
15668 note: None,
15669 });
15670 q.answer(ask::Answer::Choice(choice.to_owned()))
15671 })
15672 .unwrap();
15673 };
15674 say(&q.id, "まだ日本語", "use my text");
15675 assert!(
15676 runner
15677 .guarded_pr_message(&winner, None, true)
15678 .await
15679 .unwrap()
15680 .is_none()
15681 );
15682 let second = find(&run);
15683 assert_ne!(second.id, q.id);
15684 assert!(second.detail.contains("did not pass"));
15685 assert!(second.detail.contains("日本語のタイトル"));
15688 assert!(!second.detail.contains("まだ日本語"));
15689 assert!(second.detail.contains(&path.display().to_string()));
15690 say(&second.id, "fix: retry failed requests", "use my text");
15691 let message = runner
15692 .guarded_pr_message(&winner, None, true)
15693 .await
15694 .unwrap()
15695 .expect("a vetted replacement is posted");
15696 assert_eq!(message.title, "fix: retry failed requests");
15697 assert_eq!(
15698 runner
15699 .state
15700 .github_text
15701 .as_ref()
15702 .unwrap()
15703 .chosen_title
15704 .as_deref(),
15705 Some("fix: retry failed requests")
15706 );
15707 assert!(runner.state.github_text.as_ref().unwrap().resolved);
15708 let again = runner
15710 .guarded_pr_message(&winner, None, true)
15711 .await
15712 .unwrap()
15713 .unwrap();
15714 assert_eq!(again.title, message.title);
15715 assert_eq!(
15716 store
15717 .list()
15718 .into_iter()
15719 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15720 .count(),
15721 2
15722 );
15723 }
15724
15725 #[test]
15726 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15727 let mut state = state_with_summary(
15731 "add retries",
15732 "TITLE: fix(web): batch reads\n- reads run.json once",
15733 );
15734 state.config.graph.language = "ja".to_owned();
15735 let m = pr_message(&state, 'A');
15736 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15737 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15738
15739 let task = "今回やってほしいこと: results projector を直す";
15742 let mut state = state_with_summary(task, "- no title line");
15743 state.config.graph.language = "ja".to_owned();
15744 let m = pr_message(&state, 'A');
15745 assert_eq!(
15746 m.title,
15747 format!("chore: land candidate A of run {}", state.id)
15748 );
15749 assert!(
15750 m.body.contains(&format!(
15751 "<summary>Original task</summary>\n\n{task}\n\n</details>"
15752 )),
15753 "{}",
15754 m.body
15755 );
15756 }
15757
15758 #[test]
15759 fn pr_message_scrubs_home_paths_and_addresses() {
15760 let state = state_with_summary(
15761 "fix it in /Users/someone/src/x",
15762 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15763 );
15764 let m = pr_message(&state, 'A');
15765 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15766 assert!(!m.body.contains(leak), "{}", m.body);
15767 }
15768 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15769 }
15770
15771 #[test]
15772 fn pr_message_survives_a_task_that_closes_details() {
15773 let state = state_with_summary("a </details> b", "TITLE: fix: x");
15774 let m = pr_message(&state, 'A');
15775 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15776 }
15777
15778 #[test]
15779 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15780 let cmd = manual_merge_command(
15781 MergeStyle::Squash,
15782 Path::new("/repo"),
15783 "b",
15784 "fix: \"quoted\" $(x) `y`\n\nbody",
15785 );
15786 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15787 }
15788
15789 #[test]
15790 fn manual_merge_command_matches_the_configured_style() {
15791 let repo = Path::new("/repo");
15792 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15793
15794 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15795 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15796
15797 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15798 assert_eq!(
15799 squash,
15800 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15801 \"Merge magi run 0832 (candidate A)\""
15802 );
15803
15804 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15805 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15806 }
15807
15808 #[test]
15809 fn a_nudge_gets_a_quarter_of_the_budget() {
15810 assert_eq!(retry_budget(secs(1200), true), secs(300));
15812 assert_eq!(retry_budget(secs(3600), true), secs(900));
15813 }
15814
15815 #[test]
15816 fn a_resent_prompt_keeps_the_whole_budget() {
15817 assert_eq!(retry_budget(secs(1200), false), secs(1200));
15820 assert_eq!(retry_budget(secs(60), false), secs(60));
15821 }
15822
15823 #[test]
15824 fn the_floor_never_exceeds_the_original_budget() {
15825 assert_eq!(retry_budget(secs(60), true), secs(60));
15829 assert_eq!(retry_budget(secs(480), true), secs(120));
15830 assert_eq!(retry_budget(secs(0), true), secs(0));
15831 }
15832
15833 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15834 agent::CommandEvidence {
15835 id: "item1".to_owned(),
15836 description: "cargo test".to_owned(),
15837 exit_code,
15838 result_summary: String::new(),
15839 source: "codex".to_owned(),
15840 }
15841 }
15842
15843 #[test]
15844 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15845 assert!(!has_unconfirmed_command(&[]));
15849 }
15850
15851 #[test]
15852 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15853 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15857 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15858 assert!(!has_unconfirmed_command(&[
15859 evidence(Some(0)),
15860 evidence(Some(101))
15861 ]));
15862 }
15863
15864 #[test]
15865 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15866 assert!(has_unconfirmed_command(&[
15867 evidence(Some(0)),
15868 evidence(None)
15869 ]));
15870 }
15871
15872 #[test]
15873 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15874 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15875 assert_eq!(
15876 verified_noop_claim(true, &[], text).as_deref(),
15877 Some("already fixed by b32cfc4, on main.")
15878 );
15879 }
15880
15881 #[test]
15882 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15883 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15886 assert!(verified_noop_claim(false, &[], text).is_none());
15887 }
15888
15889 #[test]
15890 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15891 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15892 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15893 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15895 }
15896
15897 #[test]
15898 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15899 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15900 }
15901
15902 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15905 runner.state.candidates = shape
15906 .iter()
15907 .enumerate()
15908 .map(|(i, &(empty, verified))| Candidate {
15909 index: i,
15910 label: (b'A' + i as u8) as char,
15911 agent: "sonnet".to_owned(),
15912 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15913 worktree: PathBuf::from(format!("/wt/{i}")),
15914 summary: String::new(),
15915 stat: String::new(),
15916 files: 0,
15917 commits: 0,
15918 empty,
15919 failed: None,
15920 verified_noop: verified.map(str::to_owned),
15921 duration_ms: 0,
15922 folded: false,
15923 })
15924 .collect();
15925 }
15926
15927 #[test]
15928 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15929 ask_test_home();
15930 let mut runner = runner_at(RunStatus::Implementing);
15931 set_candidates(
15932 &mut runner,
15933 &[
15934 (true, Some("already on main at b32cfc4")),
15935 (true, Some("same fix, see the existing test")),
15936 ],
15937 );
15938
15939 runner
15940 .after_implement()
15941 .expect("a verified no-op is not an error");
15942
15943 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
15944 }
15945
15946 #[test]
15947 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
15948 ask_test_home();
15949 let mut runner = runner_at(RunStatus::Implementing);
15950 set_candidates(
15954 &mut runner,
15955 &[(true, Some("already on main at b32cfc4")), (true, None)],
15956 );
15957
15958 let err = runner
15959 .after_implement()
15960 .expect_err("an unverified empty candidate must still fail the run");
15961
15962 assert!(
15963 err.to_string().contains("no candidate produced a change"),
15964 "{err}"
15965 );
15966 assert_eq!(runner.state.status, RunStatus::Failed);
15967 }
15968
15969 #[test]
15970 fn after_implement_still_fails_an_ordinary_all_empty_run() {
15971 ask_test_home();
15972 let mut runner = runner_at(RunStatus::Implementing);
15973 set_candidates(&mut runner, &[(true, None), (true, None)]);
15974
15975 let err = runner
15976 .after_implement()
15977 .expect_err("no candidate declared anything; this is an ordinary failure");
15978
15979 assert!(
15980 err.to_string().contains("no candidate produced a change"),
15981 "{err}"
15982 );
15983 assert_eq!(runner.state.status, RunStatus::Failed);
15984 }
15985}
15986
15987#[derive(Debug)]
15990struct GraftRestoreFailed(String);
15991
15992impl std::fmt::Display for GraftRestoreFailed {
15993 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15994 write!(
15995 f,
15996 "could not restore the winner to its pre-graft commit: {}",
15997 self.0
15998 )
15999 }
16000}
16001
16002impl std::error::Error for GraftRestoreFailed {}
16003
16004#[cfg(test)]
16008mod resource_wait_tests {
16009 use super::*;
16010 use std::collections::BTreeMap;
16011
16012 fn sh(id: &str, script: &str) -> AgentSpec {
16013 AgentSpec {
16014 id: id.to_owned(),
16015 kind: crate::config::AgentKind::Command,
16016 model: None,
16017 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16018 extra_args: Vec::new(),
16019 env: BTreeMap::new(),
16020 prompt_delivery: None,
16021 }
16022 }
16023
16024 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16025 SeatJob {
16026 seat: SeatState::new("impl-A", &spec.id, 1),
16027 spec,
16028 cwd: dir.to_path_buf(),
16029 prompt: "do it".to_owned(),
16030 timeout,
16031 allow_write: true,
16032 sessions: false,
16033 artifacts: dir.join("art"),
16034 stem: "impl-A".to_owned(),
16035 handover: None,
16036 }
16037 }
16038
16039 fn state(resource_wait_secs: u64) -> RunState {
16040 let mut config = Config::default();
16041 config.graph.timeout_resource_wait = resource_wait_secs;
16042 RunState::new(
16043 PathBuf::from("/repo"),
16044 "main".to_owned(),
16045 "abc1234".to_owned(),
16046 "task".to_owned(),
16047 config,
16048 )
16049 }
16050
16051 fn hold(cache: &Path) -> crate::cache::Guard {
16052 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16053 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16054 crate::cache::AcquireOutcome::Acquired(g) => g,
16055 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16056 }
16057 }
16058
16059 async fn run(
16060 jobs: Vec<SeatJob>,
16061 sem: Arc<Semaphore>,
16062 cache: Option<&Path>,
16063 st: &mut RunState,
16064 ) -> Vec<(usize, SeatState, AgentOutcome)> {
16065 let prompts = Prompts::default();
16066 let ctx = WaveCtx {
16067 run: "r",
16068 node: "implement",
16069 prompts: &prompts,
16070 cache,
16071 round: None,
16072 carry_seats: false,
16073 pauses: None,
16074 };
16075 wave(jobs, sem, &ctx, st, 0).await
16076 }
16077
16078 #[tokio::test]
16079 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16080 crate::run::pin_test_home();
16081 let tmp = tempfile::tempdir().unwrap();
16082 let cache = tmp.path().join("target-a");
16083 let guard = hold(&cache);
16084 tokio::spawn(async move {
16086 tokio::time::sleep(Duration::from_millis(1500)).await;
16087 guard.release();
16088 });
16089 let mut st = state(60);
16090 let results = run(
16091 vec![job(
16092 sh("alpha", "echo ok"),
16093 tmp.path(),
16094 Duration::from_secs(1),
16095 )],
16096 Arc::new(Semaphore::new(1)),
16097 Some(&cache),
16098 &mut st,
16099 )
16100 .await;
16101 assert!(
16102 matches!(results[0].2, AgentOutcome::Ok(_)),
16103 "the wait must not be charged to the agent"
16104 );
16105 assert!(st.resource_block.is_none());
16106 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16107 assert!(st.resource_wait.is_none() && st.active.is_empty());
16108 }
16109
16110 #[tokio::test]
16111 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16112 crate::run::pin_test_home();
16113 let tmp = tempfile::tempdir().unwrap();
16114 let cache = tmp.path().join("target-b");
16115 let _held = hold(&cache);
16116 let mut st = state(1);
16117 let results = run(
16118 vec![job(
16119 sh("alpha", "echo ok"),
16120 tmp.path(),
16121 Duration::from_secs(1),
16122 )],
16123 Arc::new(Semaphore::new(1)),
16124 Some(&cache),
16125 &mut st,
16126 )
16127 .await;
16128 let (_, seat, out) = &results[0];
16129 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16130 assert!(wave_blocked(&results));
16131 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16132 assert_eq!(seat.turns, 0, "nothing was asked");
16133 assert!(
16134 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16135 "no agent was spawned"
16136 );
16137 assert!(FailClass::of(out).is_none(), "not a failure class");
16138 let block = st.resource_block.as_ref().expect("recorded for the resume");
16139 assert_eq!(block.kind, "shared build cache");
16140 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16141 assert!(st.active.is_empty());
16142 }
16143
16144 #[tokio::test]
16145 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16146 crate::run::pin_test_home();
16147 let tmp = tempfile::tempdir().unwrap();
16148 let mut st = state(1);
16149 let results = run(
16150 vec![job(
16151 sh("alpha", "echo ok"),
16152 tmp.path(),
16153 Duration::from_secs(1),
16154 )],
16155 Arc::new(Semaphore::new(0)),
16156 None,
16157 &mut st,
16158 )
16159 .await;
16160 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16161 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16162 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16163 }
16164
16165 #[tokio::test]
16166 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16167 crate::run::pin_test_home();
16168 let tmp = tempfile::tempdir().unwrap();
16169 let mut st = state(60);
16170 let results = run(
16171 vec![job(
16172 sh("alpha", "sleep 30"),
16173 tmp.path(),
16174 Duration::from_secs(1),
16175 )],
16176 Arc::new(Semaphore::new(1)),
16177 None,
16178 &mut st,
16179 )
16180 .await;
16181 let out = &results[0].2;
16182 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16183 let class = FailClass::of(out).expect("a failure");
16184 assert_eq!(class, FailClass::Timeout);
16185 assert!(should_hand_over(None, &class), "existing handover policy");
16186 assert!(st.resource_block.is_none());
16187 }
16188}