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 state.status = status;
11954 Runner {
11955 state,
11956 roles: ResolvedRoles {
11957 implementers: Vec::new(),
11958 judges: Vec::new(),
11959 reviewers: Vec::new(),
11960 fixer: None,
11961 conductor: conductor(),
11962 implementer_roster: Vec::new(),
11963 judge_roster: Vec::new(),
11964 reviewer_roster: Vec::new(),
11965 },
11966 sem: Arc::new(Semaphore::new(1)),
11967 pause: Pause::new(),
11968 interrupt: Pause::new(),
11969 }
11970 }
11971
11972 fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
11973 crate::run::MergeRepair {
11974 question: "repair-question".into(),
11975 head: "old-head".into(),
11976 pr: "https://github.com/o/r/pull/42".into(),
11977 reviews: vec![review_round(false, 1, 1, 1, false, true)],
11978 gate: vec![outcome(Some(0), false)],
11979 gate_head: Some("old-head".into()),
11980 gate_fixes: Vec::new(),
11981 pre_gate: Vec::new(),
11982 phase,
11983 failure: None,
11984 }
11985 }
11986
11987 #[tokio::test]
11988 async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
11989 crate::run::pin_test_home();
11990 let mut runner = runner_at(RunStatus::Landing);
11991 runner.state.merge_repairs.push(merge_repair_request(
11992 crate::run::MergeRepairPhase::Requested,
11993 ));
11994 runner.state.save().unwrap();
11995 let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
11996 assert!(
11997 runner
11998 .execute()
11999 .await
12000 .unwrap_err()
12001 .to_string()
12002 .contains("already running")
12003 );
12004 assert_eq!(
12005 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12006 crate::run::MergeRepairPhase::Requested
12007 );
12008 drop(claim);
12009 let mut disk = runner.state.clone();
12010 disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12011 disk.save().unwrap();
12012 assert!(
12013 runner
12014 .execute()
12015 .await
12016 .unwrap_err()
12017 .to_string()
12018 .contains("decision changed")
12019 );
12020 assert_eq!(
12021 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12022 crate::run::MergeRepairPhase::Complete
12023 );
12024 }
12025
12026 #[test]
12027 fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12028 crate::run::pin_test_home();
12029 let mut runner = runner_at(RunStatus::Landing);
12030 let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12031 runner.state.reviews = request.reviews.clone();
12032 runner.state.gate = request.gate.clone();
12033 runner.state.gate_ran = true;
12034 runner.state.land_approval = Some(crate::run::LandApproval {
12035 question: "old".into(),
12036 head: "old-head".into(),
12037 });
12038 runner.state.merge_repairs.push(request);
12039 runner.begin_merge_repair_review(0).unwrap();
12040 assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12041 assert!(!runner.state.gate_status().ok());
12042 assert!(runner.state.land_approval.is_none());
12043 let disk = RunState::load(&runner.state.id).unwrap();
12044 assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12045 assert!(disk.merge_repairs[0].gate[0].ok());
12046 assert_eq!(
12047 disk.merge_repairs[0].phase,
12048 crate::run::MergeRepairPhase::Reviewing
12049 );
12050 assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12051 assert!(disk.reviews.is_empty());
12052 }
12053
12054 #[tokio::test]
12055 async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12056 crate::run::pin_test_home();
12057 for phase in [
12058 crate::run::MergeRepairPhase::Fixing,
12059 crate::run::MergeRepairPhase::Requested,
12060 ] {
12061 let mut runner = runner_at(RunStatus::Landing);
12062 runner.state.config.graph.land_rounds = 0;
12063 runner.state.merge_repairs.push(merge_repair_request(phase));
12064 runner.state.save().unwrap();
12065 assert!(!runner.repair_merge_review().await.unwrap());
12066 assert_eq!(runner.state.status, RunStatus::Blocked);
12067 assert_eq!(
12068 runner.state.merge_repairs[0].phase,
12069 crate::run::MergeRepairPhase::Failed
12070 );
12071 let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12072 assert!(
12073 reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12074 "interrupted"
12075 } else {
12076 "budget exhausted"
12077 })
12078 );
12079 assert!(runner.state.merge.is_none());
12080 }
12081 }
12082
12083 #[test]
12087 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12088 crate::run::pin_test_home();
12089 let mut runner = runner_at(RunStatus::Implementing);
12090 let interrupt = Pause::new();
12091 runner.watch_interrupt(interrupt.clone());
12092
12093 interrupt.park_because("task a1b2 asked to run first");
12094
12095 assert!(runner.park_here().expect("park_here"));
12096 assert!(runner.state.parked);
12097 let last = runner.state.events.last().expect("a park event");
12098 assert_eq!(last.node, "park");
12099 assert!(
12100 last.message.contains("task a1b2 asked to run first"),
12101 "expected the interrupt reason in {:?}",
12102 last.message
12103 );
12104 }
12105
12106 #[test]
12109 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12110 crate::run::pin_test_home();
12111 let mut runner = runner_at(RunStatus::Implementing);
12112 runner.state.begin_pass();
12113 let shutdown = Pause::new();
12114 runner.on_pause(shutdown.clone());
12115 let upgrade = ParkCause::Upgrade {
12116 from: "0.121.0".to_owned(),
12117 to: Some("0.121.1".to_owned()),
12118 };
12119 shutdown.park_for(upgrade.clone());
12120 shutdown.park_for(ParkCause::Operator);
12121
12122 assert!(runner.park_here().expect("park_here"));
12123 assert_eq!(
12124 runner.state.passes[0].end,
12125 Some(crate::run::PassEnd::Parked { cause: upgrade })
12126 );
12127 let last = runner.state.events.last().expect("a park event");
12128 assert!(last.message.contains("0.121.0"), "{}", last.message);
12129 }
12130
12131 #[test]
12139 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12140 crate::run::pin_test_home();
12141 let mut runner = runner_at(RunStatus::Implementing);
12142 let shutdown = Pause::new();
12143 runner.on_pause(shutdown.clone());
12144 let interrupt = Pause::new();
12145 runner.watch_interrupt(interrupt.clone());
12146
12147 assert!(!runner.park_here().expect("park_here"));
12149 assert!(!runner.state.parked);
12150
12151 interrupt.park_because("test");
12153 assert!(!shutdown.parked());
12154 assert!(runner.park_here().expect("park_here"));
12155 }
12156
12157 #[tokio::test]
12171 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12172 crate::run::pin_test_home();
12173 let mut runner = runner_at(RunStatus::Implementing);
12174 let interrupt = Pause::new();
12175 runner.watch_interrupt(interrupt.clone());
12176
12177 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12178 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12179
12180 let node = async move {
12184 started_tx.send(()).expect("send started");
12185 finish_rx.await.expect("recv finish");
12186 "node finished"
12187 };
12188
12189 let interrupter = async move {
12190 started_rx.await.expect("recv started");
12191 interrupt.park_because("higher-priority task waiting");
12193 tokio::task::yield_now().await;
12197 finish_tx.send(()).expect("send finish");
12198 };
12199
12200 let (node_result, ()) = tokio::join!(node, interrupter);
12201 assert_eq!(
12202 node_result, "node finished",
12203 "the in-flight call ran to completion"
12204 );
12205
12206 assert!(runner.park_here().expect("park_here"));
12209 assert!(runner.state.parked);
12210 }
12211
12212 #[test]
12218 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12219 crate::run::pin_test_home();
12220 let mut runner = runner_at(RunStatus::Judging);
12221 runner.state.config.agents = vec![conductor()];
12225 runner.state.candidates = vec![Candidate {
12226 index: 0,
12227 label: 'A',
12228 agent: "alpha".to_owned(),
12229 branch: "magi/x/A".to_owned(),
12230 worktree: PathBuf::from("/nonexistent/worktree"),
12231 summary: "did the thing".to_owned(),
12232 stat: "1 file changed".to_owned(),
12233 files: 1,
12234 commits: 1,
12235 empty: false,
12236 failed: None,
12237 verified_noop: None,
12238 duration_ms: 1234,
12239 folded: false,
12240 }];
12241 let run_id = runner.state.id.clone();
12242
12243 let interrupt = Pause::new();
12244 runner.watch_interrupt(interrupt.clone());
12245 interrupt.park_because("task c3d4 asked to run first");
12246 assert!(runner.park_here().expect("park_here"));
12247
12248 let resumed = Runner::resume(&run_id).expect("resume");
12249 assert_eq!(resumed.state.candidates.len(), 1);
12250 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12251 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12252 assert_eq!(resumed.state.status, runner.state.status);
12253 assert!(
12254 resumed.state.parked,
12255 "still parked until `execute` actually walks the graph again"
12256 );
12257 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12258 }
12259
12260 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12262 let mut q = ask::Question::new(
12263 run.to_owned(),
12264 "implement".to_owned(),
12265 "impl-A".to_owned(),
12266 "Which storage backend should the cache use?".to_owned(),
12267 String::new(),
12268 vec!["SQLite".to_owned(), "Redis".to_owned()],
12269 );
12270 store.put(&mut q).unwrap();
12271 q
12272 }
12273
12274 #[test]
12275 fn a_failed_runs_open_question_is_abandoned() {
12276 ask_test_home();
12277 let store = ask::Questions::open();
12278 let mut runner = runner_at(RunStatus::Failed);
12279 let run = runner.state.id.clone();
12280 let q = ask_open_question(&store, &run);
12281
12282 runner.settle_questions();
12283
12284 let back = store.get(&q.id).unwrap();
12285 assert!(
12286 !back.status.open(),
12287 "the seat that asked died with the run; nobody is left to read an answer"
12288 );
12289 assert!(
12290 back.detail.contains(&run) && back.detail.contains("failed"),
12291 "the reason names what the run became, not just that it is gone: {}",
12292 back.detail
12293 );
12294 }
12295
12296 #[test]
12297 fn a_merged_runs_open_question_is_abandoned_too() {
12298 ask_test_home();
12299 let store = ask::Questions::open();
12300 for status in [RunStatus::Merged, RunStatus::Ready] {
12303 let mut runner = runner_at(status);
12304 let run = runner.state.id.clone();
12305 let q = ask_open_question(&store, &run);
12306
12307 runner.settle_questions();
12308
12309 let back = store.get(&q.id).unwrap();
12310 assert!(
12311 !back.status.open(),
12312 "{status:?} run's question must not outlive the run"
12313 );
12314 }
12315 }
12316
12317 #[test]
12318 fn a_still_resumable_runs_open_question_is_left_alone() {
12319 ask_test_home();
12320 let store = ask::Questions::open();
12321 for status in [RunStatus::Blocked, RunStatus::Stalled] {
12327 let mut runner = runner_at(status);
12328 let run = runner.state.id.clone();
12329 let q = ask_open_question(&store, &run);
12330
12331 runner.settle_questions();
12332
12333 let back = store.get(&q.id).unwrap();
12334 assert!(
12335 back.status.open(),
12336 "{status:?} is still alive; the question must still be waiting"
12337 );
12338 }
12339 }
12340
12341 #[test]
12342 fn settle_questions_never_touches_an_already_answered_question() {
12343 ask_test_home();
12344 let store = ask::Questions::open();
12345 let mut runner = runner_at(RunStatus::Failed);
12346 let run = runner.state.id.clone();
12347 let mut q = ask_open_question(&store, &run);
12348 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12349 .unwrap();
12350 store.put(&mut q).unwrap();
12351
12352 runner.settle_questions();
12357 runner.settle_questions();
12358
12359 let back = store.get(&q.id).unwrap();
12360 assert_eq!(
12361 back.status,
12362 ask::QuestionStatus::Answered,
12363 "a real answer is a decision on record, never overwritten by a sweep"
12364 );
12365 }
12366
12367 #[tokio::test]
12378 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12379 crate::run::pin_test_home();
12380 let tmp = tempfile::tempdir().expect("tempdir");
12381 let repo = tmp.path().join("repo");
12382 std::fs::create_dir_all(&repo).unwrap();
12383 init_repo(&repo);
12384
12385 let mut config = Config::default();
12386 config.graph.worktree_root = Some(tmp.path().join("wt"));
12387
12388 let mut state = RunState::new(
12389 repo.clone(),
12390 "main".to_owned(),
12391 "deadbeef".to_owned(),
12392 "task".to_owned(),
12393 config,
12394 );
12395 let root = state.worktree_root();
12396 let wt_a = root.join("cand-A");
12397 let wt_b = root.join("cand-B");
12398 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12399 .await
12400 .expect("worktree A");
12401 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12402 .await
12403 .expect("worktree B");
12404
12405 state.candidates = vec![
12406 Candidate {
12407 index: 0,
12408 label: 'A',
12409 agent: "alpha".to_owned(),
12410 branch: "magi/x/A".to_owned(),
12411 worktree: wt_a.clone(),
12412 summary: String::new(),
12413 stat: String::new(),
12414 files: 0,
12415 commits: 0,
12416 empty: false,
12417 failed: None,
12418 verified_noop: None,
12419 duration_ms: 0,
12420 folded: false,
12421 },
12422 Candidate {
12423 index: 1,
12424 label: 'B',
12425 agent: "beta".to_owned(),
12426 branch: "magi/x/B".to_owned(),
12427 worktree: wt_b.clone(),
12428 summary: String::new(),
12429 stat: String::new(),
12430 files: 0,
12431 commits: 0,
12432 empty: false,
12433 failed: None,
12434 verified_noop: None,
12435 duration_ms: 0,
12436 folded: false,
12437 },
12438 ];
12439 state.tally = Some(Tally {
12440 first_choice: BTreeMap::from([('A', 1)]),
12441 borda: BTreeMap::new(),
12442 winner: 'A',
12443 rankings: 1,
12444 unanimous_initial: true,
12445 deliberated: false,
12446 changed_votes: 0,
12447 unanimous_final: true,
12448 tie_break: None,
12449 judges: 1,
12450 present: 1,
12451 quorum: 1,
12452 met_quorum: true,
12453 uncontested: None,
12454 });
12455 state.status = RunStatus::Ready;
12456
12457 fold_run(&mut state, false, &crate::run::home())
12458 .await
12459 .expect("fold_run");
12460
12461 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12462 assert!(
12463 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12464 "the unmerged winner's branch survives"
12465 );
12466 assert!(
12467 !state.candidates[0].folded,
12468 "the winner is not marked folded"
12469 );
12470
12471 assert!(!wt_b.exists(), "the loser's worktree is removed");
12472 assert!(
12473 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12474 "the loser's branch is removed"
12475 );
12476 assert!(state.candidates[1].folded, "the loser is marked folded");
12477 }
12478
12479 #[tokio::test]
12482 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12483 let tmp = tempfile::tempdir().expect("tempdir");
12484 let repo = tmp.path().join("repo");
12485 std::fs::create_dir_all(&repo).unwrap();
12486 init_repo(&repo);
12487 let home = tmp.path().join("home");
12488
12489 let mut config = Config::default();
12490 config.graph.worktree_root = Some(tmp.path().join("wt"));
12491 let mut state = RunState::new(
12492 repo.clone(),
12493 "main".to_owned(),
12494 "deadbeef".to_owned(),
12495 "task".to_owned(),
12496 config,
12497 );
12498 git::git(&repo, &["branch", "magi/x/A", "main"])
12500 .await
12501 .expect("branch");
12502 state.candidates = vec![Candidate {
12503 index: 0,
12504 label: 'A',
12505 agent: "alpha".to_owned(),
12506 branch: "magi/x/A".to_owned(),
12507 worktree: state.worktree_root().join("cand-A"),
12508 summary: String::new(),
12509 stat: String::new(),
12510 files: 0,
12511 commits: 0,
12512 empty: false,
12513 failed: None,
12514 verified_noop: None,
12515 duration_ms: 0,
12516 folded: true,
12517 }];
12518 state.released_to = Some("20260901-000000-new1".to_owned());
12519 state.released_branches = vec!["magi/x/A".to_owned()];
12520
12521 fold_run(&mut state, true, &home).await.expect("fold_run");
12522
12523 assert!(
12524 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12525 "the handed-over branch survives a fold"
12526 );
12527 }
12528
12529 #[tokio::test]
12533 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12534 let tmp = tempfile::tempdir().expect("tempdir");
12535 let repo = tmp.path().join("repo");
12536 std::fs::create_dir_all(&repo).unwrap();
12537 init_repo(&repo);
12538 let run = |args: &[&str]| {
12539 let out = std::process::Command::new("git")
12540 .quiet()
12541 .args(args)
12542 .current_dir(&repo)
12543 .output()
12544 .expect("spawn git");
12545 assert!(out.status.success(), "git {args:?}");
12546 };
12547 run(&["branch", "magi/x/A"]);
12548 run(&["checkout", "-q", "-b", "magi/x/B"]);
12549 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12550 run(&["add", "-A"]);
12551 run(&["commit", "-q", "-m", "work"]);
12552 run(&["checkout", "-q", "main"]);
12553
12554 let probe = RunState::new(
12558 repo.clone(),
12559 "main".to_owned(),
12560 "deadbeef".to_owned(),
12561 "task".to_owned(),
12562 Config::default(),
12563 );
12564 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12565 let bare = tmp.path().join("origin.git");
12566 let out = std::process::Command::new("git")
12567 .quiet()
12568 .args(["init", "-q", "--bare"])
12569 .arg(&bare)
12570 .output()
12571 .expect("spawn git");
12572 assert!(out.status.success(), "git init --bare");
12573 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12574 run(&["push", "-q", "origin", "main"]);
12575
12576 let mut state = RunState::new(
12577 repo.clone(),
12578 "main".to_owned(),
12579 "deadbeef".to_owned(),
12580 "task".to_owned(),
12581 Config::default(),
12582 );
12583 state.seeds = vec![refs::Seed {
12584 token: "magi/27b2/A".to_owned(),
12585 kind: refs::SeedKind::Unresolved,
12586 sha: String::new(),
12587 branch: true,
12588 detail: "no branch or commit named magi/27b2/A".to_owned(),
12589 }];
12590
12591 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12592 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12593 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12594 let detail = empty_candidate_detail(&state, "main");
12595 assert!(detail.starts_with("empty candidate"), "{detail}");
12596 assert!(detail.contains("magi/27b2/A"), "{detail}");
12597 }
12598
12599 #[tokio::test]
12608 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12609 let tmp = tempfile::tempdir().expect("tempdir");
12610 let repo = tmp.path().join("repo");
12611 std::fs::create_dir_all(&repo).unwrap();
12612 init_repo(&repo);
12613
12614 let mut config = Config::default();
12615 config.merge.mode = MergeMode::Local;
12616
12617 let mut state = RunState::new(
12618 repo.clone(),
12619 "main".to_owned(),
12620 "deadbeef".to_owned(),
12621 "task".to_owned(),
12622 config,
12623 );
12624 state.candidates = vec![Candidate {
12625 index: 0,
12626 label: 'A',
12627 agent: "alpha".to_owned(),
12628 branch: "does-not-exist".to_owned(),
12629 worktree: repo.clone(),
12630 summary: String::new(),
12631 stat: String::new(),
12632 files: 0,
12633 commits: 0,
12634 empty: false,
12635 failed: None,
12636 verified_noop: None,
12637 duration_ms: 0,
12638 folded: false,
12639 }];
12640 state.tally = Some(Tally {
12641 first_choice: BTreeMap::from([('A', 1)]),
12642 borda: BTreeMap::new(),
12643 winner: 'A',
12644 rankings: 1,
12645 unanimous_initial: true,
12646 deliberated: false,
12647 changed_votes: 0,
12648 unanimous_final: true,
12649 tie_break: None,
12650 judges: 0,
12651 present: 0,
12652 quorum: 0,
12653 met_quorum: true,
12654 uncontested: Some("only candidate A produced a change".to_owned()),
12655 });
12656 state.reviews = vec![ReviewRound {
12657 round: 1,
12658 head: "deadbeef".to_owned(),
12659 verified_head: None,
12660 verified_at: None,
12661 reviews: Vec::new(),
12662 e2e: Vec::new(),
12663 fix: None,
12664 blocking: 0,
12665 answered: 0,
12666 expected: 0,
12667 clean: true,
12668 verify_retried: false,
12669 e2e_deferred: false,
12670 e2e_defer_reason: None,
12671 progressed: false,
12672 vote_split: false,
12673 reconsideration: Vec::new(),
12674 verdict: None,
12675 }];
12676 state.gate = vec![CommandOutcome {
12677 command: "test".to_owned(),
12678 code: Some(0),
12679 output_tail: String::new(),
12680 duration_ms: 0,
12681 resource_blocked: false,
12682 }];
12683 state.gate_ran = true;
12684 state.status = RunStatus::Ready;
12689 state.merge = Some(MergeOutcome {
12690 mode: MergeMode::Local,
12691 ok: false,
12692 detail: "already concluded".to_owned(),
12693 empty: false,
12694 });
12695
12696 let mut runner = Runner {
12697 state,
12698 roles: ResolvedRoles {
12699 implementers: Vec::new(),
12700 judges: Vec::new(),
12701 reviewers: Vec::new(),
12702 fixer: None,
12703 conductor: conductor(),
12704 implementer_roster: Vec::new(),
12705 judge_roster: Vec::new(),
12706 reviewer_roster: Vec::new(),
12707 },
12708 sem: Arc::new(Semaphore::new(1)),
12709 pause: Pause::new(),
12710 interrupt: Pause::new(),
12711 };
12712
12713 runner.merge().await.expect("merge");
12714
12715 assert_eq!(
12716 runner.state.status,
12717 RunStatus::Ready,
12718 "a concluded run's status must not change on reentry"
12719 );
12720 assert_eq!(
12721 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12722 Some("already concluded"),
12723 "merge must not run again once the node already recorded an outcome"
12724 );
12725 }
12726
12727 #[tokio::test]
12736 async fn merge_refuses_a_gate_that_has_not_actually_run() {
12737 let tmp = tempfile::tempdir().expect("tempdir");
12738 let repo = tmp.path().join("repo");
12739 std::fs::create_dir_all(&repo).unwrap();
12740 init_repo(&repo);
12741
12742 let mut config = Config::default();
12743 config.merge.mode = MergeMode::Local;
12744
12745 let mut state = RunState::new(
12746 repo.clone(),
12747 "main".to_owned(),
12748 "deadbeef".to_owned(),
12749 "task".to_owned(),
12750 config,
12751 );
12752 state.candidates = vec![Candidate {
12753 index: 0,
12754 label: 'A',
12755 agent: "alpha".to_owned(),
12756 branch: "does-not-exist".to_owned(),
12757 worktree: repo.clone(),
12758 summary: String::new(),
12759 stat: String::new(),
12760 files: 0,
12761 commits: 0,
12762 empty: false,
12763 failed: None,
12764 verified_noop: None,
12765 duration_ms: 0,
12766 folded: false,
12767 }];
12768 state.tally = Some(Tally {
12769 first_choice: BTreeMap::from([('A', 1)]),
12770 borda: BTreeMap::new(),
12771 winner: 'A',
12772 rankings: 1,
12773 unanimous_initial: true,
12774 deliberated: false,
12775 changed_votes: 0,
12776 unanimous_final: true,
12777 tie_break: None,
12778 judges: 0,
12779 present: 0,
12780 quorum: 0,
12781 met_quorum: true,
12782 uncontested: Some("only candidate A produced a change".to_owned()),
12783 });
12784 state.reviews = vec![ReviewRound {
12785 round: 1,
12786 head: "deadbeef".to_owned(),
12787 verified_head: None,
12788 verified_at: None,
12789 reviews: Vec::new(),
12790 e2e: Vec::new(),
12791 fix: None,
12792 blocking: 0,
12793 answered: 0,
12794 expected: 0,
12795 clean: true,
12796 verify_retried: false,
12797 e2e_deferred: false,
12798 e2e_defer_reason: None,
12799 progressed: false,
12800 vote_split: false,
12801 reconsideration: Vec::new(),
12802 verdict: None,
12803 }];
12804 state.gate = Vec::new();
12806 state.gate_ran = false;
12807 state.status = RunStatus::Gating;
12808
12809 let mut runner = Runner {
12810 state,
12811 roles: ResolvedRoles {
12812 implementers: Vec::new(),
12813 judges: Vec::new(),
12814 reviewers: Vec::new(),
12815 fixer: None,
12816 conductor: conductor(),
12817 implementer_roster: Vec::new(),
12818 judge_roster: Vec::new(),
12819 reviewer_roster: Vec::new(),
12820 },
12821 sem: Arc::new(Semaphore::new(1)),
12822 pause: Pause::new(),
12823 interrupt: Pause::new(),
12824 };
12825
12826 runner.merge().await.expect("merge");
12827
12828 assert!(
12829 runner.state.merge.is_none(),
12830 "an empty gate must never be read as a passing one: {:?}",
12831 runner.state.merge
12832 );
12833 }
12834
12835 #[tokio::test]
12842 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12843 let tmp = tempfile::tempdir().expect("tempdir");
12844 let repo = tmp.path().join("repo");
12845 std::fs::create_dir_all(&repo).unwrap();
12846 init_repo(&repo);
12847
12848 let config = Config::default();
12850
12851 let mut state = RunState::new(
12852 repo.clone(),
12853 "main".to_owned(),
12854 "deadbeef".to_owned(),
12855 "task".to_owned(),
12856 config,
12857 );
12858 state.candidates = vec![Candidate {
12859 index: 0,
12860 label: 'A',
12861 agent: "alpha".to_owned(),
12862 branch: "does-not-exist".to_owned(),
12863 worktree: repo.clone(),
12864 summary: String::new(),
12865 stat: String::new(),
12866 files: 0,
12867 commits: 0,
12868 empty: false,
12869 failed: None,
12870 verified_noop: None,
12871 duration_ms: 0,
12872 folded: false,
12873 }];
12874 state.tally = Some(Tally {
12875 first_choice: BTreeMap::from([('A', 1)]),
12876 borda: BTreeMap::new(),
12877 winner: 'A',
12878 rankings: 1,
12879 unanimous_initial: true,
12880 deliberated: false,
12881 changed_votes: 0,
12882 unanimous_final: true,
12883 tie_break: None,
12884 judges: 0,
12885 present: 0,
12886 quorum: 0,
12887 met_quorum: true,
12888 uncontested: Some("only candidate A produced a change".to_owned()),
12889 });
12890 state.reviews = vec![ReviewRound {
12891 round: 1,
12892 head: "deadbeef".to_owned(),
12893 verified_head: None,
12894 verified_at: None,
12895 reviews: Vec::new(),
12896 e2e: Vec::new(),
12897 fix: None,
12898 blocking: 0,
12899 answered: 0,
12900 expected: 0,
12901 clean: true,
12902 verify_retried: false,
12903 e2e_deferred: false,
12904 e2e_defer_reason: None,
12905 progressed: false,
12906 vote_split: false,
12907 reconsideration: Vec::new(),
12908 verdict: None,
12909 }];
12910
12911 let mut runner = Runner {
12912 state,
12913 roles: ResolvedRoles {
12914 implementers: Vec::new(),
12915 judges: Vec::new(),
12916 reviewers: Vec::new(),
12917 fixer: None,
12918 conductor: conductor(),
12919 implementer_roster: Vec::new(),
12920 judge_roster: Vec::new(),
12921 reviewer_roster: Vec::new(),
12922 },
12923 sem: Arc::new(Semaphore::new(1)),
12924 pause: Pause::new(),
12925 interrupt: Pause::new(),
12926 };
12927
12928 runner.gate().await.expect("gate");
12929 assert!(
12930 runner.state.gate_ran,
12931 "zero configured commands is still a real attempt, not an unrun gate"
12932 );
12933 assert!(runner.state.gate.is_empty());
12934 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12935 assert_ne!(
12936 runner.state.status,
12937 RunStatus::Blocked,
12938 "a gate with nothing to check must not read as failed"
12939 );
12940
12941 runner.merge().await.expect("merge");
12942 assert_eq!(
12943 runner.state.status,
12944 RunStatus::Ready,
12945 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12946 );
12947 }
12948
12949 #[tokio::test]
12960 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12961 crate::run::pin_test_home();
12962 let home = crate::run::home();
12963
12964 let tmp = tempfile::tempdir().expect("tempdir");
12965 let repo = tmp.path().join("repo");
12966 std::fs::create_dir_all(&repo).unwrap();
12967 init_repo(&repo);
12968 let cache_dir = tmp.path().join("target");
12971
12972 let mut config = Config::default();
12973 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12974 config.graph.timeout_verify = Some(2);
12977
12978 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12979 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12980 .expect("no io error acquiring directly")
12981 {
12982 crate::cache::AcquireOutcome::Acquired(g) => g,
12983 crate::cache::AcquireOutcome::Busy(b) => {
12984 panic!("expected the direct acquire to win the lease first: {b:?}")
12985 }
12986 };
12987
12988 let mut state = RunState::new(
12989 repo.clone(),
12990 "main".to_owned(),
12991 "deadbeef".to_owned(),
12992 "task".to_owned(),
12993 config,
12994 );
12995 state.candidates = vec![Candidate {
12996 index: 0,
12997 label: 'A',
12998 agent: "alpha".to_owned(),
12999 branch: "does-not-exist".to_owned(),
13000 worktree: repo.clone(),
13001 summary: String::new(),
13002 stat: String::new(),
13003 files: 0,
13004 commits: 0,
13005 empty: false,
13006 failed: None,
13007 verified_noop: None,
13008 duration_ms: 0,
13009 folded: false,
13010 }];
13011 state.tally = Some(Tally {
13012 first_choice: BTreeMap::from([('A', 1)]),
13013 borda: BTreeMap::new(),
13014 winner: 'A',
13015 rankings: 1,
13016 unanimous_initial: true,
13017 deliberated: false,
13018 changed_votes: 0,
13019 unanimous_final: true,
13020 tie_break: None,
13021 judges: 0,
13022 present: 0,
13023 quorum: 0,
13024 met_quorum: true,
13025 uncontested: Some("only candidate A produced a change".to_owned()),
13026 });
13027 state.reviews = vec![ReviewRound {
13028 round: 1,
13029 head: "deadbeef".to_owned(),
13030 verified_head: None,
13031 verified_at: None,
13032 reviews: Vec::new(),
13033 e2e: Vec::new(),
13034 fix: None,
13035 blocking: 0,
13036 answered: 0,
13037 expected: 0,
13038 clean: true,
13039 verify_retried: false,
13040 e2e_deferred: false,
13041 e2e_defer_reason: None,
13042 progressed: false,
13043 vote_split: false,
13044 reconsideration: Vec::new(),
13045 verdict: None,
13046 }];
13047
13048 let mut runner = Runner {
13049 state,
13050 roles: ResolvedRoles {
13051 implementers: Vec::new(),
13052 judges: Vec::new(),
13053 reviewers: Vec::new(),
13054 fixer: None,
13055 conductor: conductor(),
13056 implementer_roster: Vec::new(),
13057 judge_roster: Vec::new(),
13058 reviewer_roster: Vec::new(),
13059 },
13060 sem: Arc::new(Semaphore::new(1)),
13061 pause: Pause::new(),
13062 interrupt: Pause::new(),
13063 };
13064
13065 let started = std::time::Instant::now();
13066 runner.gate().await.expect("gate");
13067 assert!(
13068 started.elapsed() < Duration::from_secs(1),
13069 "a gate with nothing to run must never wait on a lease it never needed"
13070 );
13071 assert!(
13072 runner.state.gate_ran,
13073 "zero commands is still a real, immediate attempt"
13074 );
13075 assert!(runner.state.gate.is_empty());
13076 assert_ne!(
13077 runner.state.status,
13078 RunStatus::Blocked,
13079 "must not read as resource-blocked on a lease it never asked for"
13080 );
13081 }
13082
13083 #[tokio::test]
13093 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
13094 crate::run::pin_test_home();
13095
13096 let tmp = tempfile::tempdir().expect("tempdir");
13097 let repo = tmp.path().join("repo");
13098 std::fs::create_dir_all(&repo).unwrap();
13099 init_repo(&repo);
13100
13101 let mut config = Config::default();
13102 config.verify.gate = vec![
13103 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
13104 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
13105 .to_owned(),
13106 ];
13107
13108 let mut state = RunState::new(
13109 repo.clone(),
13110 "main".to_owned(),
13111 "deadbeef".to_owned(),
13112 "task".to_owned(),
13113 config,
13114 );
13115 let run_id = state.id.clone();
13116 state.candidates = vec![Candidate {
13117 index: 0,
13118 label: 'A',
13119 agent: "alpha".to_owned(),
13120 branch: "does-not-exist".to_owned(),
13121 worktree: repo.clone(),
13122 summary: String::new(),
13123 stat: String::new(),
13124 files: 0,
13125 commits: 0,
13126 empty: false,
13127 failed: None,
13128 verified_noop: None,
13129 duration_ms: 0,
13130 folded: false,
13131 }];
13132 state.tally = Some(Tally {
13133 first_choice: BTreeMap::from([('A', 1)]),
13134 borda: BTreeMap::new(),
13135 winner: 'A',
13136 rankings: 1,
13137 unanimous_initial: true,
13138 deliberated: false,
13139 changed_votes: 0,
13140 unanimous_final: true,
13141 tie_break: None,
13142 judges: 0,
13143 present: 0,
13144 quorum: 0,
13145 met_quorum: true,
13146 uncontested: Some("only candidate A produced a change".to_owned()),
13147 });
13148 state.reviews = vec![ReviewRound {
13149 round: 1,
13150 head: "deadbeef".to_owned(),
13151 verified_head: None,
13152 verified_at: None,
13153 reviews: Vec::new(),
13154 e2e: Vec::new(),
13155 fix: None,
13156 blocking: 0,
13157 answered: 0,
13158 expected: 0,
13159 clean: true,
13160 verify_retried: false,
13161 e2e_deferred: false,
13162 e2e_defer_reason: None,
13163 progressed: false,
13164 vote_split: false,
13165 reconsideration: Vec::new(),
13166 verdict: None,
13167 }];
13168
13169 let mut runner = Runner {
13170 state,
13171 roles: ResolvedRoles {
13172 implementers: Vec::new(),
13173 judges: Vec::new(),
13174 reviewers: Vec::new(),
13175 fixer: None,
13176 conductor: conductor(),
13177 implementer_roster: Vec::new(),
13178 judge_roster: Vec::new(),
13179 reviewer_roster: Vec::new(),
13180 },
13181 sem: Arc::new(Semaphore::new(1)),
13182 pause: Pause::new(),
13183 interrupt: Pause::new(),
13184 };
13185
13186 let started_marker = repo.join("started.marker");
13187 let release_marker = repo.join("release.marker");
13188 let poller = tokio::spawn(async move {
13189 for _ in 0..100 {
13194 if started_marker.exists()
13195 && let Ok(s) = crate::run::RunState::load(&run_id)
13196 && let Some(a) = s.active.get("gate")
13197 {
13198 std::fs::write(&release_marker, b"go").expect("release marker");
13199 return Some(a.clone());
13200 }
13201 tokio::time::sleep(Duration::from_millis(50)).await;
13202 }
13203 None
13204 });
13205
13206 runner.gate().await.expect("gate");
13207 let captured = poller.await.expect("poller task");
13208 let captured = captured.expect(
13209 "the poller never saw a `gate` task entry in run.json while the command was \
13210 still blocked on its own release marker",
13211 );
13212
13213 assert_eq!(captured.task.as_deref(), Some("gate"));
13214 assert_eq!(captured.node, "gate");
13215 assert_eq!(captured.index, Some(1));
13216 assert_eq!(captured.total, Some(1));
13217 assert!(
13218 captured
13219 .command
13220 .as_deref()
13221 .is_some_and(|c| c.contains("started.marker")),
13222 "{captured:?}"
13223 );
13224
13225 assert!(
13226 runner.state.active.is_empty(),
13227 "the entry must be cleared once the command actually finished: {:?}",
13228 runner.state.active
13229 );
13230 assert!(runner.state.gate_ran);
13231 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
13232 }
13233
13234 #[tokio::test]
13237 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
13238 use crate::verdict::{Finding, ReviewVote, Severity};
13239 crate::run::pin_test_home();
13240 let tmp = tempfile::tempdir().expect("tempdir");
13241 let repo = tmp.path().join("repo");
13242 std::fs::create_dir_all(&repo).unwrap();
13243 init_repo(&repo);
13244
13245 for (severity, vote, expect) in [
13246 (Severity::Major, ReviewVote::Reject, true),
13247 (Severity::Minor, ReviewVote::Reject, false),
13248 (Severity::Major, ReviewVote::Approve, false),
13249 ] {
13250 let mut round = review_round(false, 1, 1, 1, false, true);
13251 round.reviews = vec![ReviewRecord {
13252 reviewer: 1,
13253 agent: "alpha".to_owned(),
13254 summary: String::new(),
13255 findings: vec![Finding {
13256 id: "R1-1-1".to_owned(),
13257 severity,
13258 file: None,
13259 line: None,
13260 title: "t".to_owned(),
13261 detail: String::new(),
13262 }],
13263 vote: Some(vote),
13264 failed: None,
13265 duration_ms: 0,
13266 attempts: 0,
13267 }];
13268 let mut state = RunState::new(
13269 repo.clone(),
13270 "main".to_owned(),
13271 "deadbeef".to_owned(),
13272 "task".to_owned(),
13273 Config::default(),
13274 );
13275 state.reviews = vec![round];
13276 let mut runner = Runner {
13277 state,
13278 roles: ResolvedRoles {
13279 implementers: Vec::new(),
13280 judges: Vec::new(),
13281 reviewers: Vec::new(),
13282 fixer: None,
13283 conductor: conductor(),
13284 implementer_roster: Vec::new(),
13285 judge_roster: Vec::new(),
13286 reviewer_roster: Vec::new(),
13287 },
13288 sem: Arc::new(Semaphore::new(1)),
13289 pause: Pause::new(),
13290 interrupt: Pause::new(),
13291 };
13292 let shell = runner.state.config.shell();
13293 runner
13294 .stop_reviewing("round budget spent", &shell, &repo)
13295 .await
13296 .expect("stop_reviewing");
13297 assert_eq!(runner.state.status, RunStatus::Gating);
13298 assert_eq!(
13299 runner.state.contested_handoff.is_some(),
13300 expect,
13301 "{severity:?} + {vote:?}"
13302 );
13303 }
13304 }
13305
13306 #[tokio::test]
13319 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
13320 crate::run::pin_test_home();
13321 let home = crate::run::home();
13322
13323 let tmp = tempfile::tempdir().expect("tempdir");
13324 let repo = tmp.path().join("repo");
13325 std::fs::create_dir_all(&repo).unwrap();
13326 init_repo(&repo);
13327 let head = crate::git::rev_parse(&repo, "HEAD")
13328 .await
13329 .expect("rev-parse");
13330 let cache_dir = tmp.path().join("target");
13333
13334 let mut config = Config::default();
13335 config.verify.e2e = vec![format!(
13336 "CARGO_TARGET_DIR='{}' test -f README.md",
13337 cache_dir.display()
13338 )];
13339 config.graph.review_rounds = 1;
13340 config.graph.timeout_verify = Some(2);
13343
13344 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13345 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13346 .expect("no io error acquiring directly")
13347 {
13348 crate::cache::AcquireOutcome::Acquired(g) => g,
13349 crate::cache::AcquireOutcome::Busy(b) => {
13350 panic!("expected the direct acquire to win the lease first: {b:?}")
13351 }
13352 };
13353
13354 let mut state = RunState::new(
13355 repo.clone(),
13356 "main".to_owned(),
13357 head.clone(),
13358 "task".to_owned(),
13359 config,
13360 );
13361 state.candidates = vec![Candidate {
13362 index: 0,
13363 label: 'A',
13364 agent: "alpha".to_owned(),
13365 branch: "does-not-exist".to_owned(),
13366 worktree: repo.clone(),
13367 summary: String::new(),
13368 stat: String::new(),
13369 files: 0,
13370 commits: 0,
13371 empty: false,
13372 failed: None,
13373 verified_noop: None,
13374 duration_ms: 0,
13375 folded: false,
13376 }];
13377 state.tally = Some(Tally {
13378 first_choice: BTreeMap::from([('A', 1)]),
13379 borda: BTreeMap::new(),
13380 winner: 'A',
13381 rankings: 1,
13382 unanimous_initial: true,
13383 deliberated: false,
13384 changed_votes: 0,
13385 unanimous_final: true,
13386 tie_break: None,
13387 judges: 0,
13388 present: 0,
13389 quorum: 0,
13390 met_quorum: true,
13391 uncontested: Some("only candidate A produced a change".to_owned()),
13392 });
13393 state.reviews = vec![ReviewRound {
13397 round: 1,
13398 head: head.clone(),
13399 verified_head: None,
13400 verified_at: None,
13401 reviews: Vec::new(),
13402 e2e: Vec::new(),
13403 fix: None,
13404 blocking: 1,
13405 answered: 1,
13406 expected: 1,
13407 clean: false,
13408 verify_retried: false,
13409 e2e_deferred: true,
13410 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13411 progressed: false,
13412 vote_split: false,
13413 reconsideration: Vec::new(),
13414 verdict: None,
13415 }];
13416
13417 let mut runner = Runner {
13418 state,
13419 roles: ResolvedRoles {
13420 implementers: Vec::new(),
13421 judges: Vec::new(),
13422 reviewers: Vec::new(),
13423 fixer: None,
13424 conductor: conductor(),
13425 implementer_roster: Vec::new(),
13426 judge_roster: Vec::new(),
13427 reviewer_roster: Vec::new(),
13428 },
13429 sem: Arc::new(Semaphore::new(1)),
13430 pause: Pause::new(),
13431 interrupt: Pause::new(),
13432 };
13433
13434 let shell = runner.state.config.shell();
13435 runner
13436 .stop_reviewing("round budget spent", &shell, &repo)
13437 .await
13438 .expect("stop_reviewing");
13439
13440 let last = runner.state.reviews.last().expect("round record");
13441 assert_eq!(
13442 last.e2e_status(),
13443 E2eStatus::ResourceBlocked,
13444 "the shared cache is still held; the attempt must read as blocked, not deferred or \
13445 failed: {last:?}"
13446 );
13447 assert_eq!(
13448 last.verified_head.as_deref(),
13449 Some(head.as_str()),
13450 "which commit this attempt targeted is known even though nothing finished checking \
13451 it"
13452 );
13453 let first_attempt_at = last
13454 .verified_at
13455 .expect("when this attempt ran is known too");
13456 assert_ne!(
13457 runner.state.status,
13458 RunStatus::Blocked,
13459 "contention is evidence about the machine, not the patch — it must not settle the \
13460 run as blocked: {:?}",
13461 runner.state.status
13462 );
13463 assert!(
13464 !runner
13465 .state
13466 .events
13467 .iter()
13468 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13469 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13470 runner.state.events
13471 );
13472
13473 runner
13478 .stop_reviewing("round budget spent", &shell, &repo)
13479 .await
13480 .expect("stop_reviewing retry");
13481 assert_eq!(
13482 runner.state.reviews.len(),
13483 1,
13484 "no new round was started: {:?}",
13485 runner.state.reviews
13486 );
13487 let last = runner.state.reviews.last().expect("round record");
13488 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13489 assert!(
13490 last.verified_at.expect("still known") > first_attempt_at,
13491 "a second reentry must be a fresh attempt, not a stale copy of the first"
13492 );
13493 assert_ne!(runner.state.status, RunStatus::Blocked);
13494
13495 held.release();
13496 }
13497
13498 #[tokio::test]
13510 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13511 crate::run::pin_test_home();
13512 let home = crate::run::home();
13513
13514 let tmp = tempfile::tempdir().expect("tempdir");
13515 let repo = tmp.path().join("repo");
13516 std::fs::create_dir_all(&repo).unwrap();
13517 init_repo(&repo);
13518 let head = crate::git::rev_parse(&repo, "HEAD")
13519 .await
13520 .expect("rev-parse");
13521 let cache_dir = tmp.path().join("target");
13522
13523 let mut config = Config::default();
13524 config.verify.e2e = vec![format!(
13525 "CARGO_TARGET_DIR='{}' test -f README.md",
13526 cache_dir.display()
13527 )];
13528 config.graph.review_rounds = 1;
13529 config.graph.timeout_verify = Some(2);
13530
13531 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13532 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13533 .expect("no io error acquiring directly")
13534 {
13535 crate::cache::AcquireOutcome::Acquired(g) => g,
13536 crate::cache::AcquireOutcome::Busy(b) => {
13537 panic!("expected the direct acquire to win the lease first: {b:?}")
13538 }
13539 };
13540
13541 let mut state = RunState::new(
13542 repo.clone(),
13543 "main".to_owned(),
13544 head.clone(),
13545 "task".to_owned(),
13546 config,
13547 );
13548 state.candidates = vec![Candidate {
13549 index: 0,
13550 label: 'A',
13551 agent: "alpha".to_owned(),
13552 branch: "does-not-exist".to_owned(),
13553 worktree: repo.clone(),
13554 summary: String::new(),
13555 stat: String::new(),
13556 files: 0,
13557 commits: 0,
13558 empty: false,
13559 failed: None,
13560 verified_noop: None,
13561 duration_ms: 0,
13562 folded: false,
13563 }];
13564 state.tally = Some(Tally {
13565 first_choice: BTreeMap::from([('A', 1)]),
13566 borda: BTreeMap::new(),
13567 winner: 'A',
13568 rankings: 1,
13569 unanimous_initial: true,
13570 deliberated: false,
13571 changed_votes: 0,
13572 unanimous_final: true,
13573 tie_break: None,
13574 judges: 0,
13575 present: 0,
13576 quorum: 0,
13577 met_quorum: true,
13578 uncontested: Some("only candidate A produced a change".to_owned()),
13579 });
13580 state.reviews = vec![ReviewRound {
13584 round: 1,
13585 head: head.clone(),
13586 verified_head: Some(head.clone()),
13587 verified_at: Some(jiff::Timestamp::now()),
13588 reviews: Vec::new(),
13589 e2e: vec![CommandOutcome {
13590 command: format!(
13591 "CARGO_TARGET_DIR='{}' test -f README.md",
13592 cache_dir.display()
13593 ),
13594 code: None,
13595 output_tail: "waiting for the shared build cache".to_owned(),
13596 duration_ms: 0,
13597 resource_blocked: true,
13598 }],
13599 fix: None,
13600 blocking: 1,
13601 answered: 1,
13602 expected: 1,
13603 clean: false,
13604 verify_retried: false,
13605 e2e_deferred: false,
13606 e2e_defer_reason: None,
13607 progressed: false,
13608 vote_split: false,
13609 reconsideration: Vec::new(),
13610 verdict: None,
13611 }];
13612
13613 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13614 let mut runner = Runner {
13615 state,
13616 roles: ResolvedRoles {
13617 implementers: Vec::new(),
13618 judges: Vec::new(),
13619 reviewers: Vec::new(),
13620 fixer: None,
13621 conductor: conductor(),
13622 implementer_roster: Vec::new(),
13623 judge_roster: Vec::new(),
13624 reviewer_roster: Vec::new(),
13625 },
13626 sem: Arc::new(Semaphore::new(1)),
13627 pause: Pause::new(),
13628 interrupt: Pause::new(),
13629 };
13630
13631 runner.review_loop().await.expect("review_loop");
13636
13637 assert_eq!(
13638 runner.state.reviews.len(),
13639 1,
13640 "no new round was started on top of the unresolved one: {:?}",
13641 runner.state.reviews
13642 );
13643 let last = &runner.state.reviews[0];
13644 assert_eq!(
13645 last.e2e_status(),
13646 E2eStatus::ResourceBlocked,
13647 "still contended: {last:?}"
13648 );
13649 assert!(
13650 last.verified_at.expect("still known") > first_attempt_at,
13651 "review_loop must have actually retried the check, not left it exactly as found"
13652 );
13653 assert_ne!(
13654 runner.state.status,
13655 RunStatus::Blocked,
13656 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13657 runner.state.status
13658 );
13659
13660 held.release();
13661 }
13662
13663 #[tokio::test]
13664 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13665 crate::run::pin_test_home();
13666 let tmp = tempfile::tempdir().expect("tempdir");
13667 let repo = tmp.path().join("repo");
13668 std::fs::create_dir_all(&repo).unwrap();
13669 init_repo(&repo);
13670
13671 let mut config = Config::default();
13672 config.merge.mode = MergeMode::Pr;
13673 config.graph.land = true;
13674 config.graph.land_approval = false;
13675
13676 let mut state = RunState::new(
13677 repo.clone(),
13678 "main".to_owned(),
13679 "deadbeef".to_owned(),
13680 "task".to_owned(),
13681 config,
13682 );
13683 state.candidates = vec![Candidate {
13684 index: 0,
13685 label: 'A',
13686 agent: "alpha".to_owned(),
13687 branch: "does-not-exist".to_owned(),
13688 worktree: repo.clone(),
13689 summary: String::new(),
13690 stat: String::new(),
13691 files: 0,
13692 commits: 0,
13693 empty: false,
13694 failed: None,
13695 verified_noop: None,
13696 duration_ms: 0,
13697 folded: false,
13698 }];
13699 state.tally = Some(Tally {
13700 first_choice: BTreeMap::from([('A', 1)]),
13701 borda: BTreeMap::new(),
13702 winner: 'A',
13703 rankings: 1,
13704 unanimous_initial: true,
13705 deliberated: false,
13706 changed_votes: 0,
13707 unanimous_final: true,
13708 tie_break: None,
13709 judges: 0,
13710 present: 0,
13711 quorum: 0,
13712 met_quorum: true,
13713 uncontested: Some("only candidate A produced a change".to_owned()),
13714 });
13715 state.reviews = vec![ReviewRound {
13716 round: 1,
13717 head: "deadbeef".to_owned(),
13718 verified_head: None,
13719 verified_at: None,
13720 reviews: Vec::new(),
13721 e2e: Vec::new(),
13722 fix: None,
13723 blocking: 0,
13724 answered: 0,
13725 expected: 0,
13726 clean: true,
13727 verify_retried: false,
13728 e2e_deferred: false,
13729 e2e_defer_reason: None,
13730 progressed: false,
13731 vote_split: false,
13732 reconsideration: Vec::new(),
13733 verdict: None,
13734 }];
13735 state.gate = vec![CommandOutcome {
13736 command: "test".to_owned(),
13737 code: Some(0),
13738 output_tail: String::new(),
13739 duration_ms: 0,
13740 resource_blocked: false,
13741 }];
13742 state.gate_ran = true;
13743 state.status = RunStatus::Landing;
13747 state.merge = Some(MergeOutcome {
13748 mode: MergeMode::Pr,
13749 ok: true,
13750 detail: "https://example.invalid/x/y/pull/1".to_owned(),
13751 empty: false,
13752 });
13753
13754 ask_test_home();
13758 state.save().expect("save landing run");
13761 let store = ask::Questions::open();
13762 let q = ask_open_question(&store, &state.id);
13763
13764 let mut runner = Runner {
13765 state,
13766 roles: ResolvedRoles {
13767 implementers: Vec::new(),
13768 judges: Vec::new(),
13769 reviewers: Vec::new(),
13770 fixer: None,
13771 conductor: conductor(),
13772 implementer_roster: Vec::new(),
13773 judge_roster: Vec::new(),
13774 reviewer_roster: Vec::new(),
13775 },
13776 sem: Arc::new(Semaphore::new(1)),
13777 pause: Pause::new(),
13778 interrupt: Pause::new(),
13779 };
13780
13781 runner.execute().await.expect("execute");
13786
13787 assert_eq!(
13788 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13789 Some("https://example.invalid/x/y/pull/1"),
13790 "reentry must not push again or open a second pull request over the \
13791 one `land` is already watching"
13792 );
13793 assert_ne!(
13794 runner.state.status,
13795 RunStatus::Landing,
13796 "land could not actually reach the fake pull request, so it must \
13797 have given up rather than left the run silently parked forever"
13798 );
13799 assert_eq!(runner.state.status, RunStatus::Blocked);
13803 assert!(
13804 store.get(&q.id).unwrap().status.open(),
13805 "Blocked is still alive; settle_questions must have been a no-op here"
13806 );
13807 }
13808
13809 fn state_with_round(round: ReviewRound) -> RunState {
13810 let mut s = RunState::new(
13811 PathBuf::from("/repo"),
13812 "main".to_owned(),
13813 "abc1234".to_owned(),
13814 "add retries".to_owned(),
13815 Config::default(),
13816 );
13817 s.reviews = vec![round];
13818 s
13819 }
13820
13821 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13822 crate::verdict::Finding {
13823 id: id.to_owned(),
13824 severity,
13825 file: None,
13826 line: None,
13827 title: title.to_owned(),
13828 detail: String::new(),
13829 }
13830 }
13831
13832 #[test]
13833 fn pr_body_names_open_findings_and_declined_ones() {
13834 let round = ReviewRound {
13835 round: 2,
13836 head: "deadbee".to_owned(),
13837 verified_head: None,
13838 verified_at: None,
13839 reviews: vec![ReviewRecord {
13840 attempts: 0,
13841 reviewer: 1,
13842 agent: "alpha".to_owned(),
13843 summary: String::new(),
13844 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13845 vote: None,
13846 failed: None,
13847 duration_ms: 0,
13848 }],
13849 e2e: vec![CommandOutcome {
13850 command: "cargo test".to_owned(),
13851 code: Some(0),
13852 output_tail: String::new(),
13853 duration_ms: 0,
13854 resource_blocked: false,
13855 }],
13856 verify_retried: false,
13857 e2e_deferred: false,
13858 e2e_defer_reason: None,
13859 fix: Some(FixRecord {
13860 agent: "alpha".to_owned(),
13861 addressed: Vec::new(),
13862 rejected: vec![crate::verdict::Rejection {
13863 id: "R1-1-1".to_owned(),
13864 why: "not reachable from any caller".to_owned(),
13865 }],
13866 notes: String::new(),
13867 committed: true,
13868 failed: None,
13869 duration_ms: 0,
13870 continuation: None,
13871 }),
13872 blocking: 0,
13873 answered: 1,
13874 expected: 1,
13875 clean: false,
13876 progressed: true,
13877 vote_split: false,
13878 reconsideration: Vec::new(),
13879 verdict: None,
13880 };
13881 let state = state_with_round(round);
13882 let body = pr_message(&state, 'A').body;
13883
13884 assert!(body.contains("add retries"), "the task must still be there");
13885 assert!(body.contains("R2-1-1"), "{body}");
13886 assert!(!body.contains("unused import"), "{body}");
13887 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13888 assert!(
13889 !body.contains("not reachable from any caller"),
13890 "the reason it was declined: {body}"
13891 );
13892 }
13893
13894 #[test]
13895 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13896 let round = ReviewRound {
13897 round: 1,
13898 head: "deadbee".to_owned(),
13899 verified_head: None,
13900 verified_at: None,
13901 reviews: vec![ReviewRecord {
13902 attempts: 0,
13903 reviewer: 1,
13904 agent: "alpha".to_owned(),
13905 summary: String::new(),
13906 findings: Vec::new(),
13907 vote: None,
13908 failed: None,
13909 duration_ms: 0,
13910 }],
13911 e2e: Vec::new(),
13912 verify_retried: false,
13913 e2e_deferred: false,
13914 e2e_defer_reason: None,
13915 fix: None,
13916 blocking: 0,
13917 answered: 1,
13918 expected: 1,
13919 clean: true,
13920 progressed: false,
13921 vote_split: false,
13922 reconsideration: Vec::new(),
13923 verdict: None,
13924 };
13925 let state = state_with_round(round);
13926 let body = pr_message(&state, 'A').body;
13927 assert!(!body.contains("Open review findings"), "{body}");
13928 assert!(!body.contains("Declined"), "{body}");
13929 }
13930
13931 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13932 let mut state = RunState::new(
13933 PathBuf::from("/repo"),
13934 "main".to_owned(),
13935 "abc1234".to_owned(),
13936 instruction.to_owned(),
13937 Config::default(),
13938 );
13939 state.candidates.push(Candidate {
13940 index: 0,
13941 label: 'A',
13942 agent: "alpha".to_owned(),
13943 branch: "magi/x/A".to_owned(),
13944 worktree: PathBuf::from("/wt"),
13945 summary: summary.to_owned(),
13946 stat: String::new(),
13947 files: 1,
13948 commits: 1,
13949 empty: false,
13950 failed: None,
13951 verified_noop: None,
13952 folded: false,
13953 duration_ms: 0,
13954 });
13955 state
13956 }
13957
13958 fn review_state(subjects: &[&str]) -> RunState {
13959 let mut state = state_with_summary(
13960 "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",
13961 "",
13962 );
13963 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13964 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13965 state
13966 }
13967
13968 #[tokio::test]
13971 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13972 ask_test_home();
13973 let tmp = tempfile::tempdir().unwrap();
13974 let repo = tmp.path().join("repo");
13975 std::fs::create_dir_all(&repo).unwrap();
13976 init_repo(&repo);
13977 let origin = tmp.path().join("origin.git");
13978 let g = |dir: &Path, args: &[&str]| {
13979 let out = std::process::Command::new("git")
13980 .args(args)
13981 .current_dir(dir)
13982 .quiet()
13983 .output()
13984 .expect("spawn git");
13985 assert!(
13986 out.status.success(),
13987 "git {args:?}: {}",
13988 String::from_utf8_lossy(&out.stderr)
13989 );
13990 };
13991 g(
13992 tmp.path(),
13993 &[
13994 "clone",
13995 "--bare",
13996 "-q",
13997 repo.to_str().unwrap(),
13998 origin.to_str().unwrap(),
13999 ],
14000 );
14001 g(
14002 &repo,
14003 &["remote", "add", "origin", origin.to_str().unwrap()],
14004 );
14005 let c1 = git::rev_parse(&repo, "main").await.unwrap();
14006
14007 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14009 g(&repo, &["add", "-A"]);
14010 g(
14011 &repo,
14012 &["commit", "-q", "-m", "chore(deps): update a crate"],
14013 );
14014 g(&repo, &["push", "-q", "origin", "main"]);
14015 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14016 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14017 g(&repo, &["add", "-A"]);
14018 g(
14019 &repo,
14020 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14021 );
14022 g(&repo, &["checkout", "-q", "main"]);
14023
14024 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14025 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14026 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14028 assert_eq!(fallback, c1);
14029
14030 let mut state = review_state(&[
14031 "chore(deps): update a crate",
14032 "fix(daemon): apply a chosen action",
14033 ]);
14034 state.repo = repo.clone();
14035 state.base_branch = "main".to_owned();
14036 state.base_commit = c1;
14037 refresh_reviewed_commits(&mut state, "feat/own").await;
14038 assert_eq!(
14039 state.reviewed_commits,
14040 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14041 );
14042 assert_eq!(
14043 review_title(&state).as_deref(),
14044 Some("fix(daemon): apply a chosen action")
14045 );
14046 assert_eq!(
14047 leaked_subjects(&state, "feat/own").await,
14048 Some(vec!["chore(deps): update a crate".to_owned()])
14049 );
14050 state.config.merge.remote = "nowhere".to_owned();
14053 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14054 }
14055
14056 struct SummaryFixture {
14060 _tmp: tempfile::TempDir,
14061 repo: PathBuf,
14062 reply: PathBuf,
14063 runner: Runner,
14064 }
14065
14066 fn summary_fixture() -> SummaryFixture {
14067 ask_test_home();
14068 let tmp = tempfile::tempdir().unwrap();
14069 let repo = tmp.path().join("repo");
14070 std::fs::create_dir_all(&repo).unwrap();
14071 init_repo(&repo);
14072 let origin = tmp.path().join("origin.git");
14073 let g = |args: &[&str]| {
14074 let out = std::process::Command::new("git")
14075 .args(args)
14076 .current_dir(&repo)
14077 .quiet()
14078 .output()
14079 .expect("spawn git");
14080 assert!(
14081 out.status.success(),
14082 "git {args:?}: {}",
14083 String::from_utf8_lossy(&out.stderr)
14084 );
14085 };
14086 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14087 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14088 g(&["push", "-q", "origin", "main"]);
14089 g(&["checkout", "-q", "-b", "feat/own"]);
14090 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14091 g(&["add", "-A"]);
14092 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14093 let reply = tmp.path().join("reply.txt");
14094 std::fs::write(
14095 &reply,
14096 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14097 )
14098 .unwrap();
14099 let mut runner = runner_at(RunStatus::Gating);
14100 let mut state = review_state(&["feat(own): add the own file"]);
14101 state.repo = repo.clone();
14102 state.base_branch = "main".to_owned();
14103 state.candidates[0].branch = "feat/own".to_owned();
14104 state.candidates[0].worktree = repo.clone();
14105 let mut author = spec("alpha");
14106 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14107 author
14108 .env
14109 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14110 state.config.agents = vec![author];
14111 runner.state = state;
14112 SummaryFixture {
14113 _tmp: tmp,
14114 repo,
14115 reply,
14116 runner,
14117 }
14118 }
14119
14120 async fn posted(f: &mut SummaryFixture) -> PrMessage {
14121 let winner = f.runner.state.candidates[0].clone();
14122 f.runner
14123 .guarded_pr_message(&winner, None, true)
14124 .await
14125 .unwrap()
14126 .expect("an English description never parks")
14127 }
14128
14129 fn clean_round_with_minor_findings() -> ReviewRound {
14130 let mut round = passing_round(2, None);
14131 round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14132 round.reviews = vec![ReviewRecord {
14133 attempts: 0,
14134 reviewer: 1,
14135 agent: "alpha".into(),
14136 summary: "内部レビュー".into(),
14137 findings: vec![
14138 finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14139 finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14140 ],
14141 vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14142 failed: None,
14143 duration_ms: 0,
14144 }];
14145 round
14146 }
14147
14148 #[tokio::test]
14149 async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14150 for review_only in [false, true] {
14151 let mut f = summary_fixture();
14152 if !review_only {
14153 f.runner.state.candidates[0].agent = "alpha".into();
14154 }
14155 f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14156 assert!(f.runner.state.reviews[0].clean);
14157 assert!(f.runner.state.reviews[0].fix.is_none());
14158 assert!(f.runner.state.open_findings().is_empty());
14159 assert_eq!(
14160 crate::pr_outcome::required_finding_ids(&f.runner.state),
14161 ["R2-1-1", "R2-1-2"]
14162 );
14163 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14164 let evidence = crate::pr_outcome::summary_evidence(
14165 &f.runner.state,
14166 crate::pr_outcome::FinalHead::At(&head),
14167 );
14168 assert!(
14169 evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14170 );
14171 let omitted = posted(&mut f).await;
14173 assert!(omitted.body.contains("No description"));
14174 assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14175 assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14176 assert!(f.runner.state.change_summary.is_none());
14177 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();
14178 let described = posted(&mut f).await;
14179 assert!(described.body.contains("improve documentation for callers"));
14180 assert!(described.body.contains("remaining edge case"));
14181 assert!(described.body.contains("## Open review findings"));
14182 assert!(!described.body.contains("未修正"));
14183 }
14184 }
14185
14186 async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14187 let mut f = summary_fixture();
14188 if !review_only {
14189 f.runner.state.candidates[0].agent = "alpha".into();
14190 }
14191 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14192 let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14193 fixed
14194 .fix
14195 .as_mut()
14196 .unwrap()
14197 .rejected
14198 .push(crate::verdict::Rejection {
14199 id: "R1-1-1".into(),
14200 why: "呼び出されないコードのため辞退".into(),
14201 });
14202 let mut clean = clean_round_with_minor_findings();
14203 clean.verified_head = Some(head.clone());
14204 f.runner.state.reviews = vec![fixed, clean];
14205 ok_gate(&mut f.runner.state, Some(&head));
14206 std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14207 f.runner.state.config.agents.push(scripted("beta", reply));
14208 f.runner.state.config.roles.language_judge =
14209 Some(crate::config::AgentChoice::One("beta".into()));
14210 f
14211 }
14212
14213 #[tokio::test]
14214 async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14215 for review_only in [false, true] {
14216 let mut f = rewrite_fixture_with_findings(review_only,
14217 "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;
14218 let m = posted(&mut f).await;
14219 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14220 let expected =
14221 public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14222 assert!(m.body.contains(&expected));
14223 assert!(m.body.contains("## Review rounds"));
14224 assert!(
14225 m.body
14226 .contains("Gate (`verify.gate`): passed on the final commit")
14227 );
14228 assert!(
14229 m.body
14230 .contains("Tests (`verify.e2e`): passed on the final commit")
14231 );
14232 let c = f.runner.state.change_summary.as_ref().unwrap();
14233 assert!(
14234 m.body
14235 .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14236 );
14237 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14238 assert!(crate::pr_outcome::covers_required_findings(
14239 &f.runner.state,
14240 &m.body
14241 ));
14242 assert!(m.body.contains("documentation remains incomplete"));
14243 assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14244 assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14245 assert!(f.runner.state.github_text.is_none());
14246 }
14247 }
14248
14249 #[tokio::test]
14250 async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14251 for review_only in [false, true] {
14252 for reply in [
14253 "TITLE: fix: public description\nA short English description.",
14254 "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14255 ] {
14256 let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14257 let winner = f.runner.state.candidates[0].clone();
14258 let posted = f
14259 .runner
14260 .guarded_pr_message(&winner, None, true)
14261 .await
14262 .unwrap();
14263 assert!(posted.is_none());
14264 assert!(f.runner.state.parked);
14265 let withheld = f.runner.state.github_text.as_ref().unwrap();
14266 assert!(
14267 withheld.rewrite.is_none(),
14268 "an incomplete rewrite must not be selectable"
14269 );
14270 let q = ask::Questions::open()
14271 .get(withheld.question.as_ref().unwrap())
14272 .unwrap();
14273 assert!(
14274 !q.choices
14275 .contains(&crate::github_text::USE_REWRITE.to_owned())
14276 );
14277 assert!(
14278 q.detail.contains("R1-1-1")
14279 && q.detail.contains("R2-1-1")
14280 && q.detail.contains("R2-1-2")
14281 );
14282 }
14283 }
14284 }
14285
14286 #[tokio::test]
14287 async fn japanese_reports_stay_local_in_both_public_run_modes() {
14288 for review_only in [false, true] {
14289 let mut f = summary_fixture();
14290 if !review_only {
14291 f.runner.state.candidates[0].agent = "alpha".into();
14292 }
14293 f.runner.state.instruction = "内部タスクの説明".into();
14294 f.runner.state.candidates[0].summary = "内部の候補報告".into();
14295 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14296 f.runner.state.reviews = vec![
14297 round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14298 round_with_notes(2, Some("二回目の内部修正報告です。")),
14299 passing_round(3, Some(&head)),
14300 ];
14301 f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14302 ok_gate(&mut f.runner.state, Some(&head));
14303 let captured = f.repo.join("public-prompt.txt");
14304 let author = &mut f.runner.state.config.agents[0];
14305 author.command = vec![
14306 "sh".into(),
14307 "-c".into(),
14308 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14309 ];
14310 author.env.insert(
14311 "PROMPT_FILE".into(),
14312 captured.to_string_lossy().into_owned(),
14313 );
14314 let m = posted(&mut f).await;
14315 assert!(m.body.contains("Adds `own.txt`"));
14316 assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14317 assert!(m.body.contains("R1-1-1"));
14318 assert!(
14319 m.body
14320 .contains("Gate (`verify.gate`): passed on the final commit")
14321 );
14322 assert!(
14323 m.body
14324 .contains("Tests (`verify.e2e`): passed on the final commit")
14325 );
14326 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14327 assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14328 let prompt = std::fs::read_to_string(captured).unwrap();
14329 assert!(prompt.contains(&format!(
14330 "git diff {} {head}",
14331 f.runner.state.change_summary.as_ref().unwrap().base
14332 )));
14333 assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14334 assert_eq!(
14335 f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14336 "二回目の内部修正報告です。"
14337 );
14338 }
14339 }
14340
14341 #[tokio::test]
14342 async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14343 for review_only in [false, true] {
14344 let mut f = summary_fixture();
14345 if !review_only {
14346 f.runner.state.candidates[0].agent = "alpha".into();
14347 }
14348 f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14349 f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14350 std::fs::write(&f.reply, "no usable description").unwrap();
14351 let failed = posted(&mut f).await;
14352 assert!(
14353 failed
14354 .body
14355 .contains("No description of what this change does could be generated")
14356 );
14357 assert!(failed.body.is_ascii());
14358 assert!(f.runner.state.github_text.is_none());
14359 assert!(f.runner.state.change_summary.is_none());
14360 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14361 let evidence = crate::pr_outcome::summary_evidence(
14362 &f.runner.state,
14363 crate::pr_outcome::FinalHead::At(&head),
14364 );
14365 let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14366 assert!(
14367 crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14368 .exists()
14369 );
14370 f.runner.state =
14371 serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14372 std::fs::write(
14373 &f.reply,
14374 "TITLE: feat(own): add the own file\nAdds the committed own file.",
14375 )
14376 .unwrap();
14377 let retried = posted(&mut f).await;
14378 assert!(retried.body.contains("Adds the committed own file."));
14379 assert_eq!(
14380 f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14381 "日本語の修正報告"
14382 );
14383 }
14384 }
14385
14386 #[tokio::test]
14387 async fn same_head_verification_and_findings_invalidate_public_prose() {
14388 let mut f = summary_fixture();
14389 posted(&mut f).await;
14390 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14391 let original = f
14392 .runner
14393 .state
14394 .change_summary
14395 .as_ref()
14396 .unwrap()
14397 .evidence
14398 .clone();
14399 ok_gate(&mut f.runner.state, Some(&head));
14400 std::fs::write(
14401 &f.reply,
14402 "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14403 )
14404 .unwrap();
14405 let verified = posted(&mut f).await;
14406 assert!(verified.body.contains("now verified"));
14407 assert_ne!(
14408 f.runner.state.change_summary.as_ref().unwrap().evidence,
14409 original
14410 );
14411 let mut round = round_with_notes(1, Some("内部報告"));
14412 round.reviews.push(ReviewRecord {
14413 attempts: 0,
14414 reviewer: 1,
14415 agent: "alpha".into(),
14416 summary: "内部レビュー".into(),
14417 findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14418 vote: None,
14419 failed: None,
14420 duration_ms: 0,
14421 });
14422 round
14423 .fix
14424 .as_mut()
14425 .unwrap()
14426 .rejected
14427 .push(crate::verdict::Rejection {
14428 id: "R1-1-1".into(),
14429 why: "呼び出し元から到達できない".into(),
14430 });
14431 f.runner.state.reviews = vec![round];
14432 let omitted = posted(&mut f).await;
14434 assert!(omitted.body.contains("No description"));
14435 assert!(omitted.body.contains("R1-1-1"));
14436 assert!(!omitted.body.contains("到達"));
14437 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();
14438 let described = posted(&mut f).await;
14439 assert!(described.body.contains("unreachable from callers"));
14440 assert!(described.body.contains("R1-1-1"));
14441 assert!(!described.body.contains("到達"));
14442 let evidence = f
14443 .runner
14444 .state
14445 .change_summary
14446 .as_ref()
14447 .unwrap()
14448 .evidence
14449 .clone();
14450 f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14451 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14452 assert!(posted(&mut f).await.body.contains("rationale updated"));
14453 assert_ne!(
14454 f.runner.state.change_summary.as_ref().unwrap().evidence,
14455 evidence
14456 );
14457 f.runner
14459 .state
14460 .change_summary
14461 .as_mut()
14462 .unwrap()
14463 .evidence
14464 .clear();
14465 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14466 assert!(posted(&mut f).await.body.contains("current specification"));
14467 }
14468
14469 #[tokio::test]
14470 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14471 let mut f = summary_fixture();
14472 let m = posted(&mut f).await;
14473 assert_eq!(m.title, "feat(own): add the own file");
14474 assert!(
14475 m.body
14476 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14477 "{}",
14478 m.body
14479 );
14480 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14481 assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14482 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14483 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14484 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14485 let kept = f.runner.state.change_summary.as_ref().unwrap();
14486 assert_eq!(
14487 (kept.head.as_str(), kept.base.as_str()),
14488 (head.as_str(), base.as_str())
14489 );
14490 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14491 }
14492
14493 #[tokio::test]
14494 async fn review_only_summary_receives_the_configured_github_language() {
14495 let mut f = summary_fixture();
14496 f.runner.state.config.graph.github_language = "ja".into();
14497 let captured = f.repo.join("summary-prompt.txt");
14498 let author = &mut f.runner.state.config.agents[0];
14499 author.command = vec![
14500 "sh".into(),
14501 "-c".into(),
14502 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14503 ];
14504 author.env.insert(
14505 "PROMPT_FILE".into(),
14506 captured.to_string_lossy().into_owned(),
14507 );
14508 std::fs::write(
14509 &f.reply,
14510 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14511 )
14512 .unwrap();
14513 let m = posted(&mut f).await;
14514 let prompt = std::fs::read_to_string(captured).unwrap();
14515 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14516 assert!(!prompt.contains("in English"));
14517 assert_eq!(m.title, "feat(own): ファイルを追加");
14518 assert!(m.body.contains("own.txt を追加します。"));
14519 }
14520
14521 #[tokio::test]
14522 async fn review_fixes_come_after_the_whole_change_summary() {
14523 let mut f = summary_fixture();
14524 f.runner.state.reviews = vec![
14525 round_with_notes(1, Some("handled the PATH case")),
14526 round_with_notes(2, None),
14527 ];
14528 let m = posted(&mut f).await;
14529 let summary = m.body.find("## Summary").expect("summary");
14530 let fixes = m.body.find("## Review rounds").expect("review rounds");
14531 assert!(summary < fixes, "{}", m.body);
14532 assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14533 assert!(m.body.contains("## Final verification"), "{}", m.body);
14534 }
14535
14536 #[tokio::test]
14537 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14538 let mut f = summary_fixture();
14539 let first = posted(&mut f).await;
14540 let first_head = f.runner.state.change_summary.clone().unwrap().head;
14541 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14543 let again = posted(&mut f).await;
14544 assert_eq!((again.title, again.body), (first.title, first.body));
14545
14546 let g = |args: &[&str]| {
14547 let out = std::process::Command::new("git")
14548 .args(args)
14549 .current_dir(&f.repo)
14550 .quiet()
14551 .output()
14552 .expect("spawn git");
14553 assert!(out.status.success(), "git {args:?}");
14554 };
14555 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14556 g(&["add", "-A"]);
14557 g(&["commit", "-q", "-m", "fix(own): handle more"]);
14558 let second = posted(&mut f).await;
14559 let kept = f.runner.state.change_summary.clone().unwrap();
14560 assert_ne!(kept.head, first_head);
14561 assert!(second.body.contains("Not read yet."), "{}", second.body);
14562 assert!(!second.body.contains("so the first summary is read"));
14563 assert_eq!(second.title, "feat(own): other");
14564 }
14565
14566 #[tokio::test]
14567 async fn an_unavailable_summary_is_stated_and_never_invented() {
14568 let mut f = summary_fixture();
14569 std::fs::write(&f.reply, "no title line here").unwrap();
14570 let m = posted(&mut f).await;
14571 assert!(
14572 m.body
14573 .contains("No description of what this change does could be generated"),
14574 "{}",
14575 m.body
14576 );
14577 assert!(m.body.contains("internal reports remain in local records"));
14578 assert!(f.runner.state.change_summary.is_none());
14579 assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14580 let winner = f.runner.state.candidates[0].clone();
14582 let preview = f
14583 .runner
14584 .guarded_pr_message(&winner, None, false)
14585 .await
14586 .unwrap()
14587 .unwrap();
14588 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14589 }
14590
14591 #[test]
14592 fn pr_message_review_single_commit_uses_its_subject() {
14593 let state = review_state(&["feat(nats): per-role user"]);
14594 let m = pr_message(&state, 'A');
14595 assert_eq!(m.title, "feat(nats): per-role user");
14596 assert!(!m.body.contains("Review the work already"), "{}", m.body);
14597 assert!(m.body.contains("## Commits under review"), "{}", m.body);
14598 }
14599
14600 #[test]
14601 fn pr_message_review_multi_commit_takes_the_oldest() {
14602 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14603 let m = pr_message(&state, 'A');
14604 assert_eq!(m.title, "feat: the change");
14605 for s in ["feat: the change", "fix: typo", "fix: again"] {
14606 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14607 }
14608 }
14609
14610 #[test]
14611 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14612 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14613 let mut state = review_state(&[first, "fix: later fixup"]);
14614 state.candidates[0].branch = "機能/ログイン".to_owned();
14615 let m = pr_message(&state, 'A');
14616 assert!(
14617 m.title.starts_with("chore: land candidate A of run"),
14618 "{}",
14619 m.title
14620 );
14621 }
14622 }
14623
14624 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14625 BranchFacts {
14626 commits: commits
14627 .iter()
14628 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14629 .collect(),
14630 stat: stat.to_owned(),
14631 }
14632 }
14633
14634 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14635 let mut r = review_round(true, 0, 1, 1, true, true);
14636 r.round = round;
14637 r.fix = notes.map(|n| FixRecord {
14638 agent: "fixer".to_owned(),
14639 addressed: Vec::new(),
14640 rejected: Vec::new(),
14641 notes: n.to_owned(),
14642 committed: true,
14643 failed: None,
14644 duration_ms: 0,
14645 continuation: None,
14646 });
14647 r
14648 }
14649
14650 #[test]
14651 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14652 let state = review_state(&["ignored"]);
14653 let f = facts(
14654 &[
14655 (
14656 "fix(login): resolve PATH on macOS",
14657 "Login shells skip rc files.",
14658 ),
14659 ("fix: address review", ""),
14660 ],
14661 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14662 );
14663 let m = pr_message_with(&state, 'A', Some(&f));
14664 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14665 assert!(
14666 m.body
14667 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
14668 "{}",
14669 m.body
14670 );
14671 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14672 assert!(m.body.contains("## Diff stat"), "{}", m.body);
14673 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14674 for banned in [
14675 "Review the work already",
14676 "no task statement",
14677 "Original task",
14678 ] {
14679 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14680 }
14681 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14682 }
14683
14684 #[test]
14685 fn pr_message_review_truncates_a_huge_first_commit_body() {
14686 let state = review_state(&["ignored"]);
14687 let f = facts(
14688 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14689 "s",
14690 );
14691 let m = pr_message_with(&state, 'A', Some(&f));
14692 assert!(m.body.len() < 30_000, "{}", m.body.len());
14693 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14694 assert!(
14695 m.body.contains("- ... 1 more commit(s)"),
14696 "{}",
14697 m.body.len()
14698 );
14699 assert!(m.body.ends_with("magi:candidate-a\n"));
14700 }
14701
14702 #[test]
14703 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14704 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14705 assert_eq!(m.title, "feat: x");
14706 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14707 assert!(!m.body.contains("Diff stat"), "{}", m.body);
14708 assert!(!m.body.contains("no task statement"), "{}", m.body);
14709 }
14710
14711 #[test]
14712 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14713 let mut state = review_state(&["日本語の件名"]);
14714 state.candidates[0].branch = "fix/macos-login-path".to_owned();
14715 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14716 state.candidates[0].branch = "機能/ログイン".to_owned();
14717 assert!(
14718 pr_message(&state, 'A')
14719 .title
14720 .starts_with("chore: land candidate A")
14721 );
14722 }
14723
14724 #[test]
14725 fn pr_message_review_fixes_survive_a_clean_final_round() {
14726 let mut state = review_state(&["feat: x"]);
14727 state.reviews = vec![
14728 round_with_notes(1, Some("handled the PATH case")),
14729 round_with_notes(2, None),
14730 ];
14731 let body = pr_message(&state, 'A').body;
14732 assert!(
14733 body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14734 "{body}"
14735 );
14736 assert!(body.contains("### Round 1"), "{body}");
14737
14738 state.reviews = vec![
14739 round_with_notes(1, Some("first fix")),
14740 round_with_notes(2, Some("")),
14741 round_with_notes(3, Some("second fix")),
14742 round_with_notes(4, None),
14743 ];
14744 let body = pr_message(&state, 'A').body;
14745 assert!(
14746 body.contains("### Round 1") && !body.contains("first fix"),
14747 "{body}"
14748 );
14749 assert!(
14750 body.contains("### Round 3") && !body.contains("second fix"),
14751 "{body}"
14752 );
14753 assert!(body.contains("### Round 2"), "{body}");
14754 }
14755
14756 const FINAL: &str = "abcdef1234567890";
14757
14758 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14759 let mut r = round_with_notes(round, None);
14760 r.verified_head = verified.map(str::to_owned);
14761 r
14762 }
14763
14764 fn ok_gate(state: &mut RunState, head: Option<&str>) {
14765 state.gate = vec![CommandOutcome {
14766 command: "gate".to_owned(),
14767 code: Some(0),
14768 output_tail: String::new(),
14769 duration_ms: 0,
14770 resource_blocked: false,
14771 }];
14772 state.gate_ran = true;
14773 state.gate_head = head.map(str::to_owned);
14774 }
14775
14776 fn body_at(state: &RunState) -> String {
14777 pr_message_raw(
14778 state,
14779 'A',
14780 None,
14781 crate::pr_outcome::FinalHead::At(FINAL),
14782 ChangeNote::NotAsked,
14783 )
14784 .body
14785 }
14786
14787 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14788
14789 #[test]
14790 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14791 let mut state = review_state(&["feat: x"]);
14792 state.reviews = vec![
14793 round_with_notes(1, Some(SANDBOXED)),
14794 passing_round(2, Some(FINAL)),
14795 ];
14796 ok_gate(&mut state, Some(FINAL));
14797 let body = body_at(&state);
14798 assert!(!body.contains("Handled the PATH case."), "{body}");
14799 assert!(!body.contains("could not commit"), "{body}");
14800 assert!(!body.contains("could not run"), "{body}");
14801 assert!(
14802 body.contains("The fix is committed on the branch"),
14803 "{body}"
14804 );
14805 assert!(!body.contains("sandbox"), "{body}");
14806 assert!(
14807 body.contains("Tests (`verify.e2e`): passed on the final commit"),
14808 "{body}"
14809 );
14810 assert!(
14811 body.contains("Gate (`verify.gate`): passed on the final commit"),
14812 "{body}"
14813 );
14814 assert!(
14816 state.reviews[0]
14817 .fix
14818 .as_ref()
14819 .unwrap()
14820 .notes
14821 .contains("could not commit")
14822 );
14823 }
14824
14825 #[test]
14826 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14827 let mut state = review_state(&["feat: x"]);
14828 let mut r1 = round_with_notes(1, Some(SANDBOXED));
14829 r1.fix.as_mut().unwrap().committed = false;
14830 let mut r2 = passing_round(2, Some(FINAL));
14831 r2.e2e[0].code = Some(1);
14832 state.reviews = vec![r1, r2];
14833 let body = body_at(&state);
14834 assert!(!body.contains("could not commit"), "{body}");
14835 assert!(!body.contains("could not run"), "{body}");
14836 assert!(body.contains("This round produced no commit"), "{body}");
14837 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14838 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14839 assert!(!body.contains("passed"), "{body}");
14840 }
14841
14842 #[test]
14843 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14844 let mut state = review_state(&["feat: x"]);
14845 state.reviews = vec![
14846 round_with_notes(1, Some(SANDBOXED)),
14847 passing_round(2, Some("0123456789abcdef")),
14848 ];
14849 ok_gate(&mut state, Some("0123456789abcdef"));
14850 let body = body_at(&state);
14851 assert!(!body.contains("HTTP tests could not run"), "{body}");
14852 assert!(!body.contains("could not commit"), "{body}");
14854 assert!(
14855 body.contains("passed on an earlier commit (`0123456`)"),
14856 "{body}"
14857 );
14858 assert!(!body.contains("passed on the final commit"), "{body}");
14859
14860 ok_gate(&mut state, None);
14862 let body = body_at(&state);
14863 assert!(
14864 body.contains("passed on a commit that was not recorded"),
14865 "{body}"
14866 );
14867 }
14868
14869 #[test]
14870 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14871 let state = state_with_summary(
14872 "add retries to the client",
14873 "TITLE: feat: retries\n\nDid it.",
14874 );
14875 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14876 assert_eq!(m.title, "feat: retries");
14877 assert!(
14878 m.body.contains("<summary>Original task</summary>"),
14879 "{}",
14880 m.body
14881 );
14882 assert!(!m.body.contains("Commits under review"), "{}", m.body);
14883 assert!(
14884 m.body
14885 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14886 "{}",
14887 m.body
14888 );
14889 }
14890
14891 #[test]
14892 fn pr_message_review_bounds_a_long_english_subject() {
14893 let long = format!("feat: {}", "word ".repeat(100));
14894 let m = pr_message(&review_state(&[&long]), 'A');
14895 assert!(m.title.starts_with("feat: word"), "{}", m.title);
14896 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14897 }
14898
14899 #[test]
14900 fn pr_message_implementation_run_is_unchanged_by_review_support() {
14901 let state = state_with_summary("add retries\n\ndetails", "- did some things");
14902 let m = pr_message(&state, 'A');
14903 assert_eq!(m.title, "add retries");
14904 assert!(m.body.contains("<summary>Original task</summary>"));
14905 assert!(!m.body.contains("Commits under review"));
14906 assert_eq!(landing_subject_source(&state), state.instruction);
14907 }
14908
14909 #[test]
14910 fn review_run_squash_subject_is_the_change_not_the_prompt() {
14911 let state = review_state(&["feat: the change", "fix: typo"]);
14912 let source = landing_subject_source(&state);
14913 assert_eq!(land::merge_subject("", &source), "feat: the change");
14914 assert_eq!(
14915 land::merge_subject("magi: candidate A (uncommitted work)", &source),
14916 "feat: the change"
14917 );
14918 assert_eq!(
14920 land::merge_subject("feat: renamed by hand", &source),
14921 "feat: renamed by hand"
14922 );
14923 let blank = review_state(&["日本語"]);
14924 assert!(
14925 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14926 );
14927 }
14928
14929 #[test]
14930 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
14931 let state = review_state(&["feat: the change"]);
14932 let source = landing_subject_source(&state);
14933 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
14934 assert_eq!(
14935 land::merge_subject(landing_title(&state, old), &source),
14936 "feat: the change"
14937 );
14938 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
14939 let task = state_with_summary("add retries", "");
14940 assert_eq!(landing_title(&task, old), old);
14941 }
14942
14943 #[test]
14944 fn pr_message_describes_the_change_not_the_task() {
14945 let state = state_with_summary(
14946 "今回やってほしいこと: results projector を直す",
14947 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
14948 );
14949 let m = pr_message(&state, 'A');
14950 assert_eq!(m.title, "fix(web): batch the runs list reads");
14951 assert!(
14952 m.body.starts_with("## Summary\n\n- reads run.json once"),
14953 "{}",
14954 m.body
14955 );
14956 assert!(!m.body.contains("TITLE:"), "{}", m.body);
14957 let task_at = m.body.find("今回やってほしいこと").unwrap();
14958 let details_at = m.body.find("<details>").unwrap();
14959 assert!(
14960 details_at < task_at,
14961 "the task lives inside <details>: {}",
14962 m.body
14963 );
14964 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
14965 assert!(m.body.contains("magi:candidate-a"));
14966 }
14967
14968 #[test]
14969 fn pr_message_falls_back_to_the_task_without_a_title_line() {
14970 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
14971 let m = pr_message(&state, 'A');
14972 assert_eq!(m.title, "add retries");
14973 assert!(
14974 m.body.contains("## Summary\n\n- did some things"),
14975 "{}",
14976 m.body
14977 );
14978
14979 let none = RunState::new(
14980 PathBuf::from("/repo"),
14981 "main".to_owned(),
14982 "abc1234".to_owned(),
14983 "add retries".to_owned(),
14984 Config::default(),
14985 );
14986 let m = pr_message(&none, 'A');
14987 assert_eq!(m.title, "add retries");
14988 assert!(!m.body.contains("## Summary"), "{}", m.body);
14989 }
14990
14991 #[test]
14992 fn pr_message_refuses_the_candidate_commit_subject() {
14993 for bad in [
14994 "TITLE: magi: candidate A (uncommitted work)",
14995 "TITLE: chore: stuff (uncommitted work)",
14996 "TITLE: ",
14997 ] {
14998 let state = state_with_summary("add retries", bad);
14999 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15000 }
15001 }
15002
15003 #[test]
15004 fn pr_message_bounds_a_very_long_task_and_title() {
15005 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15006 let state = state_with_summary(&long, "- nothing");
15007 let m = pr_message(&state, 'A');
15008 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15009 assert!(!m.title.contains('\n'));
15010
15011 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15012 let m = pr_message(&state, 'A');
15013 assert!(m.title.starts_with("feat: "));
15014 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15015 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15016 }
15017
15018 fn long_title_of(instruction: &str) -> String {
15019 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15020 }
15021
15022 #[test]
15023 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15024 let first = "Make the landing path keep a readable title for long tasks";
15025 let line = format!(
15026 "{first}. {}",
15027 "And then keep going with more words ".repeat(20)
15028 );
15029 let t = long_title_of(&line);
15030 assert_eq!(t, first);
15031 assert!(!t.starts_with("chore: land"));
15032 }
15033
15034 #[test]
15035 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15036 let line = "word ".repeat(200);
15037 let t = long_title_of(&line);
15038 assert!(t.ends_with("word..."), "{t}");
15039 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15040 }
15041
15042 #[test]
15043 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15044 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15045 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15046 }
15047 }
15048
15049 #[test]
15050 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15051 let mut state = state_with_summary("再試行を追加する", "- nothing");
15052 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15053 state.config.graph.github_language = "ja".into();
15054 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15055 }
15056
15057 #[test]
15058 fn pr_message_title_limit_is_exact() {
15059 let at = "a".repeat(PR_TITLE_MAX);
15060 assert_eq!(long_title_of(&at), at);
15061 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15062 assert!(over.ends_with("..."), "{over}");
15063 assert_eq!(over.chars().count(), PR_TITLE_MAX);
15064 }
15065
15066 #[test]
15067 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15068 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15069 let t = long_title_of(&line);
15070 assert!(t.ends_with("..."), "{t}");
15071 assert!(t.is_ascii(), "{t}");
15072 }
15073
15074 #[test]
15075 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15076 let line = format!(
15077 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15078 "plus more filler words ".repeat(20)
15079 );
15080 let t = long_title_of(&line);
15081 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15082 }
15083
15084 #[test]
15085 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15086 let line = format!("\n\n# {}", "title words ".repeat(40));
15087 let state = state_with_summary(&line, "- nothing");
15088 let m = pr_message(&state, 'A');
15089 assert!(m.title.starts_with("title words"), "{}", m.title);
15090 assert_eq!(
15091 land::merge_subject(&m.title, &landing_subject_source(&state)),
15092 m.title
15093 );
15094 assert_eq!(
15096 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15097 "feat: renamed by hand"
15098 );
15099 }
15100
15101 #[tokio::test]
15102 async fn github_text_rewrite_is_bounded_and_falls_back() {
15103 crate::run::pin_test_home();
15104 for (reply, accepted) in [
15105 (
15106 "TITLE: fix: retries\nAdd retries for failed requests.",
15107 true,
15108 ),
15109 (
15110 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15111 false,
15112 ),
15113 (
15114 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15115 true,
15116 ),
15117 (
15118 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15119 true,
15120 ),
15121 (
15122 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15123 true,
15124 ),
15125 (
15126 "TITLE: fix: retries\n日本語の説明です。 token=secret",
15127 false,
15128 ),
15129 ("TITLE: fix: retries\n", false),
15130 ] {
15131 let dir = tempfile::tempdir().unwrap();
15132 let mut runner = runner_at(RunStatus::Gating);
15133 runner.state = state_with_summary(
15134 "add retries",
15135 "TITLE: fix: retries\n日本語の説明を書きます。",
15136 );
15137 runner.state.repo = dir.path().to_owned();
15138 runner.state.candidates[0].worktree = dir.path().to_owned();
15139 let mut author = spec("alpha");
15140 author.command = vec![
15141 "sh".into(),
15142 "-c".into(),
15143 "printf '%s' \"$REWRITE_REPLY\"".into(),
15144 ];
15145 author.env.insert("REWRITE_REPLY".into(), reply.into());
15146 runner.state.config.agents = vec![author];
15147 seed_public_summary(&mut runner);
15148 let winner = runner.state.candidates[0].clone();
15149 let first = runner
15150 .guarded_pr_message(&winner, None, true)
15151 .await
15152 .unwrap();
15153 let message = if accepted {
15154 first.expect("a passing rewrite never asks")
15155 } else {
15156 assert!(first.is_none());
15158 assert!(runner.state.parked);
15159 let store = ask::Questions::open();
15160 let asked: Vec<_> = store
15161 .list()
15162 .into_iter()
15163 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15164 .collect();
15165 assert_eq!(asked.len(), 1);
15166 assert!(!asked[0].detail.contains("secret"));
15167 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15168 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15169 assert_eq!(
15170 asked[0]
15171 .choices
15172 .contains(&crate::github_text::USE_REWRITE.to_owned()),
15173 rewrite.is_some()
15174 );
15175 store
15178 .update(&asked[0].id, |q| {
15179 q.abandon("test");
15180 Ok(())
15181 })
15182 .unwrap();
15183 let again = runner
15184 .guarded_pr_message(&winner, None, true)
15185 .await
15186 .unwrap()
15187 .expect("an abandoned question falls back");
15188 let n = store
15189 .list()
15190 .into_iter()
15191 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15192 .count();
15193 assert_eq!(n, 1, "asked once per rejected text");
15194 again
15195 };
15196 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15197 assert!(!message.body.contains("secret"));
15198 assert!(!message.body.contains("prefix"));
15199 assert!(!message.title.contains("secret"));
15200 assert_eq!(
15201 message.body.contains("Add retries for failed requests."),
15202 accepted
15203 );
15204 assert_eq!(
15205 runner.state.seats["github-text-rewrite"].turns, 1,
15206 "only one rewrite invocation"
15207 );
15208 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15209 if accepted {
15210 assert!(runner.state.github_text.is_none());
15211 assert!(!runner.state.parked);
15212 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15213 assert!(message.body.contains("## Final verification"));
15214 assert!(message.body.contains("Evidence: final diff"));
15215 assert!(
15216 message
15217 .body
15218 .contains(&format!("magi:run/{}", runner.state.id))
15219 );
15220 }
15221 if !accepted {
15222 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15223 assert!(
15224 message
15225 .body
15226 .contains(&format!("magi:run/{}", runner.state.id))
15227 );
15228 }
15229 }
15230 }
15231
15232 fn scripted(id: &str, reply: &str) -> AgentSpec {
15233 let mut a = spec(id);
15234 a.command = vec![
15235 "sh".into(),
15236 "-c".into(),
15237 "printf '%s' \"$REWRITE_REPLY\"".into(),
15238 ];
15239 a.env.insert("REWRITE_REPLY".into(), reply.into());
15240 a
15241 }
15242
15243 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15244 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15245
15246 fn seed_public_summary(runner: &mut Runner) {
15248 let repo = &runner.state.repo;
15249 let g = |args: &[&str]| {
15250 let out = std::process::Command::new("git")
15251 .args(args)
15252 .current_dir(repo)
15253 .quiet()
15254 .output()
15255 .unwrap();
15256 assert!(out.status.success(), "git {args:?}");
15257 String::from_utf8_lossy(&out.stdout).trim().to_owned()
15258 };
15259 g(&["init", "-q", "-b", "main"]);
15260 g(&[
15261 "-c",
15262 "user.name=Test",
15263 "-c",
15264 "user.email=test@example.invalid",
15265 "commit",
15266 "-q",
15267 "--allow-empty",
15268 "-m",
15269 "feat: fixture",
15270 ]);
15271 let head = g(&["rev-parse", "HEAD"]);
15272 g(&["update-ref", "refs/remotes/origin/main", &head]);
15273 let evidence = crate::pr_outcome::summary_evidence(
15274 &runner.state,
15275 crate::pr_outcome::FinalHead::At(&head),
15276 );
15277 let generated = &runner.state.candidates[0].summary;
15278 runner.state.change_summary = Some(crate::run::ChangeSummary {
15279 base: head.clone(),
15280 head,
15281 evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15282 title: summary_title(generated).unwrap(),
15283 body: summary_without_title(generated),
15284 });
15285 }
15286
15287 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15288 let mut runner = runner_at(RunStatus::Gating);
15289 runner.state = state_with_summary("add retries", JA_SUMMARY);
15290 if review_only {
15291 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15292 }
15293 runner.state.repo = dir.to_owned();
15294 runner.state.candidates[0].worktree = dir.to_owned();
15295 runner.state.config.agents = agents;
15296 seed_public_summary(&mut runner);
15297 runner
15298 }
15299
15300 fn gate_questions(runner: &Runner) -> usize {
15301 ask::Questions::open()
15302 .list()
15303 .into_iter()
15304 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15305 .count()
15306 }
15307
15308 #[tokio::test]
15309 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15310 crate::run::pin_test_home();
15311 let dir = tempfile::tempdir().unwrap();
15312 let mut runner = rewrite_runner(
15313 dir.path(),
15314 true,
15315 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15316 );
15317 runner.state.config.roles.language_judge =
15318 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15319 let winner = runner.state.candidates[0].clone();
15320 let msg = runner
15321 .guarded_pr_message(&winner, None, true)
15322 .await
15323 .unwrap()
15324 .expect("an English rewrite posts without asking");
15325 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15326 assert!(msg.body.contains("<details>kept</details>"));
15327 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15328 assert_eq!(gate_questions(&runner), 0);
15329 assert!(runner.state.github_text.is_none());
15330 assert!(!runner.state.seats.contains_key("impl-A"));
15331 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15332 }
15333
15334 #[tokio::test]
15335 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15336 crate::run::pin_test_home();
15337 let dir = tempfile::tempdir().unwrap();
15338 let mut runner = rewrite_runner(
15339 dir.path(),
15340 false,
15341 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15342 );
15343 runner.state.config.roles.language_judge =
15344 Some(crate::config::AgentChoice::One("beta".into()));
15345 runner.state.seats.insert(
15346 "impl-A".into(),
15347 SeatState::new("impl-A", "alpha", runner.state.seed),
15348 );
15349 let winner = runner.state.candidates[0].clone();
15350 let msg = runner
15351 .guarded_pr_message(&winner, None, true)
15352 .await
15353 .unwrap()
15354 .expect("rewritten");
15355 assert!(msg.body.contains("<details>kept</details>"));
15356 assert_eq!(runner.state.seats["impl-A"].turns, 0);
15357 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15358 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15359 }
15360
15361 #[tokio::test]
15362 async fn rewrite_reuses_a_resumable_implementer_session() {
15363 crate::run::pin_test_home();
15364 let dir = tempfile::tempdir().unwrap();
15365 let mut runner = rewrite_runner(
15366 dir.path(),
15367 false,
15368 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15369 );
15370 runner.state.config.roles.language_judge =
15371 Some(crate::config::AgentChoice::One("beta".into()));
15372 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15373 seat.turns = 1;
15374 runner.state.seats.insert("impl-A".into(), seat);
15375 let winner = runner.state.candidates[0].clone();
15376 runner
15377 .guarded_pr_message(&winner, None, true)
15378 .await
15379 .unwrap()
15380 .expect("rewritten");
15381 assert_eq!(runner.state.seats["impl-A"].turns, 2);
15382 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15383 }
15384
15385 #[tokio::test]
15386 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15387 crate::run::pin_test_home();
15388 let dir = tempfile::tempdir().unwrap();
15389 let mut broken = spec("bad");
15390 broken.command = vec!["false".into()];
15391 let mut runner = rewrite_runner(
15392 dir.path(),
15393 true,
15394 vec![
15395 broken,
15396 scripted("noformat", "Add retries."),
15397 scripted("good", EN_REWRITE),
15398 ],
15399 );
15400 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15401 "bad".into(),
15402 "noformat".into(),
15403 "good".into(),
15404 ]));
15405 let winner = runner.state.candidates[0].clone();
15406 let msg = runner
15407 .guarded_pr_message(&winner, None, true)
15408 .await
15409 .unwrap()
15410 .expect("the third agent rewrites");
15411 assert!(msg.body.contains("`src/net.rs`"));
15412 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15413 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15414 }
15415
15416 #[tokio::test]
15417 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15418 crate::run::pin_test_home();
15419 for (agents, role, saved) in [
15421 (
15422 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15423 Some("beta"),
15424 true,
15425 ),
15426 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15427 (Vec::new(), None, false),
15428 ] {
15429 let dir = tempfile::tempdir().unwrap();
15430 let mut runner = rewrite_runner(dir.path(), true, agents);
15431 runner.state.config.roles.language_judge =
15432 role.map(|r| crate::config::AgentChoice::One(r.into()));
15433 let winner = runner.state.candidates[0].clone();
15434 let first = runner
15435 .guarded_pr_message(&winner, None, true)
15436 .await
15437 .unwrap();
15438 assert!(first.is_none(), "withheld text parks the run");
15439 assert!(runner.state.parked);
15440 assert_eq!(gate_questions(&runner), 1);
15441 let g = runner.state.github_text.as_ref().unwrap();
15442 assert_eq!(g.rewrite.is_some(), saved);
15443 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15444 }
15445 }
15446
15447 #[tokio::test]
15448 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15449 crate::run::pin_test_home();
15450 let dir = tempfile::tempdir().unwrap();
15451 let mut runner = runner_at(RunStatus::Gating);
15452 runner.state =
15453 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15454 runner.state.repo = dir.path().to_owned();
15455 runner.state.candidates[0].worktree = dir.path().to_owned();
15456 let mut author = spec("alpha");
15457 author.command = vec![
15458 "sh".into(),
15459 "-c".into(),
15460 "printf '%s' \"$REWRITE_REPLY\"".into(),
15461 ];
15462 author.env.insert(
15463 "REWRITE_REPLY".into(),
15464 format!(
15465 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15466 "English sentence. ".repeat(600)
15467 ),
15468 );
15469 runner.state.config.agents = vec![author];
15470 seed_public_summary(&mut runner);
15471 let winner = runner.state.candidates[0].clone();
15472 assert!(
15473 runner
15474 .guarded_pr_message(&winner, None, true)
15475 .await
15476 .unwrap()
15477 .is_none()
15478 );
15479 let record = runner.state.github_text.clone().unwrap();
15480 let rewrite = record.rewrite.clone().unwrap();
15481 assert_eq!(rewrite.failed, ["body-language"]);
15482 assert!(
15483 rewrite
15484 .body
15485 .ends_with(&format!("magi:run/{}", runner.state.id))
15486 );
15487 let store = ask::Questions::open();
15488 let q = store.get(record.question.as_ref().unwrap()).unwrap();
15489 assert!(
15490 q.choices
15491 .contains(&crate::github_text::USE_REWRITE.to_owned())
15492 );
15493 for label in [
15494 "ORIGINAL",
15495 "FALLBACK",
15496 "REWRITE",
15497 "Rules still failed: body-language",
15498 "truncated",
15499 ] {
15500 assert!(q.detail.contains(label), "{label}");
15501 }
15502 let artifact = std::fs::read_to_string(crate::run::artifact_path(
15503 &runner.state,
15504 &crate::github_text::artifact_name(&record.fingerprint),
15505 ))
15506 .unwrap();
15507 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15508 runner.state =
15510 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15511 store
15512 .update(&q.id, |q| {
15513 q.say("まだ日本語")?;
15514 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15515 })
15516 .unwrap();
15517 assert!(
15518 runner
15519 .guarded_pr_message(&winner, None, true)
15520 .await
15521 .unwrap()
15522 .is_none()
15523 );
15524 let retry = runner.state.github_text.as_ref().unwrap();
15525 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15526 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15527 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15528 assert!(
15529 q.posting_comparison
15530 .as_ref()
15531 .unwrap()
15532 .matches_detail(&q.detail)
15533 );
15534 store
15535 .update(&q.id, |q| {
15536 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15537 })
15538 .unwrap();
15539 let message = runner
15540 .guarded_pr_message(&winner, None, true)
15541 .await
15542 .unwrap()
15543 .unwrap();
15544 assert_eq!(message.title, rewrite.title);
15545 assert_eq!(message.body, rewrite.body);
15546 assert_eq!(
15547 runner.state.github_text.as_ref().unwrap().choice,
15548 Some(GithubTextChoice::Rewrite)
15549 );
15550 let again = runner
15551 .guarded_pr_message(&winner, None, true)
15552 .await
15553 .unwrap()
15554 .unwrap();
15555 assert_eq!(again.title, message.title);
15556 assert_eq!(again.body, message.body);
15557 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15558 assert!(
15559 !runner
15560 .state
15561 .events
15562 .iter()
15563 .any(|e| e.message.contains(&rewrite.title)
15564 || e.message.contains("English sentence."))
15565 );
15566 }
15567
15568 #[tokio::test]
15569 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15570 crate::run::pin_test_home();
15571 let dir = tempfile::tempdir().unwrap();
15572 let mut runner = runner_at(RunStatus::Gating);
15573 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15574 runner.state.repo = dir.path().to_owned();
15575 runner.state.candidates[0].worktree = dir.path().to_owned();
15576 seed_public_summary(&mut runner);
15577 let winner = runner.state.candidates[0].clone();
15578 assert!(
15579 runner
15580 .guarded_pr_message(&winner, None, true)
15581 .await
15582 .unwrap()
15583 .is_none()
15584 );
15585 let record = runner.state.github_text.as_ref().unwrap();
15586 assert!(record.rewrite.is_none());
15587 let mut old = serde_json::to_value(record).unwrap();
15589 old.as_object_mut().unwrap().remove("rewrite");
15590 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15591 assert!(old.rewrite.is_none());
15592 let id = record.question.clone().unwrap();
15593 ask::Questions::open()
15594 .update(&id, |q| {
15595 q.choices.push(crate::github_text::USE_REWRITE.into());
15596 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15597 })
15598 .unwrap();
15599 let message = runner
15600 .guarded_pr_message(&winner, None, true)
15601 .await
15602 .unwrap()
15603 .unwrap();
15604 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15605 assert_eq!(
15606 runner.state.github_text.as_ref().unwrap().choice,
15607 Some(GithubTextChoice::Fallback)
15608 );
15609 }
15610
15611 #[tokio::test]
15612 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15613 crate::run::pin_test_home();
15614 let dir = tempfile::tempdir().unwrap();
15615 let mut runner = runner_at(RunStatus::Gating);
15616 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15617 runner.state.repo = dir.path().to_owned();
15618 runner.state.candidates[0].worktree = dir.path().to_owned();
15619 seed_public_summary(&mut runner);
15620 let winner = runner.state.candidates[0].clone();
15621 assert!(
15622 runner
15623 .guarded_pr_message(&winner, None, true)
15624 .await
15625 .unwrap()
15626 .is_none()
15627 );
15628 let store = ask::Questions::open();
15629 let find = |run: &str| {
15630 store
15631 .list()
15632 .into_iter()
15633 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15634 .unwrap()
15635 };
15636 let run = runner.state.id.clone();
15637 let q = find(&run);
15638 let fp = runner
15639 .state
15640 .github_text
15641 .as_ref()
15642 .unwrap()
15643 .fingerprint
15644 .clone();
15645 let name = crate::github_text::artifact_name(&fp);
15646 let path = crate::run::artifact_path(&runner.state, &name);
15647 assert!(q.detail.contains(&path.display().to_string()));
15648 assert!(q.detail.contains("title-language: title"));
15649 let saved = std::fs::read_to_string(&path).expect("artifact written");
15650 assert!(saved.contains("日本語のタイトル"));
15651 assert!(q.cwd.is_none());
15652 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15653 let say = |id: &str, text: &str, choice: &str| {
15655 store
15656 .update(id, |q| {
15657 q.thread.push(ask::Turn {
15658 who: ask::Who::Operator,
15659 body: text.to_owned(),
15660 at: jiff::Timestamp::now(),
15661 note: None,
15662 });
15663 q.answer(ask::Answer::Choice(choice.to_owned()))
15664 })
15665 .unwrap();
15666 };
15667 say(&q.id, "まだ日本語", "use my text");
15668 assert!(
15669 runner
15670 .guarded_pr_message(&winner, None, true)
15671 .await
15672 .unwrap()
15673 .is_none()
15674 );
15675 let second = find(&run);
15676 assert_ne!(second.id, q.id);
15677 assert!(second.detail.contains("did not pass"));
15678 assert!(second.detail.contains("日本語のタイトル"));
15681 assert!(!second.detail.contains("まだ日本語"));
15682 assert!(second.detail.contains(&path.display().to_string()));
15683 say(&second.id, "fix: retry failed requests", "use my text");
15684 let message = runner
15685 .guarded_pr_message(&winner, None, true)
15686 .await
15687 .unwrap()
15688 .expect("a vetted replacement is posted");
15689 assert_eq!(message.title, "fix: retry failed requests");
15690 assert_eq!(
15691 runner
15692 .state
15693 .github_text
15694 .as_ref()
15695 .unwrap()
15696 .chosen_title
15697 .as_deref(),
15698 Some("fix: retry failed requests")
15699 );
15700 assert!(runner.state.github_text.as_ref().unwrap().resolved);
15701 let again = runner
15703 .guarded_pr_message(&winner, None, true)
15704 .await
15705 .unwrap()
15706 .unwrap();
15707 assert_eq!(again.title, message.title);
15708 assert_eq!(
15709 store
15710 .list()
15711 .into_iter()
15712 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15713 .count(),
15714 2
15715 );
15716 }
15717
15718 #[test]
15719 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15720 let mut state = state_with_summary(
15724 "add retries",
15725 "TITLE: fix(web): batch reads\n- reads run.json once",
15726 );
15727 state.config.graph.language = "ja".to_owned();
15728 let m = pr_message(&state, 'A');
15729 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15730 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15731
15732 let task = "今回やってほしいこと: results projector を直す";
15735 let mut state = state_with_summary(task, "- no title line");
15736 state.config.graph.language = "ja".to_owned();
15737 let m = pr_message(&state, 'A');
15738 assert_eq!(
15739 m.title,
15740 format!("chore: land candidate A of run {}", state.id)
15741 );
15742 assert!(
15743 m.body.contains(&format!(
15744 "<summary>Original task</summary>\n\n{task}\n\n</details>"
15745 )),
15746 "{}",
15747 m.body
15748 );
15749 }
15750
15751 #[test]
15752 fn pr_message_scrubs_home_paths_and_addresses() {
15753 let state = state_with_summary(
15754 "fix it in /Users/someone/src/x",
15755 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15756 );
15757 let m = pr_message(&state, 'A');
15758 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15759 assert!(!m.body.contains(leak), "{}", m.body);
15760 }
15761 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15762 }
15763
15764 #[test]
15765 fn pr_message_survives_a_task_that_closes_details() {
15766 let state = state_with_summary("a </details> b", "TITLE: fix: x");
15767 let m = pr_message(&state, 'A');
15768 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15769 }
15770
15771 #[test]
15772 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15773 let cmd = manual_merge_command(
15774 MergeStyle::Squash,
15775 Path::new("/repo"),
15776 "b",
15777 "fix: \"quoted\" $(x) `y`\n\nbody",
15778 );
15779 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15780 }
15781
15782 #[test]
15783 fn manual_merge_command_matches_the_configured_style() {
15784 let repo = Path::new("/repo");
15785 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15786
15787 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15788 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15789
15790 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15791 assert_eq!(
15792 squash,
15793 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15794 \"Merge magi run 0832 (candidate A)\""
15795 );
15796
15797 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15798 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15799 }
15800
15801 #[test]
15802 fn a_nudge_gets_a_quarter_of_the_budget() {
15803 assert_eq!(retry_budget(secs(1200), true), secs(300));
15805 assert_eq!(retry_budget(secs(3600), true), secs(900));
15806 }
15807
15808 #[test]
15809 fn a_resent_prompt_keeps_the_whole_budget() {
15810 assert_eq!(retry_budget(secs(1200), false), secs(1200));
15813 assert_eq!(retry_budget(secs(60), false), secs(60));
15814 }
15815
15816 #[test]
15817 fn the_floor_never_exceeds_the_original_budget() {
15818 assert_eq!(retry_budget(secs(60), true), secs(60));
15822 assert_eq!(retry_budget(secs(480), true), secs(120));
15823 assert_eq!(retry_budget(secs(0), true), secs(0));
15824 }
15825
15826 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15827 agent::CommandEvidence {
15828 id: "item1".to_owned(),
15829 description: "cargo test".to_owned(),
15830 exit_code,
15831 result_summary: String::new(),
15832 source: "codex".to_owned(),
15833 }
15834 }
15835
15836 #[test]
15837 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15838 assert!(!has_unconfirmed_command(&[]));
15842 }
15843
15844 #[test]
15845 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15846 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15850 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15851 assert!(!has_unconfirmed_command(&[
15852 evidence(Some(0)),
15853 evidence(Some(101))
15854 ]));
15855 }
15856
15857 #[test]
15858 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15859 assert!(has_unconfirmed_command(&[
15860 evidence(Some(0)),
15861 evidence(None)
15862 ]));
15863 }
15864
15865 #[test]
15866 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15867 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15868 assert_eq!(
15869 verified_noop_claim(true, &[], text).as_deref(),
15870 Some("already fixed by b32cfc4, on main.")
15871 );
15872 }
15873
15874 #[test]
15875 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15876 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15879 assert!(verified_noop_claim(false, &[], text).is_none());
15880 }
15881
15882 #[test]
15883 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15884 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15885 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15886 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15888 }
15889
15890 #[test]
15891 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15892 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15893 }
15894
15895 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15898 runner.state.candidates = shape
15899 .iter()
15900 .enumerate()
15901 .map(|(i, &(empty, verified))| Candidate {
15902 index: i,
15903 label: (b'A' + i as u8) as char,
15904 agent: "sonnet".to_owned(),
15905 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15906 worktree: PathBuf::from(format!("/wt/{i}")),
15907 summary: String::new(),
15908 stat: String::new(),
15909 files: 0,
15910 commits: 0,
15911 empty,
15912 failed: None,
15913 verified_noop: verified.map(str::to_owned),
15914 duration_ms: 0,
15915 folded: false,
15916 })
15917 .collect();
15918 }
15919
15920 #[test]
15921 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15922 ask_test_home();
15923 let mut runner = runner_at(RunStatus::Implementing);
15924 set_candidates(
15925 &mut runner,
15926 &[
15927 (true, Some("already on main at b32cfc4")),
15928 (true, Some("same fix, see the existing test")),
15929 ],
15930 );
15931
15932 runner
15933 .after_implement()
15934 .expect("a verified no-op is not an error");
15935
15936 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
15937 }
15938
15939 #[test]
15940 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
15941 ask_test_home();
15942 let mut runner = runner_at(RunStatus::Implementing);
15943 set_candidates(
15947 &mut runner,
15948 &[(true, Some("already on main at b32cfc4")), (true, None)],
15949 );
15950
15951 let err = runner
15952 .after_implement()
15953 .expect_err("an unverified empty candidate must still fail the run");
15954
15955 assert!(
15956 err.to_string().contains("no candidate produced a change"),
15957 "{err}"
15958 );
15959 assert_eq!(runner.state.status, RunStatus::Failed);
15960 }
15961
15962 #[test]
15963 fn after_implement_still_fails_an_ordinary_all_empty_run() {
15964 ask_test_home();
15965 let mut runner = runner_at(RunStatus::Implementing);
15966 set_candidates(&mut runner, &[(true, None), (true, None)]);
15967
15968 let err = runner
15969 .after_implement()
15970 .expect_err("no candidate declared anything; this is an ordinary failure");
15971
15972 assert!(
15973 err.to_string().contains("no candidate produced a change"),
15974 "{err}"
15975 );
15976 assert_eq!(runner.state.status, RunStatus::Failed);
15977 }
15978}
15979
15980#[derive(Debug)]
15983struct GraftRestoreFailed(String);
15984
15985impl std::fmt::Display for GraftRestoreFailed {
15986 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15987 write!(
15988 f,
15989 "could not restore the winner to its pre-graft commit: {}",
15990 self.0
15991 )
15992 }
15993}
15994
15995impl std::error::Error for GraftRestoreFailed {}
15996
15997#[cfg(test)]
16001mod resource_wait_tests {
16002 use super::*;
16003 use std::collections::BTreeMap;
16004
16005 fn sh(id: &str, script: &str) -> AgentSpec {
16006 AgentSpec {
16007 id: id.to_owned(),
16008 kind: crate::config::AgentKind::Command,
16009 model: None,
16010 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16011 extra_args: Vec::new(),
16012 env: BTreeMap::new(),
16013 prompt_delivery: None,
16014 }
16015 }
16016
16017 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16018 SeatJob {
16019 seat: SeatState::new("impl-A", &spec.id, 1),
16020 spec,
16021 cwd: dir.to_path_buf(),
16022 prompt: "do it".to_owned(),
16023 timeout,
16024 allow_write: true,
16025 sessions: false,
16026 artifacts: dir.join("art"),
16027 stem: "impl-A".to_owned(),
16028 handover: None,
16029 }
16030 }
16031
16032 fn state(resource_wait_secs: u64) -> RunState {
16033 let mut config = Config::default();
16034 config.graph.timeout_resource_wait = resource_wait_secs;
16035 RunState::new(
16036 PathBuf::from("/repo"),
16037 "main".to_owned(),
16038 "abc1234".to_owned(),
16039 "task".to_owned(),
16040 config,
16041 )
16042 }
16043
16044 fn hold(cache: &Path) -> crate::cache::Guard {
16045 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16046 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16047 crate::cache::AcquireOutcome::Acquired(g) => g,
16048 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16049 }
16050 }
16051
16052 async fn run(
16053 jobs: Vec<SeatJob>,
16054 sem: Arc<Semaphore>,
16055 cache: Option<&Path>,
16056 st: &mut RunState,
16057 ) -> Vec<(usize, SeatState, AgentOutcome)> {
16058 let prompts = Prompts::default();
16059 let ctx = WaveCtx {
16060 run: "r",
16061 node: "implement",
16062 prompts: &prompts,
16063 cache,
16064 round: None,
16065 carry_seats: false,
16066 pauses: None,
16067 };
16068 wave(jobs, sem, &ctx, st, 0).await
16069 }
16070
16071 #[tokio::test]
16072 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16073 crate::run::pin_test_home();
16074 let tmp = tempfile::tempdir().unwrap();
16075 let cache = tmp.path().join("target-a");
16076 let guard = hold(&cache);
16077 tokio::spawn(async move {
16079 tokio::time::sleep(Duration::from_millis(1500)).await;
16080 guard.release();
16081 });
16082 let mut st = state(60);
16083 let results = run(
16084 vec![job(
16085 sh("alpha", "echo ok"),
16086 tmp.path(),
16087 Duration::from_secs(1),
16088 )],
16089 Arc::new(Semaphore::new(1)),
16090 Some(&cache),
16091 &mut st,
16092 )
16093 .await;
16094 assert!(
16095 matches!(results[0].2, AgentOutcome::Ok(_)),
16096 "the wait must not be charged to the agent"
16097 );
16098 assert!(st.resource_block.is_none());
16099 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16100 assert!(st.resource_wait.is_none() && st.active.is_empty());
16101 }
16102
16103 #[tokio::test]
16104 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16105 crate::run::pin_test_home();
16106 let tmp = tempfile::tempdir().unwrap();
16107 let cache = tmp.path().join("target-b");
16108 let _held = hold(&cache);
16109 let mut st = state(1);
16110 let results = run(
16111 vec![job(
16112 sh("alpha", "echo ok"),
16113 tmp.path(),
16114 Duration::from_secs(1),
16115 )],
16116 Arc::new(Semaphore::new(1)),
16117 Some(&cache),
16118 &mut st,
16119 )
16120 .await;
16121 let (_, seat, out) = &results[0];
16122 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16123 assert!(wave_blocked(&results));
16124 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16125 assert_eq!(seat.turns, 0, "nothing was asked");
16126 assert!(
16127 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16128 "no agent was spawned"
16129 );
16130 assert!(FailClass::of(out).is_none(), "not a failure class");
16131 let block = st.resource_block.as_ref().expect("recorded for the resume");
16132 assert_eq!(block.kind, "shared build cache");
16133 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16134 assert!(st.active.is_empty());
16135 }
16136
16137 #[tokio::test]
16138 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16139 crate::run::pin_test_home();
16140 let tmp = tempfile::tempdir().unwrap();
16141 let mut st = state(1);
16142 let results = run(
16143 vec![job(
16144 sh("alpha", "echo ok"),
16145 tmp.path(),
16146 Duration::from_secs(1),
16147 )],
16148 Arc::new(Semaphore::new(0)),
16149 None,
16150 &mut st,
16151 )
16152 .await;
16153 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16154 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16155 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16156 }
16157
16158 #[tokio::test]
16159 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16160 crate::run::pin_test_home();
16161 let tmp = tempfile::tempdir().unwrap();
16162 let mut st = state(60);
16163 let results = run(
16164 vec![job(
16165 sh("alpha", "sleep 30"),
16166 tmp.path(),
16167 Duration::from_secs(1),
16168 )],
16169 Arc::new(Semaphore::new(1)),
16170 None,
16171 &mut st,
16172 )
16173 .await;
16174 let out = &results[0].2;
16175 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16176 let class = FailClass::of(out).expect("a failure");
16177 assert_eq!(class, FailClass::Timeout);
16178 assert!(should_hand_over(None, &class), "existing handover policy");
16179 assert!(st.resource_block.is_none());
16180 }
16181}