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 crate::pr_head::ensure_owner(&state)?;
1080 if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1081 bail!(
1082 "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1083 );
1084 }
1085 if let Some(to) = &state.released_to {
1086 bail!(
1087 "run {} cannot be resumed: its worktree was released to run {}",
1088 state.short(),
1089 crate::run::short_of(to)
1090 );
1091 }
1092 let roles = state.config.resolve_roles()?;
1093 if let Some(id) = &state.config.graph.selected_implementer
1094 && state.candidates.iter().any(|c| c.agent != *id)
1095 {
1096 bail!(
1097 "replacement implementer pin does not match the saved candidates; task must stay held"
1098 );
1099 }
1100 let max_parallel = state.config.graph.max_parallel.max(1);
1101 Ok(Self {
1102 state,
1103 roles,
1104 sem: Arc::new(Semaphore::new(max_parallel)),
1105 pause: Pause::new(),
1106 interrupt: Pause::new(),
1107 })
1108 }
1109
1110 pub async fn execute(&mut self) -> Result<()> {
1117 let _repair_claim = if self.state.config.graph.selected_implementer.is_some()
1120 || self.state.land_approval.is_some()
1121 || self.state.merge_decision.is_some()
1122 || !self.state.merge_repairs.is_empty()
1123 || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1124 {
1125 let claim = FixClaim::acquire(&self.state.dir())?;
1126 let disk = RunState::load(&self.state.id)?;
1127 let checkpoint = |s: &RunState| {
1128 (
1129 s.merge_repairs.len(),
1130 s.merge_repairs
1131 .last()
1132 .map(|r| (r.question.clone(), r.phase)),
1133 s.land_approval
1134 .as_ref()
1135 .map(|a| (a.question.clone(), a.head.clone())),
1136 )
1137 };
1138 if checkpoint(&disk) != checkpoint(&self.state) {
1139 bail!(
1140 "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1141 );
1142 }
1143 Some(claim)
1144 } else {
1145 None
1146 };
1147 let result = self.execute_graph().await;
1148 self.mark_driver_exited();
1149 let ended = if result.is_err() {
1150 Some(crate::notices::run_stopped(&self.state.id, &self.state))
1151 } else {
1152 crate::notices::run_ended(&self.state)
1153 };
1154 if let Some(notice) = ended {
1155 crate::notices::raise(notice);
1156 }
1157 result
1158 }
1159
1160 fn mark_driver_exited(&mut self) {
1169 self.state.driver_exited = true;
1170 let pid = std::process::id();
1171 let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1172 return;
1173 };
1174 let Ok(mut disk) = RunState::load(&self.state.id) else {
1175 return;
1176 };
1177 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1178 return;
1179 }
1180 disk.driver_exited = true;
1181 if let Err(e) = disk.save() {
1182 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1183 }
1184 }
1185
1186 fn claim_driver(&mut self) -> Result<()> {
1188 let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1189 let disk = RunState::load(&self.state.id)?;
1190 crate::pr_head::ensure_owner(&disk)?;
1191 self.state.parked = false;
1196 self.state.resource_wait = None;
1197 self.state.resource_block = None;
1198 self.state.clear_active();
1205 if let Some(to) = &disk.released_to {
1223 bail!(
1224 "run {} cannot continue: its worktree was released to run {}",
1225 self.state.short(),
1226 crate::run::short_of(to)
1227 );
1228 }
1229 let pid = std::process::id();
1230 self.state.driver_pid = Some(pid);
1231 self.state.driver_started_at = crate::proc::process_started_at(pid);
1232 self.state.driver_exited = false;
1233 self.state.begin_pass();
1234 self.state.save()?;
1235 Ok(())
1236 }
1237
1238 async fn execute_graph(&mut self) -> Result<()> {
1239 self.claim_driver()?;
1240 if self
1241 .state
1242 .merge_decision
1243 .as_ref()
1244 .is_some_and(|decision| !decision.closed)
1245 && self.state.status != RunStatus::Landing
1246 {
1247 if crate::merge_decision::resumable_hold(&self.state) {
1248 self.state.merge = Some(MergeOutcome {
1249 mode: MergeMode::Pr,
1250 ok: false,
1251 detail: self
1252 .state
1253 .merge_decision
1254 .as_ref()
1255 .unwrap()
1256 .target
1257 .pr
1258 .clone(),
1259 empty: false,
1260 });
1261 self.run_land().await?;
1262 self.settle_questions();
1263 return Ok(());
1264 }
1265 bail!(
1266 "owner stop decision is held; inspect the existing PR and request an explicit fresh review/decision before further work"
1267 );
1268 }
1269 if self.state.config.graph.selected_implementer.is_some()
1270 && self.state.status == RunStatus::Blocked
1271 && self.state.tally.is_none()
1272 {
1273 bail!(
1274 "selected implementation is held; explicitly release the owning task to retry it"
1275 );
1276 }
1277
1278 if self.state.merge_repairs.last().is_some_and(|r| {
1279 matches!(
1280 r.phase,
1281 crate::run::MergeRepairPhase::Requested
1282 | crate::run::MergeRepairPhase::Fixing
1283 | crate::run::MergeRepairPhase::Reviewing
1284 )
1285 }) {
1286 if self.repair_merge_review().await? {
1287 self.run_land().await?;
1288 }
1289 self.settle_questions();
1290 return Ok(());
1291 }
1292 if self.state.pr_publication_pending {
1295 self.merge().await?;
1296 if self.resource_halt()? {
1297 return Ok(());
1298 }
1299 self.state.save()?;
1300 self.settle_questions();
1301 return Ok(());
1302 }
1303 if self.state.status == RunStatus::Stalled {
1316 if self.recover_stall().await? {
1317 self.finish_after_tally().await?;
1318 } else {
1319 self.state.save()?;
1321 }
1322 return Ok(());
1323 }
1324 if self.state.status == RunStatus::Landing {
1334 self.run_land().await?;
1335 self.settle_questions();
1340 return Ok(());
1341 }
1342 if self.state.github_text.is_some()
1347 && self.state.merge.is_none()
1348 && self.state.status == RunStatus::Gating
1349 {
1350 self.merge().await?;
1351 if self.resource_halt()? {
1352 return Ok(());
1353 }
1354 self.state.save()?;
1355 return Ok(());
1356 }
1357 if let Err(e) = self.prep().await {
1358 self.state.status = RunStatus::Blocked;
1359 self.state
1360 .prep_failure
1361 .get_or_insert_with(|| format!("{e:#}"));
1362 self.state
1363 .event("prep", format!("preparation stopped: {e:#}"));
1364 self.state.save()?;
1365 return Err(e);
1366 }
1367 if self.park_here()? {
1368 return Ok(());
1369 }
1370 self.advise().await?;
1371 if self.park_here()? {
1372 return Ok(());
1373 }
1374 self.implement().await?;
1375 if self.park_here()? {
1376 return Ok(());
1377 }
1378 if self.state.status == RunStatus::VerifiedNoop {
1382 return Ok(());
1383 }
1384 self.judge().await?;
1385 if self.park_here()? {
1386 return Ok(());
1387 }
1388 self.deliberate().await?;
1389 if self.park_here()? {
1390 return Ok(());
1391 }
1392 self.vote().await?;
1393 if self.park_here()? {
1394 return Ok(());
1395 }
1396 self.tally()?;
1397 if self.state.status == RunStatus::Stalled {
1402 self.state.save()?;
1406 return Ok(());
1407 }
1408 self.finish_after_tally().await?;
1409 Ok(())
1410 }
1411
1412 fn stop_for_resource(&mut self) -> Result<()> {
1417 if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1419 {
1420 self.state.status = RunStatus::Blocked;
1421 }
1422 self.state.save()
1423 }
1424
1425 fn resource_halt(&mut self) -> Result<bool> {
1427 if self.state.resource_block.is_none() {
1428 return Ok(false);
1429 }
1430 self.stop_for_resource()?;
1431 Ok(true)
1432 }
1433
1434 fn park_here(&mut self) -> Result<bool> {
1441 let parking = self.pause.parked() || self.interrupt.parked();
1442 if !parking && self.state.resource_block.is_some() {
1443 self.stop_for_resource()?;
1444 return Ok(true);
1445 }
1446 if !self.pause.parked() && !self.interrupt.parked() {
1451 return Ok(false);
1452 }
1453 let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1454 let why = match cause.as_ref().map(ParkCause::label) {
1455 Some(reason) => format!(
1456 "parked after `{}` ({reason}) — resume to carry on from here",
1457 self.state.status.as_str()
1458 ),
1459 None => format!(
1460 "parked after `{}` — resume to carry on from here",
1461 self.state.status.as_str()
1462 ),
1463 };
1464 self.state.event("park", why);
1465 self.state
1466 .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1467 self.state.parked = true;
1468 self.state.save()?;
1469 Ok(true)
1470 }
1471
1472 pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1474 if self.state.prep_failure.is_none() {
1475 return Ok(());
1476 }
1477 let failed_at = self
1478 .state
1479 .events
1480 .iter()
1481 .rev()
1482 .find(|event| event.node == "prep")
1483 .map_or(self.state.updated_at, |event| event.at);
1484 anyhow::ensure!(
1485 approval_at > failed_at,
1486 "preparation retry requires a new explicit release after the failure"
1487 );
1488 self.state.prep_failure = None;
1489 self.state.prep_complete = Some(false);
1490 self.state
1491 .event("prep-retry", "operator approved retry of preparation");
1492 Ok(())
1493 }
1494
1495 pub fn on_pause(&mut self, pause: Pause) {
1497 self.pause = pause;
1498 }
1499
1500 pub fn watch_interrupt(&mut self, pause: Pause) {
1506 self.interrupt = pause;
1507 }
1508
1509 fn settle_questions(&mut self) {
1529 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1530 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1531 }
1532 }
1533
1534 async fn finish_after_tally(&mut self) -> Result<()> {
1537 self.graft().await?;
1540 if self.resource_halt()? {
1541 return Ok(());
1542 }
1543 self.fold_losers().await?;
1544 self.sync_to_base().await?;
1549 if self.state.status == RunStatus::AlreadyInBase {
1550 return Ok(());
1551 }
1552 self.review_loop().await?;
1553 if self.resource_halt()? {
1554 return Ok(());
1555 }
1556 self.sync_to_base().await?;
1557 if self.state.status == RunStatus::AlreadyInBase {
1558 return Ok(());
1559 }
1560 self.gate().await?;
1561 self.merge().await?;
1562 if self.resource_halt()? {
1563 return Ok(());
1564 }
1565 self.state.save()?;
1566 Ok(())
1567 }
1568
1569 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1576 let result = match ensure_setup_config(&mut self.state, repo).await {
1577 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1578 Err(e) => Err(e),
1579 };
1580 if let Err(e) = result {
1581 git::worktree_remove(repo, wt).await.ok();
1582 if in_prep {
1583 self.rollback_prep(repo).await;
1584 }
1585 self.state.status = RunStatus::Blocked;
1586 self.state.event(
1587 "setup",
1588 format!("worktree setup failed in {}: {e:#}", wt.display()),
1589 );
1590 self.state.save()?;
1591 return Err(e);
1592 }
1593 Ok(())
1594 }
1595
1596 async fn rollback_prep(&mut self, repo: &Path) {
1598 let root = self.state.worktree_root();
1599 for c in std::mem::take(&mut self.state.candidates) {
1600 git::worktree_remove(repo, &c.worktree).await.ok();
1601 git::branch_delete(repo, &c.branch).await.ok();
1602 }
1603 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1604 for j in 1..=seats {
1605 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1606 .await
1607 .ok();
1608 }
1609 for k in 1..=self.state.config.graph.advisors {
1610 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1611 .await
1612 .ok();
1613 }
1614 }
1615
1616 async fn prep(&mut self) -> Result<()> {
1617 if let Some(reason) = &self.state.prep_failure {
1618 bail!(
1619 "candidate preparation previously failed: {reason}; correct the source before retrying"
1620 );
1621 }
1622 if self
1623 .state
1624 .prep_complete
1625 .unwrap_or(!self.state.candidates.is_empty())
1626 {
1627 return Ok(());
1628 }
1629 let repo = self.state.repo.clone();
1630 self.rollback_prep(&repo).await;
1631 self.state.prep_complete = Some(false);
1632 self.state.status = RunStatus::Prep;
1633 self.state.save()?;
1634 let repo = self.state.repo.clone();
1635 let base = self.state.base_commit.clone();
1636 let plan = refs::plan(&repo, &self.state.seeds).await?;
1637 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1638 let root = self.state.worktree_root();
1639 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1640
1641 let hooks_dir = self.state.dir().join("hooks");
1644 if self.state.config.blind.commit_msg_hook {
1645 std::fs::create_dir_all(&hooks_dir)
1646 .with_context(|| format!("create {}", hooks_dir.display()))?;
1647 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1648 let path = hooks_dir.join("commit-msg");
1649 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1650 make_executable(&path)?;
1651 git::acquire_worktree_config(&repo).await?;
1659 self.state.enabled_worktree_config = true;
1660 }
1661
1662 ensure_setup_config(&mut self.state, &repo).await?;
1665
1666 for (index, (spec, label)) in self
1667 .roles
1668 .implementers
1669 .clone()
1670 .into_iter()
1671 .zip(labels)
1672 .enumerate()
1673 {
1674 let branch = self.state.branch_for(label);
1675 let worktree = root.join(format!("cand-{label}"));
1676 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1677 if self.state.config.blind.commit_msg_hook {
1678 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1679 }
1680 git::local_exclude(&worktree, "/.magi/").await?;
1681 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1682 git::worktree_remove(&repo, &worktree).await.ok();
1685 git::branch_delete(&repo, &branch).await.ok();
1686 self.rollback_prep(&repo).await;
1687 self.state.status = RunStatus::Blocked;
1688 self.state
1689 .event("prep", format!("worktree setup failed: {e:#}"));
1690 self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1691 self.state.save()?;
1692 return Err(e);
1693 }
1694 self.state.candidates.push(Candidate {
1695 index,
1696 label,
1697 agent: spec.id.clone(),
1698 branch,
1699 worktree,
1700 summary: String::new(),
1701 stat: String::new(),
1702 files: 0,
1703 commits: 0,
1704 empty: false,
1705 failed: None,
1706 verified_noop: None,
1707 duration_ms: 0,
1708 folded: false,
1709 });
1710 self.state.save()?;
1711 for pick in &plan.picks {
1712 if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1713 {
1714 self.state.status = RunStatus::Blocked;
1715 self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1716 self.state
1717 .event("prep", format!("cannot apply referenced commit: {e}"));
1718 self.state.save()?;
1719 return Err(e);
1720 }
1721 }
1722 }
1723
1724 for j in 1..=self.roles.judges.len() {
1725 let wt = root.join(format!("judge-{j}"));
1726 if !wt.exists() {
1727 git::worktree_add_detached(&repo, &wt, &base).await?;
1728 self.setup_seat_worktree(&repo, &wt, true).await?;
1729 }
1730 }
1731
1732 if self.state.config.graph.advise {
1740 for k in 1..=self.state.config.graph.advisors {
1741 let wt = root.join(format!("advisor-{k}"));
1742 if !wt.exists() {
1743 git::worktree_add_detached(&repo, &wt, &base).await?;
1744 self.setup_seat_worktree(&repo, &wt, true).await?;
1745 }
1746 }
1747 }
1748
1749 let authors: Vec<&str> = self
1754 .roles
1755 .implementers
1756 .iter()
1757 .map(|a| a.id.as_str())
1758 .collect();
1759 let overlap: Vec<String> = self
1760 .roles
1761 .judges
1762 .iter()
1763 .enumerate()
1764 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1765 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1766 .collect();
1767 if !overlap.is_empty() {
1768 let note = format!(
1769 "{} also authored a candidate; blind, but the panel is less \
1770 independent than {} distinct agents would be",
1771 overlap.join(", "),
1772 self.roles.judges.len()
1773 );
1774 self.state.event("prep", note);
1775 }
1776
1777 self.state.event(
1778 "prep",
1779 format!(
1780 "{} candidates, {} judges, base {} ({})",
1781 self.state.candidates.len(),
1782 self.roles.judges.len(),
1783 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1784 self.state.base_branch
1785 ),
1786 );
1787 self.state.prep_complete = Some(true);
1788 self.state.status = RunStatus::Implementing;
1789 self.state.save()?;
1790 Ok(())
1791 }
1792
1793 async fn advise(&mut self) -> Result<()> {
1826 let implement_untouched = self
1827 .state
1828 .candidates
1829 .iter()
1830 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1831 if !self.state.config.graph.advise || self.state.advise_attempted {
1832 return Ok(());
1833 }
1834 if !implement_untouched {
1835 self.state.event(
1836 "advise",
1837 "skipping the design-deliberation stage: at least one \
1838 candidate already shows implementation progress, so this \
1839 run is past the point the stage exists to run before"
1840 .to_owned(),
1841 );
1842 self.state.advise_attempted = true;
1843 self.state.save()?;
1844 return Ok(());
1845 }
1846 let run_id = self.state.id.clone();
1847 let prompts = self.state.config.prompts.clone();
1848 let instruction = self.state.instruction.clone();
1849 let language = self.state.config.graph.language.clone();
1850 let root = self.state.worktree_root();
1851 let n = self.state.config.graph.advisors;
1852 let where_recorded = self.state.dir().join("run.json");
1853
1854 let seats = match self.state.config.advisors() {
1855 Ok(seats) if !seats.is_empty() => seats,
1856 Ok(_) => {
1857 self.state.event(
1858 "advise",
1859 format!(
1860 "[graph] advisors is 0; skipping the design-deliberation \
1861 stage and continuing without a synthesis brief (see {})",
1862 where_recorded.display()
1863 ),
1864 );
1865 self.state.advise_attempted = true;
1866 self.state.save()?;
1867 return Ok(());
1868 }
1869 Err(e) => {
1870 self.state.event(
1871 "advise",
1872 format!(
1873 "could not resolve advisor seats ({e:#}); continuing \
1874 without a design-deliberation brief (see {})",
1875 where_recorded.display()
1876 ),
1877 );
1878 self.state.advise_attempted = true;
1879 self.state.save()?;
1880 return Ok(());
1881 }
1882 };
1883
1884 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1885 let artifacts = agent::artifacts_dir(&self.state.dir());
1886 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1887
1888 let mut jobs = Vec::new();
1889 for (i, spec) in seats.iter().cloned().enumerate() {
1890 let seat_key = format!("advisor-{}", i + 1);
1891 let seat = self.seat(&seat_key, &spec.id);
1892 jobs.push(SeatJob {
1893 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1894 spec,
1895 seat,
1896 cwd: worktrees[i % worktrees.len()].clone(),
1897 timeout,
1898 allow_write: false,
1899 sessions: false,
1900 artifacts: artifacts.clone(),
1901 stem: seat_key,
1902 handover: None,
1903 });
1904 }
1905
1906 self.state.event(
1907 "advise",
1908 format!(
1909 "{} advisor seat(s) sketching a design in parallel",
1910 jobs.len()
1911 ),
1912 );
1913 let mut quota_losses = Vec::new();
1914 let cache = self.state.config.cache_dir();
1915 let ctx = WaveCtx {
1916 carry_seats: false,
1917 pauses: Some((&self.pause, &self.interrupt)),
1918 run: &run_id,
1919 node: "advise",
1920 prompts: &prompts,
1921 cache: cache.as_deref(),
1922 round: None,
1923 };
1924 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1925 let results = ask_json_wave::<Proposal>(
1926 jobs,
1927 Arc::clone(&self.sem),
1928 self.state.config.graph.retries,
1929 &advisor_roster,
1930 &ctx,
1931 &mut quota_losses,
1932 &mut self.state,
1933 &|p: &Proposal| p.validate(),
1934 )
1935 .await;
1936 if self.state.resource_block.is_some() {
1937 return self.stop_for_resource();
1938 }
1939 self.state.quota.extend(quota_losses);
1940
1941 let mut records = Vec::with_capacity(results.len());
1942 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1943 let agent_id = seat.agent.clone();
1944 self.state.seats.insert(seat.key.clone(), seat);
1945 match res {
1946 Ok((proposal, out)) => {
1947 self.state
1948 .event("advise", format!("advisor-{} proposed a design", i + 1));
1949 records.push(advise::AdvisorRecord::proposed(
1950 i + 1,
1951 agent_id,
1952 proposal,
1953 out.duration_ms,
1954 ));
1955 }
1956 Err(e) => {
1957 self.state.event(
1958 "advise",
1959 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1960 );
1961 records.push(advise::AdvisorRecord::failed(
1962 i + 1,
1963 agent_id,
1964 e.to_string(),
1965 ));
1966 }
1967 }
1968 }
1969
1970 let mut advice = advise::Advice {
1971 records,
1972 synthesis: None,
1973 };
1974 if advice.proposals().is_empty() {
1975 self.state.event(
1976 "advise",
1977 "no advisor produced a usable proposal; continuing without a \
1978 synthesis brief"
1979 .to_owned(),
1980 );
1981 } else {
1982 match self
1983 .synthesize_brief(
1984 &advice,
1985 &instruction,
1986 &language,
1987 &worktrees[0],
1988 &artifacts,
1989 &run_id,
1990 &prompts,
1991 cache.as_deref(),
1992 )
1993 .await
1994 {
1995 Ok(Some(text)) => {
1996 self.state.event(
1997 "advise",
1998 "synthesized a design brief for the implementer".to_owned(),
1999 );
2000 advice.synthesis = Some(text);
2001 }
2002 Ok(None) => {
2003 self.state.event(
2004 "advise",
2005 "the synthesis seat produced nothing usable; continuing \
2006 without a design brief"
2007 .to_owned(),
2008 );
2009 }
2010 Err(e) => {
2011 self.state.event(
2012 "advise",
2013 format!("could not synthesize a design brief: {e:#}"),
2014 );
2015 }
2016 }
2017 }
2018 advise::apply_reflection(&mut advice);
2019
2020 self.state.advice = Some(advice);
2021 self.state.advise_attempted = true;
2022 self.state.save()?;
2023 Ok(())
2024 }
2025
2026 #[allow(clippy::too_many_arguments)]
2038 async fn synthesize_brief(
2039 &mut self,
2040 advice: &advise::Advice,
2041 instruction: &str,
2042 language: &str,
2043 cwd: &Path,
2044 artifacts: &Path,
2045 run_id: &str,
2046 prompts: &Prompts,
2047 cache: Option<&Path>,
2048 ) -> Result<Option<String>> {
2049 let chain = agent::pick_chain(
2050 &self.state.config.agents,
2051 self.state.config.roles.synthesizer.as_ref(),
2052 &agent::installed,
2053 "synthesizer",
2054 )?;
2055 let proposals = advice.proposals();
2056 let mut prompt = prompt::with_overlay(
2057 prompt::synthesize_brief(instruction, &proposals, language),
2058 prompts.overlay("advise"),
2059 );
2060 if cache.is_some() {
2061 prompt.push('\n');
2066 prompt.push_str(&prompt::build_cache_note("advise", false));
2067 }
2068 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2069 let mut last = None;
2074 for (n, spec) in chain.iter().enumerate() {
2075 if n > 0 {
2076 self.state
2077 .event("advise", format!("synthesis falling back to {}", spec.id));
2078 }
2079 let mut seat = self.seat("advise-synthesis", &spec.id);
2080 let outcome = agent::invoke(
2081 spec,
2082 &mut seat,
2083 &Invocation {
2084 cwd,
2085 prompt: &prompt,
2086 timeout,
2087 allow_write: false,
2088 unsandboxed: false,
2089 sessions: false,
2090 artifacts,
2091 stem: &if n == 0 {
2092 "advise-synthesis".to_owned()
2093 } else {
2094 format!("advise-synthesis-{}", spec.id)
2095 },
2096 run: run_id,
2097 node: "advise",
2098 cache_dir: None,
2099 attachments: &[],
2100 writable: &[],
2101 },
2102 )
2103 .await;
2104 if outcome.is_ok() {
2105 self.state.seats.insert(seat.key.clone(), seat);
2106 }
2107 let advance = agent::chain_advances(&outcome);
2108 last = Some(outcome);
2109 if !advance {
2110 break;
2111 }
2112 }
2113 let out = last.expect("a chain holds at least one agent")?;
2116 if !out.usable() {
2117 return Ok(None);
2118 }
2119 let text =
2120 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2121 Ok((!text.trim().is_empty()).then_some(text))
2122 }
2123
2124 async fn implement(&mut self) -> Result<()> {
2127 let run_id = self.state.id.clone();
2132 let prompts = self.state.config.prompts.clone();
2133 let todo: Vec<usize> = self
2134 .state
2135 .candidates
2136 .iter()
2137 .enumerate()
2138 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2139 .map(|(i, _)| i)
2140 .collect();
2141 if todo.is_empty() {
2142 return self.after_implement();
2143 }
2144 self.state.status = RunStatus::Implementing;
2145
2146 let language = self.state.config.graph.language.clone();
2147 let github_language = self.state.config.graph.github_language.clone();
2148 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2149 let sessions = self.state.config.graph.sessions;
2150 let artifacts = agent::artifacts_dir(&self.state.dir());
2151 let brief = self
2155 .state
2156 .advice
2157 .as_ref()
2158 .and_then(|a| a.synthesis.as_deref())
2159 .map(str::to_owned);
2160 let attachments = self.state.attachments.clone();
2161
2162 let mut jobs = Vec::new();
2163 for &i in &todo {
2164 let (index, label, worktree) = {
2165 let c = &self.state.candidates[i];
2166 (c.index, c.label, c.worktree.clone())
2167 };
2168 let spec = self.roles.implementers[index].clone();
2169 let seat_key = format!("impl-{label}");
2170 let seat = self.seat(&seat_key, &spec.id);
2171 let instruction = seeded_instruction(&self.state);
2172 jobs.push(SeatJob {
2173 spec,
2174 seat,
2175 prompt: prompt::implement_in(
2176 &instruction,
2177 &worktree.to_string_lossy(),
2178 &language,
2179 &github_language,
2180 brief.as_deref(),
2181 &attachments,
2182 ),
2183 cwd: worktree,
2184 timeout,
2185 allow_write: true,
2186 sessions,
2187 artifacts: artifacts.clone(),
2188 stem: format!("impl-{label}"),
2189 handover: None,
2190 });
2191 }
2192
2193 self.state.event(
2194 "implement",
2195 format!("{} candidates in parallel", jobs.len()),
2196 );
2197 let mut sent = jobs.clone();
2203 let cache = self.state.config.cache_dir();
2204 let ctx = WaveCtx {
2205 carry_seats: false,
2206 pauses: Some((&self.pause, &self.interrupt)),
2207 run: &run_id,
2208 node: "implement",
2209 prompts: &prompts,
2210 cache: cache.as_deref(),
2211 round: None,
2212 };
2213 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2214 if wave_blocked(&results) {
2215 return self.stop_for_resource();
2216 }
2217 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2218 .await;
2219 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2220 .await;
2221 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2222 .await;
2223 if self.state.resource_block.is_some() {
2224 return self.stop_for_resource();
2225 }
2226
2227 let selected_failed = self.state.config.graph.selected_implementer.is_some()
2228 && results
2229 .iter()
2230 .any(|(_, _, out)| FailClass::of(out).is_some());
2231 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2232 let seat_key = seat.key.clone();
2233 let agent = seat.agent.clone();
2242 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2243 self.state.seats.insert(seat.key.clone(), seat);
2244 let label = self.state.candidates[i].label;
2245 let worktree = self.state.candidates[i].worktree.clone();
2246 let base = self.state.base_commit.clone();
2247
2248 let (summary, duration, failed, verified_claim) = match out {
2249 AgentOutcome::Ok(o) => {
2250 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2251 let failed = (!o.usable()).then(|| {
2252 if o.timed_out {
2253 "agent timed out".to_owned()
2254 } else {
2255 format!("agent exited with {:?}", o.exit_code)
2256 }
2257 });
2258 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2259 (text, o.duration_ms, failed, verified_claim)
2260 }
2261 AgentOutcome::Dropped(o) => {
2267 let why = o
2268 .dropped
2269 .as_ref()
2270 .map(|d| d.why.as_str())
2271 .unwrap_or("the CLI ended the stream without delivering its answer");
2272 (
2273 String::new(),
2274 o.duration_ms,
2275 Some(format!("the CLI dropped the stream ({why})")),
2276 None,
2277 )
2278 }
2279 AgentOutcome::Quota(o) => {
2280 self.state.quota.push(QuotaLoss {
2281 seat: seat_key,
2282 node: "implement".to_owned(),
2283 at: Timestamp::now(),
2284 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2285 });
2286 (
2287 String::new(),
2288 o.duration_ms,
2289 Some("rate limited (quota); produced no change".to_owned()),
2290 None,
2291 )
2292 }
2293 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2294 (String::new(), 0, Some(e), None)
2295 }
2296 };
2297
2298 let rescued = match git::rescue_commit(
2301 &worktree,
2302 &format!("magi: candidate {label} (uncommitted work)"),
2303 )
2304 .await
2305 {
2306 Ok(r) => {
2307 self.state.note_withheld("implement", &r.withheld);
2308 r.committed
2309 }
2310 Err(_) => false,
2311 };
2312 let commits = git::commits_ahead(&worktree, &base, "HEAD")
2313 .await
2314 .unwrap_or(0);
2315 let patch = git::diff(&worktree, &base, "HEAD")
2316 .await
2317 .unwrap_or_default();
2318 let stat = git::diff_stat(&worktree, &base, "HEAD")
2319 .await
2320 .unwrap_or_default();
2321 let files = git::changed_files(&worktree, &base, "HEAD")
2322 .await
2323 .map(|f| f.len())
2324 .unwrap_or(0);
2325 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2326
2327 let c = &mut self.state.candidates[i];
2328 if !exhausted_the_fallback_chain {
2329 c.agent = agent;
2330 }
2331 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2332 c.stat = stat;
2333 c.files = files;
2334 c.commits = commits;
2335 c.duration_ms = duration;
2336 c.empty = commits == 0 || patch.trim().is_empty();
2337 c.failed = match failed {
2340 Some(_) if c.empty => failed,
2341 _ => None,
2342 };
2343 c.verified_noop = if c.empty { verified_claim } else { None };
2348 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2349 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2350 (None, true, Some(_), _) => {
2351 format!("candidate {label}: no change produced (agent-verified no-op)")
2352 }
2353 (None, true, None, _) => format!("candidate {label}: no change produced"),
2354 (None, false, _, true) => {
2355 format!(
2356 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2357 )
2358 }
2359 (None, false, _, false) => {
2360 format!("candidate {label}: {files} files, {commits} commits")
2361 }
2362 };
2363 self.state.event("implement", note);
2364 self.state.save()?;
2365 }
2366
2367 if selected_failed {
2368 self.state.status = RunStatus::Blocked;
2369 self.state.event("implement", "selected implementer failed or hit quota; holding without fallback, review or publication");
2370 self.state.save()?;
2371 bail!(
2372 "selected implementer unavailable or quota-limited; restore availability and explicitly release the task"
2373 );
2374 }
2375 self.after_implement()
2376 }
2377
2378 async fn resume_undelivered(
2406 &mut self,
2407 results: &mut [(usize, SeatState, AgentOutcome)],
2408 sent: &[SeatJob],
2409 prompts: &Prompts,
2410 run_id: &str,
2411 ) {
2412 for (wi, seat, out) in results.iter_mut() {
2413 let Some(dropped) = (match &*out {
2414 AgentOutcome::Dropped(o) => o.dropped.clone(),
2415 _ => None,
2416 }) else {
2417 continue;
2418 };
2419 let Some(job) = sent.get(*wi) else { continue };
2420 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2422 self.state.event(
2423 "implement",
2424 format!(
2425 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2426 work is in the tree",
2427 seat.key, dropped.output_tokens, dropped.why
2428 ),
2429 );
2430 continue;
2431 }
2432 if !has_context(&job.spec, seat, job.sessions) {
2440 self.state.event(
2441 "implement",
2442 format!(
2443 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2444 is no session left to resume",
2445 seat.key, dropped.output_tokens, dropped.why
2446 ),
2447 );
2448 continue;
2449 }
2450 self.state.event(
2451 "implement",
2452 format!(
2453 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2454 conversation",
2455 seat.key, dropped.output_tokens, dropped.why
2456 ),
2457 );
2458 let mut retry = job.clone();
2459 retry.seat = seat.clone();
2460 retry.prompt = prompt::resume_after_drop(&dropped.why);
2461 retry.timeout = retry_budget(job.timeout, true);
2462 retry.stem = format!("{}-resume", job.stem);
2463 let cache = self.state.config.cache_dir();
2464 let ctx = WaveCtx {
2465 carry_seats: false,
2466 pauses: Some((&self.pause, &self.interrupt)),
2467 run: run_id,
2468 node: "implement",
2469 prompts,
2470 cache: cache.as_deref(),
2471 round: None,
2472 };
2473 let (resumed_seat, resumed) =
2474 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2475 *seat = resumed_seat;
2476 *out = resumed;
2477 }
2478 }
2479
2480 async fn resume_seat_handovers(
2545 &mut self,
2546 results: &mut [(usize, SeatState, AgentOutcome)],
2547 sent: &mut [SeatJob],
2548 prompts: &Prompts,
2549 run_id: &str,
2550 ) {
2551 let instruction = seeded_instruction(&self.state);
2552 let language = self.state.config.graph.language.clone();
2553 let github_language = self.state.config.graph.github_language.clone();
2554 let brief = self
2555 .state
2556 .advice
2557 .as_ref()
2558 .and_then(|a| a.synthesis.as_deref())
2559 .map(str::to_owned);
2560 let attachments = self.state.attachments.clone();
2561 for (wi, seat, out) in results.iter_mut() {
2562 let others: BTreeSet<String> = sent
2565 .iter()
2566 .enumerate()
2567 .filter(|(j, _)| j != wi)
2568 .map(|(_, j)| j.spec.id.clone())
2569 .collect();
2570 let Some(job) = sent.get_mut(*wi) else {
2571 continue;
2572 };
2573 let start = self
2578 .roles
2579 .implementer_roster
2580 .iter()
2581 .position(|s| s.id == job.spec.id)
2582 .unwrap_or(0);
2583 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2584 let mut fallback_attempt = 0usize;
2585 let mut prev: Option<FailClass> = None;
2586 while let Some(cur) = FailClass::of(&*out) {
2587 if !should_hand_over(prev.as_ref(), &cur) {
2588 break;
2589 }
2590 let Some(next) =
2591 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2592 .cloned()
2593 else {
2594 break;
2595 };
2596 tried.insert(next.id.clone());
2597 fallback_attempt += 1;
2598
2599 if let Ok(r) = git::rescue_commit(
2600 &job.cwd,
2601 &format!(
2602 "magi: candidate {} (uncommitted work before {} fallback)",
2603 seat.key,
2604 if cur == FailClass::Quota {
2605 "quota"
2606 } else {
2607 "handover"
2608 }
2609 ),
2610 )
2611 .await
2612 {
2613 self.state.note_withheld("implement", &r.withheld);
2614 }
2615
2616 record_handover(
2617 &mut self.state,
2618 "implement",
2619 &seat.key,
2620 &seat.agent,
2621 &next.id,
2622 &cur,
2623 &fail_reason(&*out),
2624 );
2625 prev = Some(cur.clone());
2626
2627 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2628 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2629 job.spec = next.clone();
2637 let mut retry = job.clone();
2638 retry.seat = new_seat;
2639 retry.prompt = prompt::implement_in(
2640 &instruction,
2641 &job.cwd.to_string_lossy(),
2642 &language,
2643 &github_language,
2644 brief.as_deref(),
2645 &attachments,
2646 );
2647 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2648 let cache = self.state.config.cache_dir();
2649 let ctx = WaveCtx {
2650 carry_seats: false,
2651 pauses: Some((&self.pause, &self.interrupt)),
2652 run: run_id,
2653 node: "implement",
2654 prompts,
2655 cache: cache.as_deref(),
2656 round: None,
2657 };
2658 let (fallback_seat, fallback_out) = run_one(
2659 retry,
2660 Arc::clone(&self.sem),
2661 &ctx,
2662 &mut self.state,
2663 fallback_attempt,
2664 )
2665 .await;
2666 *seat = fallback_seat;
2667 *out = fallback_out;
2668 }
2669 }
2670 }
2671
2672 async fn resume_unconfirmed_commands(
2696 &mut self,
2697 results: &mut [(usize, SeatState, AgentOutcome)],
2698 sent: &[SeatJob],
2699 prompts: &Prompts,
2700 run_id: &str,
2701 ) {
2702 for (wi, seat, out) in results.iter_mut() {
2703 let AgentOutcome::Ok(o) = &*out else {
2704 continue;
2705 };
2706 if !has_unconfirmed_command(&o.commands) {
2707 continue;
2708 }
2709 let Some(job) = sent.get(*wi) else { continue };
2710 if !has_context(&job.spec, seat, job.sessions) {
2711 self.state.event(
2712 "implement",
2713 format!(
2714 "{}: the reply named a command whose own CLI never confirmed the exit \
2715 status of, but there is no session left to resume",
2716 seat.key
2717 ),
2718 );
2719 continue;
2720 }
2721 self.state.event(
2722 "implement",
2723 format!(
2724 "{}: the reply named a command whose own CLI never confirmed the exit \
2725 status of; resuming the conversation",
2726 seat.key
2727 ),
2728 );
2729 let mut retry = job.clone();
2730 retry.seat = seat.clone();
2731 retry.prompt = prompt::resume_incomplete(
2732 "a command in your last reply had no confirmed exit status",
2733 );
2734 retry.timeout = retry_budget(job.timeout, true);
2735 retry.stem = format!("{}-confirm", job.stem);
2736 let cache = self.state.config.cache_dir();
2737 let ctx = WaveCtx {
2738 carry_seats: false,
2739 pauses: Some((&self.pause, &self.interrupt)),
2740 run: run_id,
2741 node: "implement",
2742 prompts,
2743 cache: cache.as_deref(),
2744 round: None,
2745 };
2746 let (resumed_seat, resumed) =
2747 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2748 *seat = resumed_seat;
2749 *out = resumed;
2750 }
2751 }
2752
2753 async fn continue_fix_report(
2774 &mut self,
2775 mut seat: SeatState,
2776 parse_err: String,
2777 job: &SeatJob,
2778 prompts: &Prompts,
2779 run_id: &str,
2780 round: usize,
2781 ) -> (
2782 SeatState,
2783 Option<FixReport>,
2784 Option<String>,
2785 ContinuationRecord,
2786 ) {
2787 let mut last_err = parse_err;
2788 let mut cumulative_wait_ms = 0u64;
2789 let mut attempts = 0usize;
2790 loop {
2791 if !has_context(&job.spec, &seat, job.sessions) {
2792 self.state.event(
2793 "fix",
2794 format!(
2795 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2796 session left to resume into"
2797 ),
2798 );
2799 let outcome = if attempts == 0 {
2800 ContinuationOutcome::NoSession
2801 } else {
2802 ContinuationOutcome::Exhausted
2803 };
2804 return (
2805 seat,
2806 None,
2807 Some(format!("unparsable fix report: {last_err}")),
2808 ContinuationRecord {
2809 attempts,
2810 cumulative_wait_ms,
2811 outcome,
2812 },
2813 );
2814 }
2815 if attempts >= MAX_FIX_CONTINUATIONS {
2816 self.state.event(
2817 "fix",
2818 format!(
2819 "round {round}: fixer's reply still had no adoption report after \
2820 {attempts} continuation(s) ({last_err}); giving up"
2821 ),
2822 );
2823 return (
2824 seat,
2825 None,
2826 Some(format!(
2827 "unparsable fix report after {attempts} continuation(s): {last_err}"
2828 )),
2829 ContinuationRecord {
2830 attempts,
2831 cumulative_wait_ms,
2832 outcome: ContinuationOutcome::Exhausted,
2833 },
2834 );
2835 }
2836 attempts += 1;
2837 self.state.event(
2838 "fix",
2839 format!(
2840 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2841 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2842 ),
2843 );
2844 let mut retry = job.clone();
2845 retry.seat = seat.clone();
2846 retry.prompt = prompt::resume_incomplete(&last_err);
2847 retry.timeout = retry_budget(job.timeout, true);
2848 retry.stem = format!("{}-continue{attempts}", job.stem);
2849 let cache = self.state.config.cache_dir();
2850 let ctx = WaveCtx {
2851 carry_seats: false,
2852 pauses: Some((&self.pause, &self.interrupt)),
2853 run: run_id,
2854 node: "fix",
2855 prompts,
2856 cache: cache.as_deref(),
2857 round: Some(round),
2858 };
2859 let (resumed_seat, resumed_out) = run_one(
2860 retry,
2861 Arc::clone(&self.sem),
2862 &ctx,
2863 &mut self.state,
2864 attempts,
2865 )
2866 .await;
2867 seat = resumed_seat;
2868 match resumed_out {
2869 AgentOutcome::Ok(o) => {
2870 cumulative_wait_ms += o.duration_ms;
2871 match verdict::extract_json::<FixReport>(&o.text) {
2872 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2873 self.state.event(
2874 "fix",
2875 format!(
2876 "round {round}: fixer's adoption report recovered after \
2877 {attempts} continuation(s)"
2878 ),
2879 );
2880 return (
2881 seat,
2882 Some(report),
2883 None,
2884 ContinuationRecord {
2885 attempts,
2886 cumulative_wait_ms,
2887 outcome: ContinuationOutcome::Resumed,
2888 },
2889 );
2890 }
2891 Ok(_) => {
2899 last_err = "the reply parsed, but it reported a command whose own CLI \
2900 never confirmed an exit status"
2901 .to_owned();
2902 }
2903 Err(e) => last_err = e.to_string(),
2904 }
2905 }
2906 AgentOutcome::Quota(o) => {
2907 cumulative_wait_ms += o.duration_ms;
2908 self.state.quota.push(QuotaLoss {
2909 seat: seat.key.clone(),
2910 node: "fix".to_owned(),
2911 at: Timestamp::now(),
2912 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2913 });
2914 self.state.event(
2915 "fix",
2916 format!(
2917 "round {round}: continuation rate limited (quota); not retrying now"
2918 ),
2919 );
2920 return (
2921 seat,
2922 None,
2923 Some("rate limited (quota) while recovering the fix report".to_owned()),
2924 ContinuationRecord {
2925 attempts,
2926 cumulative_wait_ms,
2927 outcome: ContinuationOutcome::QuotaLost,
2928 },
2929 );
2930 }
2931 AgentOutcome::Dropped(o) => {
2932 cumulative_wait_ms += o.duration_ms;
2933 let why = o
2934 .dropped
2935 .as_ref()
2936 .map(|d| d.why.as_str())
2937 .unwrap_or("the CLI ended the stream without delivering its answer");
2938 last_err = format!("the CLI dropped the stream ({why})");
2939 }
2940 AgentOutcome::Failed(e) => last_err = e,
2941 AgentOutcome::ResourceBlocked(e) => {
2944 return (
2945 seat,
2946 None,
2947 Some(format!("the continuation was not started: {e}")),
2948 ContinuationRecord {
2949 attempts: attempts.saturating_sub(1),
2950 cumulative_wait_ms,
2951 outcome: ContinuationOutcome::NoSession,
2952 },
2953 );
2954 }
2955 }
2956 }
2957 }
2958
2959 fn after_implement(&mut self) -> Result<()> {
2960 if self.state.leaks.is_empty() {
2962 let cfg = self.state.config.blind.clone();
2963 let mut leaks = Vec::new();
2964 for c in &self.state.candidates {
2965 let Some(patch) =
2966 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2967 else {
2968 continue;
2969 };
2970 leaks.extend(blind::scan(
2971 &format!("candidate {} patch", c.label),
2972 &patch,
2973 &cfg.vendor_tokens,
2974 ));
2975 }
2976 if !leaks.is_empty() {
2977 let summary = leaks
2978 .iter()
2979 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2980 .collect::<Vec<_>>()
2981 .join(", ");
2982 match cfg.on_leak {
2983 LeakPolicy::Fail => {
2984 self.state.status = RunStatus::Failed;
2985 self.state
2986 .event("blind", format!("vendor text in a patch: {summary}"));
2987 self.state.leaks = leaks;
2988 self.state.save()?;
2989 self.settle_questions();
2990 bail!(
2991 "blind.on_leak = \"fail\" and vendor text reached a \
2992 judged patch: {summary}"
2993 );
2994 }
2995 LeakPolicy::Redact => self.state.event(
2996 "blind",
2997 format!("redacting vendor text for judging: {summary}"),
2998 ),
2999 LeakPolicy::Warn => self.state.event(
3000 "blind",
3001 format!("vendor text present in a judged patch (shown as-is): {summary}"),
3002 ),
3003 }
3004 self.state.leaks = leaks;
3005 }
3006 }
3007
3008 if self.state.viable().is_empty() {
3009 if self.state.all_candidates_verified_noop() {
3010 self.state.status = RunStatus::VerifiedNoop;
3021 self.state.save()?;
3022 self.settle_questions();
3023 return Ok(());
3024 }
3025 self.state.status = RunStatus::Failed;
3026 self.state.save()?;
3027 self.settle_questions();
3028 bail!("no candidate produced a change; nothing to judge");
3029 }
3030 self.state.status = RunStatus::Judging;
3031 self.state.save()?;
3032 Ok(())
3033 }
3034
3035 async fn judge(&mut self) -> Result<()> {
3038 let run_id = self.state.id.clone();
3043 let prompts = self.state.config.prompts.clone();
3044 if !self.state.judgements.is_empty() || self.state.judge_skipped {
3045 return Ok(());
3046 }
3047 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3048 if viable.len() == 1 {
3049 self.state.judge_skipped = true;
3056 self.state.event(
3057 "judge",
3058 format!(
3059 "only candidate {} produced a change; judging skipped",
3060 viable[0].label
3061 ),
3062 );
3063 self.state.save()?;
3064 return Ok(());
3065 }
3066 self.state.status = RunStatus::Judging;
3067
3068 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3069 let language = self.state.config.graph.language.clone();
3070 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3071 let sessions = self.state.config.graph.sessions;
3072 let artifacts = agent::artifacts_dir(&self.state.dir());
3073 let root = self.state.worktree_root();
3074 let base_short = short(&self.state.base_commit);
3075
3076 let mut jobs = Vec::new();
3077 let mut orders = Vec::new();
3078 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3079 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3080 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3081 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3082 let seat_key = format!("judge-{}", j + 1);
3083 let seat = self.seat(&seat_key, &spec.id);
3084 jobs.push(SeatJob {
3085 prompt: prompt::judge(
3086 &self.state.instruction,
3087 &views,
3088 self.roles.judges.len(),
3089 &base_short,
3090 &language,
3091 ),
3092 spec,
3093 seat,
3094 cwd: root.join(format!("judge-{}", j + 1)),
3095 timeout,
3096 allow_write: false,
3097 sessions,
3098 artifacts: artifacts.clone(),
3099 stem: format!("judge-{}", j + 1),
3100 handover: None,
3101 });
3102 }
3103
3104 self.state.event(
3105 "judge",
3106 format!(
3107 "{} judges ranking {} candidates blind",
3108 jobs.len(),
3109 viable.len()
3110 ),
3111 );
3112 let labels_for_check = labels.clone();
3113 let mut quota_losses = Vec::new();
3114 let cache = self.state.config.cache_dir();
3115 let ctx = WaveCtx {
3116 carry_seats: false,
3117 pauses: Some((&self.pause, &self.interrupt)),
3118 run: &run_id,
3119 node: "judge",
3120 prompts: &prompts,
3121 cache: cache.as_deref(),
3122 round: None,
3123 };
3124 let results = ask_json_wave::<Ranking>(
3125 jobs,
3126 Arc::clone(&self.sem),
3127 self.state.config.graph.retries,
3128 &self.roles.judge_roster,
3129 &ctx,
3130 &mut quota_losses,
3131 &mut self.state,
3132 &move |r: &Ranking| r.validate(&labels_for_check),
3133 )
3134 .await;
3135 if self.state.resource_block.is_some() {
3136 return self.stop_for_resource();
3137 }
3138 self.state.quota.extend(quota_losses);
3139
3140 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3141 let agent_id = seat.agent.clone();
3142 self.state.seats.insert(seat.key.clone(), seat);
3143 let mut record = Judgement {
3144 judge: j + 1,
3145 seat: format!("judge-{}", j + 1),
3146 agent: agent_id,
3147 ranking: Vec::new(),
3148 reasons: BTreeMap::new(),
3149 confidence: None,
3150 strengths: BTreeMap::new(),
3151 order: orders[j].clone(),
3152 failed: None,
3153 duration_ms: 0,
3154 };
3155 match res {
3156 Ok((ranking, out)) => {
3157 record.ranking = ranking.normalized();
3158 record.reasons = ranking.reasons;
3159 record.strengths = ranking.strengths;
3160 record.confidence = ranking.confidence;
3161 record.duration_ms = out.duration_ms;
3162 self.state.event(
3163 "judge",
3164 format!(
3165 "judge {} ranked {}",
3166 j + 1,
3167 record.ranking.iter().collect::<String>()
3168 ),
3169 );
3170 }
3171 Err(e) => {
3172 record.failed = Some(e.to_string());
3173 self.state
3174 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3175 }
3176 }
3177 self.state.judgements.push(record);
3178 self.state.save()?;
3179 }
3180 Ok(())
3181 }
3182
3183 async fn deliberate(&mut self) -> Result<()> {
3186 let run_id = self.state.id.clone();
3191 let prompts = self.state.config.prompts.clone();
3192 if !self.state.deliberation.is_empty() {
3193 return Ok(());
3194 }
3195 let tops: Vec<char> = self
3196 .state
3197 .judgements
3198 .iter()
3199 .filter_map(|j| j.ranking.first().copied())
3200 .collect();
3201 let rounds = self.state.config.graph.deliberate_rounds;
3202 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3203 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3204 self.state.event(
3205 "deliberate",
3206 format!("judges agreed on {} outright; no deliberation", tops[0]),
3207 );
3208 }
3209 self.state.status = RunStatus::Voting;
3210 self.state.save()?;
3211 return Ok(());
3212 }
3213
3214 self.state.status = RunStatus::Deliberating;
3215 self.state.event(
3216 "deliberate",
3217 format!(
3218 "split: first choices were {} — opening {rounds} round(s)",
3219 tops.iter().collect::<String>()
3220 ),
3221 );
3222
3223 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3224 let language = self.state.config.graph.language.clone();
3225 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3226 let sessions = self.state.config.graph.sessions;
3227 let artifacts = agent::artifacts_dir(&self.state.dir());
3228 let root = self.state.worktree_root();
3229 let base_short = short(&self.state.base_commit);
3230
3231 for round in 1..=rounds {
3235 let mut turns: Vec<DeliberationTurn> = Vec::new();
3236 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3237 if self.state.judgements[j].failed.is_some() {
3238 continue;
3239 }
3240 let seat_key = format!("judge-{}", j + 1);
3241 let spec = self.occupant(&seat_key, spec);
3242 let mut seat = self.seat(&seat_key, &spec.id);
3243 let transcript = self.transcript(&turns, j);
3244 let build = |context: Option<&str>| {
3245 prompt::deliberate(
3246 &self.state.instruction,
3247 context,
3248 &transcript,
3249 round,
3250 rounds,
3251 &language,
3252 )
3253 };
3254 let block = self.candidate_block(&viable, &base_short);
3255 let full = build(Some(&block));
3256 let text = if has_context(&spec, &seat, sessions) {
3257 build(None)
3258 } else {
3259 full.clone()
3260 };
3261 let job = SeatJob {
3262 spec,
3263 seat: seat.clone(),
3264 prompt: text,
3265 cwd: root.join(format!("judge-{}", j + 1)),
3266 timeout,
3267 allow_write: false,
3268 sessions,
3269 artifacts: artifacts.clone(),
3270 stem: format!("delib-{round}-judge-{}", j + 1),
3271 handover: Some(full),
3272 };
3273 let cache = self.state.config.cache_dir();
3274 let ctx = WaveCtx {
3275 carry_seats: false,
3276 pauses: Some((&self.pause, &self.interrupt)),
3277 run: &run_id,
3278 node: "deliberate",
3279 prompts: &prompts,
3280 cache: cache.as_deref(),
3281 round: None,
3282 };
3283 let mut losses = Vec::new();
3287 let mut results = ask_wave_with::<String>(
3288 vec![job],
3289 Arc::clone(&self.sem),
3290 0,
3291 &self.roles.judge_roster,
3292 &ctx,
3293 &mut losses,
3294 &mut self.state,
3295 &|text: &str| {
3296 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3297 },
3298 )
3299 .await;
3300 self.state.quota.extend(losses);
3301 let (updated, res, _) = results.pop().expect("one job in, one result out");
3302 seat = updated;
3303 let agent_id = seat.agent.clone();
3304 self.state.seats.insert(seat.key.clone(), seat);
3305 let body = match res {
3306 Ok((body, _)) => body,
3307 Err(e) => {
3310 self.state
3311 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3312 continue;
3313 }
3314 };
3315 let tentative = verdict::extract_json::<Position>(&body)
3316 .ok()
3317 .and_then(|p| p.tentative)
3318 .and_then(|s| s.trim().chars().next())
3319 .map(|c| c.to_ascii_uppercase());
3320 self.state.event(
3321 "deliberate",
3322 format!(
3323 "round {round}: judge {} now favours {}",
3324 j + 1,
3325 tentative.map_or("—".to_owned(), |c| c.to_string())
3326 ),
3327 );
3328 turns.push(DeliberationTurn {
3329 judge: j + 1,
3330 agent: agent_id,
3331 body: blind::sanitize_prose(&body, &self.state.config.blind),
3332 tentative,
3333 });
3334 }
3335 self.state
3336 .deliberation
3337 .push(DeliberationRound { round, turns });
3338 self.state.save()?;
3339 }
3340
3341 self.state.status = RunStatus::Voting;
3342 self.state.save()?;
3343 Ok(())
3344 }
3345
3346 async fn vote(&mut self) -> Result<()> {
3349 let run_id = self.state.id.clone();
3354 let prompts = self.state.config.prompts.clone();
3355 if !self.state.votes.is_empty() {
3356 return Ok(());
3357 }
3358 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3359 if viable.len() == 1 {
3360 return Ok(());
3361 }
3362 self.state.status = RunStatus::Voting;
3363
3364 let language = self.state.config.graph.language.clone();
3365 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3366 let sessions = self.state.config.graph.sessions;
3367 let artifacts = agent::artifacts_dir(&self.state.dir());
3368 let root = self.state.worktree_root();
3369 let base_short = short(&self.state.base_commit);
3370 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3371
3372 let mut jobs = Vec::new();
3373 let mut seats_at = Vec::new();
3374 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3375 if self
3376 .state
3377 .judgements
3378 .get(j)
3379 .is_some_and(|r| r.failed.is_some())
3380 {
3381 continue;
3382 }
3383 let seat_key = format!("judge-{}", j + 1);
3384 let spec = self.occupant(&seat_key, spec);
3385 let seat = self.seat(&seat_key, &spec.id);
3386 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3387 let text = if has_context(&spec, &seat, sessions) {
3388 prompt::final_vote(&viable, &language)
3389 } else {
3390 full.clone()
3391 };
3392 jobs.push(SeatJob {
3393 spec,
3394 seat,
3395 prompt: text,
3396 cwd: root.join(format!("judge-{}", j + 1)),
3397 timeout,
3398 allow_write: false,
3399 sessions,
3400 artifacts: artifacts.clone(),
3401 stem: format!("vote-judge-{}", j + 1),
3402 handover: Some(full),
3403 });
3404 seats_at.push(j);
3405 }
3406
3407 self.state.event(
3408 "vote",
3409 format!(
3410 "collecting {} final votes one by one, privately",
3411 jobs.len()
3412 ),
3413 );
3414 let allowed = viable.clone();
3415 let mut quota_losses = Vec::new();
3416 let cache = self.state.config.cache_dir();
3417 let ctx = WaveCtx {
3418 carry_seats: false,
3419 pauses: Some((&self.pause, &self.interrupt)),
3420 run: &run_id,
3421 node: "vote",
3422 prompts: &prompts,
3423 cache: cache.as_deref(),
3424 round: None,
3425 };
3426 let results = ask_json_wave::<FinalVote>(
3427 jobs,
3428 Arc::clone(&self.sem),
3429 self.state.config.graph.retries,
3430 &self.roles.judge_roster,
3431 &ctx,
3432 &mut quota_losses,
3433 &mut self.state,
3434 &move |v: &FinalVote| match v.label() {
3435 Some(c) if allowed.contains(&c) => Ok(()),
3436 other => bail!("vote {other:?} is not one of {allowed:?}"),
3437 },
3438 )
3439 .await;
3440 if self.state.resource_block.is_some() {
3441 return self.stop_for_resource();
3442 }
3443 self.state.quota.extend(quota_losses);
3444
3445 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3446 let agent_id = seat.agent.clone();
3447 self.state.seats.insert(seat.key.clone(), seat);
3448 let initial = self
3449 .state
3450 .judgements
3451 .get(j)
3452 .and_then(|r| r.ranking.first().copied());
3453 let mut record = VoteRecord {
3454 judge: j + 1,
3455 agent: agent_id,
3456 vote: None,
3457 reason: String::new(),
3458 changed: false,
3459 };
3460 match res {
3461 Ok((v, _)) => {
3462 record.vote = v.label();
3463 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3464 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3465 self.state.event(
3466 "vote",
3467 format!(
3468 "judge {} voted {}{}",
3469 j + 1,
3470 record.vote.unwrap_or('?'),
3471 if record.changed { " (changed)" } else { "" }
3472 ),
3473 );
3474 }
3475 Err(e) => {
3476 self.state
3477 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3478 }
3479 }
3480 self.state.votes.push(record);
3481 self.state.save()?;
3482 }
3483 Ok(())
3484 }
3485
3486 fn tally(&mut self) -> Result<()> {
3489 if self.state.tally.is_some() {
3490 return Ok(());
3491 }
3492 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3493 let tops: Vec<char> = self
3494 .state
3495 .judgements
3496 .iter()
3497 .filter_map(|j| j.ranking.first().copied())
3498 .collect();
3499 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3500
3501 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3504 let mut cast: Vec<char> = Vec::new();
3505 for (i, j) in self.state.judgements.iter().enumerate() {
3506 let vote = self
3507 .state
3508 .votes
3509 .iter()
3510 .find(|v| v.judge == i + 1)
3511 .and_then(|v| v.vote)
3512 .or_else(|| j.ranking.first().copied());
3513 if let Some(v) = vote {
3514 *first_choice.entry(v).or_insert(0) += 1;
3515 cast.push(v);
3516 }
3517 }
3518
3519 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3520 for j in &self.state.judgements {
3521 let n = j.ranking.len();
3522 for (pos, label) in j.ranking.iter().enumerate() {
3523 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3524 }
3525 }
3526
3527 let best = first_choice.values().copied().max().unwrap_or(0);
3528 let mut leaders: Vec<char> = first_choice
3529 .iter()
3530 .filter(|(_, v)| **v == best)
3531 .map(|(k, _)| *k)
3532 .collect();
3533 let mut tie_break = None;
3534 if leaders.len() > 1 {
3535 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3536 let borda_leaders: Vec<char> = leaders
3537 .iter()
3538 .copied()
3539 .filter(|l| borda[l] == top_borda)
3540 .collect();
3541 tie_break = Some(if borda_leaders.len() == 1 {
3542 format!(
3543 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3544 leaders.len()
3545 )
3546 } else {
3547 format!(
3548 "{} way tie on both first-choice votes and Borda points, broken by label order",
3549 leaders.len()
3550 )
3551 });
3552 leaders = borda_leaders;
3553 leaders.sort_unstable();
3554 }
3555 let winner = *leaders
3556 .first()
3557 .or(viable.first())
3558 .context("no candidate to declare a winner from")?;
3559
3560 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3561 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3562 let deliberated = !self.state.deliberation.is_empty();
3563
3564 let quota_seats: std::collections::BTreeSet<&str> =
3568 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3569 let mut present = 0usize;
3570 for (i, j) in self.state.judgements.iter().enumerate() {
3571 if quota_seats.contains(j.seat.as_str()) {
3572 continue;
3573 }
3574 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3575 let voted = self
3576 .state
3577 .votes
3578 .iter()
3579 .any(|v| v.judge == i + 1 && v.vote.is_some());
3580 if ranked || voted {
3581 present += 1;
3582 }
3583 }
3584 let needs_quorum = viable.len() > 1;
3590 let judges_total = if needs_quorum {
3591 self.roles.judges.len()
3592 } else {
3593 0
3594 };
3595 let quorum = if needs_quorum {
3596 judges_total / 2 + 1
3597 } else {
3598 0
3599 };
3600 let met_quorum = !needs_quorum || present >= quorum;
3601 let uncontested = (!needs_quorum).then(|| {
3602 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3603 });
3604
3605 self.state.event(
3606 "tally",
3607 match &uncontested {
3608 Some(reason) => format!("winner {winner} — {reason}"),
3609 None => format!(
3610 "winner {winner} — votes {} | initial {} | {} changed | \
3611 {present}/{judges_total} judges{}",
3612 first_choice
3613 .iter()
3614 .map(|(k, v)| format!("{k}:{v}"))
3615 .collect::<Vec<_>>()
3616 .join(" "),
3617 if unanimous_initial {
3618 "unanimous"
3619 } else {
3620 "split"
3621 },
3622 changed_votes,
3623 if met_quorum {
3624 String::new()
3625 } else {
3626 format!(" — below quorum ({quorum} required)")
3627 },
3628 ),
3629 },
3630 );
3631 if !met_quorum {
3632 self.state.event(
3633 "stall",
3634 format!(
3635 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3636 the run stops here, resumable"
3637 ),
3638 );
3639 }
3640 self.state.tally = Some(Tally {
3641 first_choice,
3642 borda,
3643 winner,
3644 rankings: tops.len(),
3645 unanimous_initial,
3646 deliberated,
3647 changed_votes,
3648 unanimous_final,
3649 tie_break,
3650 judges: judges_total,
3651 present,
3652 quorum,
3653 met_quorum,
3654 uncontested,
3655 });
3656 self.state.status = if met_quorum {
3657 RunStatus::Reviewing
3658 } else {
3659 RunStatus::Stalled
3660 };
3661 self.state.save()?;
3662 Ok(())
3663 }
3664
3665 #[allow(clippy::too_many_lines)]
3686 async fn recover_stall(&mut self) -> Result<bool> {
3687 let run_id = self.state.id.clone();
3692 let prompts = self.state.config.prompts.clone();
3693 let quota_seats: BTreeSet<&str> =
3698 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3699 let absent: Vec<String> = self
3700 .state
3701 .judgements
3702 .iter()
3703 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3704 .map(|j| j.seat.clone())
3705 .collect();
3706 if absent.is_empty() {
3707 return Ok(false);
3708 }
3709 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3710 if viable.len() <= 1 {
3711 return Ok(false);
3712 }
3713 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3714 let language = self.state.config.graph.language.clone();
3715 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3716 let sessions = self.state.config.graph.sessions;
3717 let artifacts = agent::artifacts_dir(&self.state.dir());
3718 let root = self.state.worktree_root();
3719 let base_short = short(&self.state.base_commit);
3720 let candidates: Vec<Candidate> = viable.clone();
3721
3722 let mut positions: Vec<usize> = absent
3724 .iter()
3725 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3726 .collect();
3727 if positions.is_empty() {
3728 return Ok(false);
3729 }
3730 positions.sort_unstable();
3731 positions.dedup();
3732
3733 let mut judge_jobs = Vec::new();
3735 for &j in &positions {
3736 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3737 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3738 let seat_key = format!("judge-{}", j + 1);
3739 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3740 let seat = self.seat(&seat_key, &spec.id);
3741 judge_jobs.push(SeatJob {
3742 spec,
3743 seat,
3744 prompt: prompt::judge(
3745 &self.state.instruction,
3746 &views,
3747 self.roles.judges.len(),
3748 &base_short,
3749 &language,
3750 ),
3751 cwd: root.join(seat_key),
3752 timeout,
3753 allow_write: false,
3754 sessions,
3755 artifacts: artifacts.clone(),
3756 stem: format!("judge-{}-recover", j + 1),
3757 handover: None,
3758 });
3759 }
3760
3761 let labels_for_check = labels.clone();
3762 let mut judge_losses = Vec::new();
3763 let retries = self.state.config.graph.retries;
3764 let cache = self.state.config.cache_dir();
3765 let ctx = WaveCtx {
3766 carry_seats: false,
3767 pauses: Some((&self.pause, &self.interrupt)),
3768 run: &run_id,
3769 node: "judge",
3770 prompts: &prompts,
3771 cache: cache.as_deref(),
3772 round: None,
3773 };
3774 let results = ask_json_wave::<Ranking>(
3775 judge_jobs,
3776 Arc::clone(&self.sem),
3777 retries,
3778 &self.roles.judge_roster,
3779 &ctx,
3780 &mut judge_losses,
3781 &mut self.state,
3782 &move |r: &Ranking| r.validate(&labels_for_check),
3783 )
3784 .await;
3785 if self.state.resource_block.is_some() {
3786 self.stop_for_resource()?;
3787 return Ok(false);
3788 }
3789
3790 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3792 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3793 let agent_id = seat.agent.clone();
3794 self.state.seats.insert(seat.key.clone(), seat);
3795 let record = &mut self.state.judgements[j];
3796 match res {
3797 Ok((ranking, out)) => {
3798 record.agent = agent_id;
3799 record.ranking = ranking.normalized();
3800 record.reasons = ranking.reasons;
3801 record.strengths = ranking.strengths;
3802 record.confidence = ranking.confidence;
3803 record.failed = None;
3804 record.duration_ms = out.duration_ms;
3805 recovered.insert(j);
3806 self.state.event(
3807 "recover",
3808 format!("judge {} ranked again after the limit", j + 1),
3809 );
3810 }
3811 Err(e) => {
3812 self.state
3813 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3814 }
3815 }
3816 }
3817
3818 let mut vote_jobs = Vec::new();
3820 let mut vote_pos: Vec<usize> = Vec::new();
3821 for &j in &recovered {
3822 let seat_key = format!("judge-{}", j + 1);
3823 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3824 let seat = self.seat(&seat_key, &spec.id);
3825 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3826 let text = if has_context(&spec, &seat, sessions) {
3827 prompt::final_vote(&labels, &language)
3828 } else {
3829 full.clone()
3830 };
3831 vote_jobs.push(SeatJob {
3832 spec,
3833 seat,
3834 prompt: text,
3835 cwd: root.join(seat_key),
3836 timeout,
3837 allow_write: false,
3838 sessions,
3839 artifacts: artifacts.clone(),
3840 stem: format!("vote-judge-{}-recover", j + 1),
3841 handover: Some(full),
3842 });
3843 vote_pos.push(j);
3844 }
3845 let allowed = labels.clone();
3846 let mut vote_losses = Vec::new();
3847 let vote_retries = self.state.config.graph.retries;
3848 let vote_cache = self.state.config.cache_dir();
3849 let ctx = WaveCtx {
3850 carry_seats: false,
3851 pauses: Some((&self.pause, &self.interrupt)),
3852 run: &run_id,
3853 node: "vote",
3854 prompts: &prompts,
3855 cache: vote_cache.as_deref(),
3856 round: None,
3857 };
3858 let votes = ask_json_wave::<FinalVote>(
3859 vote_jobs,
3860 Arc::clone(&self.sem),
3861 vote_retries,
3862 &self.roles.judge_roster,
3863 &ctx,
3864 &mut vote_losses,
3865 &mut self.state,
3866 &move |v: &FinalVote| match v.label() {
3867 Some(c) if allowed.contains(&c) => Ok(()),
3868 other => bail!("vote {other:?} is not one of {allowed:?}"),
3869 },
3870 )
3871 .await;
3872 if self.state.resource_block.is_some() {
3873 self.stop_for_resource()?;
3874 return Ok(false);
3875 }
3876 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3877 let agent_id = seat.agent.clone();
3878 self.state.seats.insert(seat.key.clone(), seat);
3879 match res {
3880 Ok((v, _)) => {
3881 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3882 rec.vote = v.label();
3883 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3884 } else {
3885 self.state.votes.push(VoteRecord {
3886 judge: j + 1,
3887 agent: agent_id,
3888 vote: v.label(),
3889 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3890 changed: false,
3891 });
3892 }
3893 self.state.event(
3894 "recover",
3895 format!("judge {} voted again after the limit", j + 1),
3896 );
3897 }
3898 Err(e) => {
3899 self.state
3900 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3901 }
3902 }
3903 }
3904
3905 let recovered_keys: BTreeSet<String> = recovered
3909 .iter()
3910 .map(|&j| format!("judge-{}", j + 1))
3911 .collect();
3912 self.state
3913 .quota
3914 .retain(|q| !recovered_keys.contains(&q.seat));
3915 for loss in judge_losses.into_iter().chain(vote_losses) {
3919 if recovered_keys.contains(&loss.seat) {
3920 continue;
3921 }
3922 self.state.quota.retain(|q| q.seat != loss.seat);
3923 self.state.quota.push(loss);
3924 }
3925
3926 self.state.tally = None;
3928 self.tally()?;
3929 Ok(self
3930 .state
3931 .tally
3932 .as_ref()
3933 .map(|t| t.met_quorum)
3934 .unwrap_or(false))
3935 }
3936
3937 async fn graft(&mut self) -> Result<()> {
3954 let Err(e) = self.graft_inner().await else {
3955 return Ok(());
3956 };
3957 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3958 self.state.status = RunStatus::Blocked;
3959 self.state.event("graft", format!("blocked: {e:#}"));
3960 self.state.save()?;
3961 return Err(e);
3962 }
3963 if let Some(rec) = self.state.graft.clone()
3968 && !rec.outcome.settled()
3969 {
3970 if let Some(winner) = self.state.winner().cloned() {
3971 if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3972 self.state.status = RunStatus::Blocked;
3973 self.state.event("graft", format!("blocked: {re:#}"));
3974 self.state.save()?;
3975 return Err(re);
3976 }
3977 }
3978 if let Some(g) = self.state.graft.as_mut() {
3979 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3980 }
3981 }
3982 self.state.event("graft", format!("skipped: {e:#}"));
3983 if let Err(e) = self.state.save() {
3984 tracing::warn!("save after graft: {e:#}");
3985 }
3986 Ok(())
3987 }
3988
3989 async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3992 crate::recovery::restore_repair(wt, pre)
3993 .await
3994 .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
3995 }
3996
3997 fn graft_skipped(&mut self, why: &str) {
3999 let message = format!("skipped: {why}");
4000 if !self
4001 .state
4002 .events
4003 .iter()
4004 .any(|e| e.node == "graft" && e.message == message)
4005 {
4006 self.state.event("graft", message);
4007 }
4008 }
4009
4010 async fn graft_inner(&mut self) -> Result<()> {
4011 if let Some(rec) = self.state.graft.clone()
4014 && !rec.outcome.settled()
4015 {
4016 if self.state.reviews.is_empty()
4020 && let Some(winner) = self.state.winner().cloned()
4021 {
4022 Self::graft_restore(&winner.worktree, &rec.pre).await?;
4023 }
4024 if let Some(g) = self.state.graft.as_mut() {
4025 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
4026 }
4027 self.state.event(
4028 "graft",
4029 "rolled back: the previous round was interrupted; the winner is untouched",
4030 );
4031 self.state.save()?;
4032 return Ok(());
4033 }
4034 let plan = match graft::plan(&self.state) {
4035 Ok(plan) => plan,
4036 Err(skip) => {
4037 if skip != graft::Skip::AlreadyDone {
4038 self.graft_skipped(skip.reason());
4039 self.state.save()?;
4040 }
4041 return Ok(());
4042 }
4043 };
4044 let Some(winner) = self.state.winner().cloned() else {
4045 return Ok(());
4046 };
4047 let wt = winner.worktree.clone();
4048 if !git::is_clean(&wt).await? {
4049 self.graft_skipped("the winner's worktree has uncommitted changes");
4050 self.state.save()?;
4051 return Ok(());
4052 }
4053 let pre = git::rev_parse(&wt, "HEAD").await?;
4054 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
4055 else {
4056 self.graft_skipped("the winner's diff could not be measured");
4057 self.state.save()?;
4058 return Ok(());
4059 };
4060 let limit = graft::limit(winner_lines);
4061 self.state.graft = Some(GraftRecord {
4064 pre: pre.clone(),
4065 post: None,
4066 lines: None,
4067 limit: Some(limit),
4068 agent: None,
4069 outcome: GraftOutcome::Running,
4070 requested: graft::requested(&plan),
4071 adoption: None,
4072 adoption_error: None,
4073 });
4074 self.state.event(
4075 "graft",
4076 format!(
4077 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4078 plan.strengths
4079 .iter()
4080 .map(|(l, _, _)| l.to_string())
4081 .collect::<Vec<_>>()
4082 .join(", "),
4083 plan.winner
4084 ),
4085 );
4086 self.state.save()?;
4087
4088 let fix_prompt = prompt::graft(
4089 &self.state.instruction,
4090 plan.winner,
4091 &plan.strengths,
4092 limit,
4093 &self.state.config.graph.language,
4094 );
4095 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4096 let sessions = self.state.config.graph.sessions;
4097 let artifacts = agent::artifacts_dir(&self.state.dir());
4098 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4099 let (_, seat, out) = self
4100 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4101 SeatJob {
4102 prompt: fix_prompt.clone(),
4103 spec,
4104 seat,
4105 cwd: wt.clone(),
4106 timeout,
4107 allow_write: true,
4108 sessions,
4109 artifacts: artifacts.clone(),
4110 stem: "graft".to_owned(),
4111 handover: None,
4112 }
4113 })
4114 .await;
4115 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4120 Self::graft_restore(&wt, &pre).await?;
4121 self.state.graft = None;
4122 self.state.save()?;
4123 return self.stop_for_resource();
4124 }
4125 let mut why: Option<String> = None;
4126 match &out {
4127 AgentOutcome::Ok(o) if agent::output_advances(o) => {
4128 why = Some("the fixer produced nothing usable".to_owned());
4129 }
4130 AgentOutcome::Ok(_) => {}
4131 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4132 AgentOutcome::Quota(o) => {
4133 self.state.quota.push(QuotaLoss {
4134 seat: seat.key.clone(),
4135 node: "graft".to_owned(),
4136 at: Timestamp::now(),
4137 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4138 });
4139 why = Some("rate limited (quota)".to_owned());
4140 }
4141 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4142 why = Some(format!(
4143 "the fixer failed: {}",
4144 e.lines()
4145 .next()
4146 .unwrap_or("")
4147 .chars()
4148 .take(160)
4149 .collect::<String>()
4150 ));
4151 }
4152 }
4153 let agent_id = seat.agent.clone();
4154 self.state.seats.insert(seat.key.clone(), seat);
4155
4156 let mut post = None;
4157 let mut lines = None;
4158 if why.is_none() {
4159 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4160 if let Ok(r) = &rescued {
4161 self.state.note_withheld("graft", &r.withheld);
4162 }
4163 let head = git::rev_parse(&wt, "HEAD").await?;
4164 let dirty = match &rescued {
4168 Ok(r) => git::status_porcelain(&wt)
4169 .await
4170 .map(|st| {
4171 st.lines().any(|l| {
4172 !r.withheld
4173 .iter()
4174 .any(|w| l.trim_end().ends_with(w.path.as_str()))
4175 })
4176 })
4177 .unwrap_or(true),
4178 Err(_) => true,
4179 };
4180 if dirty {
4181 why = Some("the fixer left changes that could not be committed".to_owned());
4182 } else if head == pre {
4183 why = Some("the fixer changed nothing".to_owned());
4184 } else {
4185 lines = git::changed_lines(&wt, &pre, &head).await;
4186 if graft::within_budget(lines, limit) {
4187 post = Some(head);
4188 } else {
4189 why = Some(format!(
4190 "{} changed lines against a limit of {limit}",
4191 lines.map_or_else(
4192 || "an unmeasurable number of".to_owned(),
4193 |l| l.to_string()
4194 )
4195 ));
4196 }
4197 }
4198 }
4199 let (adoption, adoption_error) = match &out {
4200 AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4201 _ => (None, None),
4202 };
4203 let requested = graft::requested(&plan);
4204 let outcome = match why {
4205 None => {
4206 self.state.event(
4207 "graft",
4208 format!(
4209 "applied: {} changed lines (limit {limit})",
4210 lines.unwrap_or(0)
4211 ),
4212 );
4213 GraftOutcome::Applied
4214 }
4215 Some(why) => {
4216 Self::graft_restore(&wt, &pre).await?;
4219 self.state.event(
4220 "graft",
4221 format!("rolled back: {why}; the winner is untouched"),
4222 );
4223 GraftOutcome::RolledBack(why)
4224 }
4225 };
4226 self.state.graft = Some(GraftRecord {
4227 pre,
4228 post,
4229 lines,
4230 limit: Some(limit),
4231 agent: Some(agent_id),
4232 outcome,
4233 requested,
4234 adoption,
4235 adoption_error,
4236 });
4237 self.state.save()?;
4238 Ok(())
4239 }
4240
4241 async fn fold_losers(&mut self) -> Result<()> {
4244 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4245 return Ok(());
4246 };
4247 let repo = self.state.repo.clone();
4248 let mut folded = Vec::new();
4249 for i in 0..self.state.candidates.len() {
4250 let c = &self.state.candidates[i];
4251 if c.label == winner || c.folded {
4252 continue;
4253 }
4254 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4255 git::worktree_remove(&repo, &wt).await.ok();
4256 git::branch_delete(&repo, &branch).await.ok();
4257 self.state.candidates[i].folded = true;
4258 folded.push(label.to_string());
4259 }
4260 let root = self.state.worktree_root();
4262 for j in 1..=self.roles.judges.len() {
4263 let wt = root.join(format!("judge-{j}"));
4264 if wt.exists() {
4265 git::worktree_remove(&repo, &wt).await.ok();
4266 }
4267 }
4268 if self.state.config.graph.advise {
4271 for k in 1..=self.state.config.graph.advisors {
4272 let wt = root.join(format!("advisor-{k}"));
4273 if wt.exists() {
4274 git::worktree_remove(&repo, &wt).await.ok();
4275 }
4276 }
4277 }
4278 if !folded.is_empty() {
4279 self.state
4280 .event("fold", format!("folded candidates {}", folded.join(", ")));
4281 self.state.save()?;
4282 }
4283 Ok(())
4284 }
4285
4286 async fn sync_to_base(&mut self) -> Result<()> {
4326 if self.state.status == RunStatus::AlreadyInBase {
4327 return Ok(());
4328 }
4329 let conflicted = self
4330 .state
4331 .base_sync
4332 .as_ref()
4333 .is_some_and(|s| s.conflict.is_some());
4334 let Some(winner) = self.state.winner().cloned() else {
4335 return Ok(());
4336 };
4337
4338 let repo = self.state.repo.clone();
4339 let remote = self.state.config.merge.remote.clone();
4340 let base_branch = self.state.base_branch.clone();
4341 let tracking = format!("{remote}/{base_branch}");
4342
4343 git::fetch(&repo, &remote, &base_branch).await.ok();
4344 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4349 return Ok(());
4350 };
4351
4352 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4353 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4354 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4355
4356 if (behind > 0 || conflicted || head == tip)
4365 && self
4366 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4367 .await?
4368 {
4369 return Ok(());
4370 }
4371 if conflicted {
4372 return Ok(());
4373 }
4374
4375 if behind == 0 {
4376 if let Some(from) = self
4385 .state
4386 .rebase_fixes
4387 .iter()
4388 .rev()
4389 .find_map(|r| r.from.clone())
4390 && from != head
4391 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4392 .await
4393 .is_ok_and(|o| o.ok())
4394 {
4395 git::sync_to_head(&winner.worktree).await?;
4396 }
4397 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4403 if let Some(why) = &conflict {
4404 self.state.status = RunStatus::Blocked;
4405 self.state.event("land", why.clone());
4406 }
4407 self.state.base_sync = Some(BaseSync {
4408 tip,
4409 behind: 0,
4410 attempts,
4411 conflict,
4412 already_in: None,
4413 });
4414 self.state.save()?;
4415 return Ok(());
4416 }
4417
4418 if attempts >= BASE_SYNC_ROUNDS {
4419 let why = format!(
4420 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4421 rebase(s); rebasing again would only race it",
4422 winner.branch
4423 );
4424 self.state.status = RunStatus::Blocked;
4425 self.state.base_sync = Some(BaseSync {
4426 tip,
4427 behind,
4428 attempts,
4429 conflict: Some(why.clone()),
4430 already_in: None,
4431 });
4432 self.state.event("land", why);
4433 self.state.save()?;
4434 return Ok(());
4435 }
4436
4437 self.state.event(
4438 "land",
4439 format!(
4440 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4441 winner.branch
4442 ),
4443 );
4444 self.state.save()?;
4445
4446 let branch_tracking = format!("{remote}/{}", winner.branch);
4452 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4453 let remote_tip = if self.state.task_pr_branch().is_none()
4454 && matches!(&fetched_branch, Ok(o) if o.ok())
4455 {
4456 git::rev_parse(&repo, &branch_tracking).await.ok()
4457 } else {
4458 None
4459 };
4460 if let Some(theirs) = &remote_tip
4464 && !git::is_ancestor(&repo, theirs, &head).await
4465 && !crate::reconcile::origin_missing(&repo, &head, theirs)
4466 .await
4467 .is_ok_and(|missing| missing.is_empty())
4468 {
4469 let why = format!(
4470 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4471 them",
4472 short(theirs),
4473 winner.branch
4474 );
4475 self.state.status = RunStatus::Blocked;
4476 self.state.base_sync = Some(BaseSync {
4477 tip,
4478 behind,
4479 attempts,
4480 conflict: Some(why.clone()),
4481 already_in: None,
4482 });
4483 self.state.event("land", why);
4484 self.state.save()?;
4485 return Ok(());
4486 }
4487
4488 let scratch = self.state.dir().join("base-sync");
4489 let rebased = match crate::rebase::rebase_with_fixer(
4490 &mut self.state,
4491 &scratch,
4492 &winner.branch,
4493 &tracking,
4494 )
4495 .await
4496 {
4497 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4498 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4499 Err(e) => Err(e),
4500 };
4501 let attempts = attempts + 1;
4502 match rebased {
4503 Ok(None) => {
4504 git::sync_to_head(&winner.worktree).await?;
4508 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4509 let mut conflict = None;
4510 if let Some(pinned) = &remote_tip {
4511 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4512 match pushed {
4513 Ok(o) if o.ok() => self.state.event(
4514 "land",
4515 format!("pushed rebased {} to {remote}", winner.branch),
4516 ),
4517 Ok(o) => {
4518 conflict = Some(format!(
4519 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4520 winner.branch,
4521 short(pinned),
4522 o.stderr.chars().take(600).collect::<String>()
4523 ));
4524 }
4525 Err(e) => {
4526 conflict = Some(format!(
4527 "rebased {} locally but could not push it: {e:#}",
4528 winner.branch
4529 ));
4530 }
4531 }
4532 }
4533 if let Some(why) = &conflict {
4534 self.state.status = RunStatus::Blocked;
4535 self.state.event("land", why.clone());
4536 }
4537 self.state.base_sync = Some(BaseSync {
4538 tip: tip.clone(),
4539 behind: 0,
4540 attempts,
4541 conflict,
4542 already_in: None,
4543 });
4544 self.state
4545 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4546 }
4547 Ok(Some(conflict)) => {
4548 let why = format!(
4549 "{} conflicts with {tracking} and did not rebase: {}",
4550 winner.branch,
4551 conflict.chars().take(600).collect::<String>()
4552 );
4553 self.state.status = RunStatus::Blocked;
4554 self.state.base_sync = Some(BaseSync {
4555 tip,
4556 behind,
4557 attempts,
4558 conflict: Some(why.clone()),
4559 already_in: None,
4560 });
4561 self.state.event("land", why);
4562 }
4563 Err(e) => {
4564 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4565 self.state.status = RunStatus::Blocked;
4566 self.state.base_sync = Some(BaseSync {
4567 tip,
4568 behind,
4569 attempts,
4570 conflict: Some(why.clone()),
4571 already_in: None,
4572 });
4573 self.state.event("land", why);
4574 }
4575 }
4576 self.state.save()?;
4577 Ok(())
4578 }
4579
4580 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4586 if self.state.task_pr_branch().is_some() {
4589 return None;
4590 }
4591 let repo = self.state.repo.clone();
4592 let remote = self.state.config.merge.remote.clone();
4593 let fetched = git::fetch(&repo, &remote, branch).await;
4594 if !matches!(&fetched, Ok(o) if o.ok()) {
4595 return None;
4596 }
4597 let branch_tracking = format!("{remote}/{branch}");
4598 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4599 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4600 return None;
4601 }
4602 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4603 .await
4604 .is_ok_and(|missing| missing.is_empty())
4605 {
4606 return Some(format!(
4607 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4608 them",
4609 short(&theirs)
4610 ));
4611 }
4612 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4613 Ok(o) if o.ok() => {
4614 self.state
4615 .event("land", format!("pushed rebased {branch} to {remote}"));
4616 None
4617 }
4618 Ok(o) => Some(format!(
4619 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4620 someone may have pushed): {}",
4621 short(&theirs),
4622 o.stderr.chars().take(600).collect::<String>()
4623 )),
4624 Err(e) => Some(format!(
4625 "{branch} is rebased locally but could not be pushed: {e:#}"
4626 )),
4627 }
4628 }
4629
4630 async fn settle_already_in(
4647 &mut self,
4648 branch: &str,
4649 tip: &str,
4650 head: &str,
4651 attempts: usize,
4652 behind: usize,
4653 ) -> Result<bool> {
4654 let repo = self.state.repo.clone();
4655 let remote = self.state.config.merge.remote.clone();
4656 let start = self.state.base_commit.clone();
4657 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4658 Ok(Some(e)) => e,
4659 Ok(None) => return Ok(false),
4660 Err(e) => {
4661 tracing::warn!("already-in-base check for {branch}: {e:#}");
4662 return Ok(false);
4663 }
4664 };
4665 let mut verified = vec![head.to_owned()];
4666 let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4667 let fetched = git::fetch(&repo, &remote, &public_branch).await;
4668 if matches!(&fetched, Ok(o) if o.ok())
4669 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4670 && theirs != head
4671 {
4672 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4673 Ok(Some(_)) => verified.push(theirs),
4674 _ => return Ok(false),
4675 }
4676 }
4677 let base_branch = self.state.base_branch.clone();
4678 let message = format!(
4679 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4680 land",
4681 evidence.names(),
4682 evidence.proof.as_str()
4683 );
4684 let closed =
4685 crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4686 .await;
4687 match closed {
4688 Ok(Ok(url)) => self
4689 .state
4690 .event("land", format!("closed {url}: superseded on {base_branch}")),
4691 Ok(Err(why)) => self
4692 .state
4693 .event("land", format!("did not close a pull request: {why}")),
4694 Err(e) => {
4695 let why = format!(
4696 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4697 not be closed ({e:#}); resume to retry"
4698 );
4699 self.state.status = RunStatus::Blocked;
4700 self.state.base_sync = Some(BaseSync {
4701 tip: tip.to_owned(),
4702 behind,
4703 attempts,
4704 conflict: Some(why.clone()),
4705 already_in: None,
4706 });
4707 self.state.event("land", why);
4708 self.state.save()?;
4709 return Ok(true);
4710 }
4711 }
4712 self.state.status = RunStatus::AlreadyInBase;
4713 self.state.base_sync = Some(BaseSync {
4714 tip: tip.to_owned(),
4715 behind,
4716 attempts,
4717 conflict: None,
4718 already_in: Some(evidence),
4719 });
4720 self.state.event("land", message);
4721 self.state.save()?;
4722 self.settle_questions();
4723 Ok(true)
4724 }
4725
4726 fn landing_base(&self) -> String {
4736 self.state
4737 .base_sync
4738 .as_ref()
4739 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4740 }
4741
4742 pub async fn fix_selected(
4775 &mut self,
4776 ids: &[String],
4777 reason: &str,
4778 allow_stale: bool,
4779 ) -> Result<()> {
4780 let reason = reason.trim();
4781 if reason.is_empty() {
4782 bail!("a fix request needs a reason — that is the operator's own record of why");
4783 }
4784 if ids.is_empty() {
4785 bail!("no finding id given");
4786 }
4787 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4788 bail!(
4789 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4790 has already concluded — can be given a targeted fix. A run still \
4791 in progress should simply be resumed; a `merged` run's branch has \
4792 already landed, so its answer is a fresh `magi review <branch>`, \
4793 not reopening this run's own record",
4794 self.state.id,
4795 self.state.status.as_str()
4796 );
4797 }
4798 let Some(winner) = self.state.winner().cloned() else {
4799 bail!("run {} has no winning candidate to fix", self.state.id);
4800 };
4801 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4802 bail!(
4803 "branch `{}` no longer exists; this run cannot be extended",
4804 winner.branch
4805 );
4806 }
4807 let home = crate::run::home();
4808 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4809 bail!(
4810 "run {} is currently being worked on by another magi process",
4811 self.state.id
4812 );
4813 }
4814 let _claim = FixClaim::acquire(&self.state.dir())?;
4820
4821 let mut seen = BTreeSet::new();
4825 let mut findings = Vec::new();
4826 let mut missing = Vec::new();
4827 for id in ids {
4828 if !seen.insert(id.clone()) {
4829 continue;
4830 }
4831 match self.state.finding(id) {
4832 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4833 id: f.id.clone(),
4834 severity: f.severity,
4835 reviewer_vote: rec.vote,
4836 round: round.round,
4837 round_head: round.head.clone(),
4838 reviewer: rec.reviewer,
4839 agent: rec.agent.clone(),
4840 file: f.file.clone(),
4841 line: f.line,
4842 title: f.title.clone(),
4843 detail: f.detail.clone(),
4844 outcome: OperatorFixOutcome::Pending,
4845 }),
4846 None => missing.push(id.clone()),
4847 }
4848 }
4849 if !missing.is_empty() {
4850 bail!(
4851 "unknown finding id(s): {}; nothing was changed",
4852 missing.join(", ")
4853 );
4854 }
4855
4856 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4857 let stale_details: Vec<(String, String)> = findings
4858 .iter()
4859 .filter(|f| f.round_head != head_at_request)
4860 .map(|f| (f.id.clone(), f.round_head.clone()))
4861 .collect();
4862 let stale = !stale_details.is_empty();
4863 if stale && !allow_stale {
4864 bail!(
4865 "the branch has moved since some finding(s) were raised — {} — now \
4866 at {}; pass --allow-stale to fix anyway, or re-run review first",
4867 stale_details
4868 .iter()
4869 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4870 .collect::<Vec<_>>()
4871 .join(", "),
4872 short(&head_at_request)
4873 );
4874 }
4875
4876 let request = OperatorFixRequest {
4877 requested_at: Timestamp::now(),
4878 reason: reason.to_owned(),
4879 findings,
4880 head_at_request: head_at_request.clone(),
4881 allow_stale,
4882 stale,
4883 fix: None,
4884 result_head: None,
4885 follow_up_review_run: None,
4886 };
4887 self.state.event(
4888 "fix",
4889 format!(
4890 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4891 request.findings.len(),
4892 request
4893 .findings
4894 .iter()
4895 .map(|f| f.id.as_str())
4896 .collect::<Vec<_>>()
4897 .join(", "),
4898 ),
4899 );
4900 self.state.operator_fixes.push(request);
4907 self.state.save()?;
4908 let request_index = self.state.operator_fixes.len() - 1;
4909
4910 if winner.worktree.exists() {
4919 let dirty = git::git(
4922 &winner.worktree,
4923 &["status", "--porcelain", "--untracked-files=all"],
4924 )
4925 .await?;
4926 let only_withheld = dirty.lines().all(|l| {
4927 l.strip_prefix("?? ")
4928 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4929 });
4930 if !only_withheld {
4931 bail!(
4932 "`{}` has uncommitted changes; refusing to touch it — commit or \
4933 discard them first",
4934 winner.worktree.display()
4935 );
4936 }
4937 git::worktree_remove(&self.state.repo, &winner.worktree)
4938 .await
4939 .ok();
4940 }
4941 let fix_worktree = self.state.worktree_root().join("operator-fix");
4942 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4943 git::git(
4944 &self.state.repo,
4945 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4946 )
4947 .await
4948 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4949 if !git::is_clean(&fix_worktree).await? {
4950 git::worktree_remove(&self.state.repo, &fix_worktree)
4951 .await
4952 .ok();
4953 bail!(
4954 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4955 winner.branch
4956 );
4957 }
4958 let repo_for_setup = self.state.repo.clone();
4959 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4960 Ok(()) => {
4961 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4962 }
4963 Err(e) => Err(e),
4964 };
4965 if let Err(e) = setup {
4966 git::worktree_remove(&self.state.repo, &fix_worktree)
4967 .await
4968 .ok();
4969 return Err(e);
4970 }
4971
4972 let run_id = self.state.id.clone();
4973 let prompts = self.state.config.prompts.clone();
4974 let language = self.state.config.graph.language.clone();
4975 let sessions = self.state.config.graph.sessions;
4976 let artifacts = agent::artifacts_dir(&self.state.dir());
4977 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4978 .findings
4979 .iter()
4980 .map(|f| Finding {
4981 id: f.id.clone(),
4982 severity: f.severity,
4983 file: f.file.clone(),
4984 line: f.line,
4985 title: f.title.clone(),
4986 detail: f.detail.clone(),
4987 })
4988 .collect();
4989 let fix_prompt = prompt::operator_fix_in(
4990 &self.state.instruction,
4991 &finding_list,
4992 reason,
4993 &stale_details,
4994 &head_at_request,
4995 &language,
4996 &self.state.config.graph.github_language,
4997 );
4998 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4999 let (job, seat, out) = self
5000 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
5001 prompt: fix_prompt.clone(),
5002 spec,
5003 seat,
5004 cwd: fix_worktree.clone(),
5005 timeout,
5006 allow_write: true,
5007 sessions,
5008 artifacts: artifacts.clone(),
5009 stem: "operator-fix".to_owned(),
5010 handover: None,
5011 })
5012 .await;
5013 let agent_id = seat.agent.clone();
5014
5015 let mut fix = FixRecord {
5016 agent: agent_id,
5017 addressed: Vec::new(),
5018 rejected: Vec::new(),
5019 notes: String::new(),
5020 committed: false,
5021 failed: None,
5022 duration_ms: 0,
5023 continuation: None,
5024 };
5025 let mut final_seat = seat.clone();
5026 match out {
5027 AgentOutcome::Ok(o) => {
5028 fix.duration_ms = o.duration_ms;
5029 let parsed = verdict::extract_json::<FixReport>(&o.text);
5030 let incomplete_reason = match &parsed {
5031 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5032 "the reply parsed, but it reported a command whose own CLI \
5033 never confirmed an exit status"
5034 .to_owned(),
5035 ),
5036 Ok(_) => None,
5037 Err(e) => Some(e.to_string()),
5038 };
5039 match incomplete_reason {
5040 None => {
5041 let report = parsed.expect("checked Ok above");
5042 fix.addressed = report.addressed;
5043 fix.rejected = report.rejected;
5044 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5045 }
5046 Some(reason) => {
5047 let (resumed_seat, resolved, failure, cont) = self
5048 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
5049 .await;
5050 fix.duration_ms += cont.cumulative_wait_ms;
5051 fix.continuation = Some(cont);
5052 final_seat = resumed_seat;
5053 match resolved {
5054 Some(report) => {
5055 fix.addressed = report.addressed;
5056 fix.rejected = report.rejected;
5057 fix.notes =
5058 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5059 }
5060 None => fix.failed = failure,
5061 }
5062 }
5063 }
5064 }
5065 AgentOutcome::Dropped(o) => {
5066 fix.duration_ms = o.duration_ms;
5067 let why = o
5068 .dropped
5069 .as_ref()
5070 .map(|d| d.why.as_str())
5071 .unwrap_or("the CLI ended the stream without delivering its answer");
5072 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5073 }
5074 AgentOutcome::Quota(o) => {
5075 self.state.quota.push(QuotaLoss {
5076 seat: final_seat.key.clone(),
5077 node: "fix".to_owned(),
5078 at: Timestamp::now(),
5079 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5080 });
5081 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5082 }
5083 AgentOutcome::Failed(e) => fix.failed = Some(e),
5084 AgentOutcome::ResourceBlocked(e) => {
5085 fix.failed = Some(format!("the fixer was not started: {e}"));
5086 }
5087 }
5088 if fix.continuation.is_none() {
5089 fix.continuation = Some(ContinuationRecord::not_needed());
5090 }
5091 self.state.seats.insert(final_seat.key.clone(), final_seat);
5092
5093 let rescue_message = format!(
5094 "magi: operator-selected fix ({}) (uncommitted work)",
5095 self.state.operator_fixes[request_index]
5096 .findings
5097 .iter()
5098 .map(|f| f.id.as_str())
5099 .collect::<Vec<_>>()
5100 .join(", ")
5101 );
5102 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5103 self.state.note_withheld("fix", &r.withheld);
5104 }
5105 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5106 fix.committed = after != head_at_request;
5107 git::worktree_remove(&self.state.repo, &fix_worktree)
5108 .await
5109 .ok();
5110
5111 self.state.event(
5112 "fix",
5113 match &fix.failed {
5114 Some(reason) => format!(
5115 "operator fix: adoption report was lost ({reason}); {}",
5116 if fix.committed {
5117 "committed"
5118 } else {
5119 "NO new commit"
5120 }
5121 ),
5122 None => format!(
5123 "operator fix: {} addressed, {} rejected, {}",
5124 fix.addressed.len(),
5125 fix.rejected.len(),
5126 if fix.committed {
5127 "committed"
5128 } else {
5129 "NO new commit"
5130 }
5131 ),
5132 },
5133 );
5134
5135 for f in &mut self.state.operator_fixes[request_index].findings {
5142 f.outcome = if fix.failed.is_some() {
5143 OperatorFixOutcome::Unreported
5144 } else if fix.addressed.contains(&f.id) {
5145 OperatorFixOutcome::Addressed
5146 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5147 OperatorFixOutcome::Rejected { why: r.why.clone() }
5148 } else {
5149 OperatorFixOutcome::Unreported
5150 };
5151 }
5152
5153 let committed = fix.committed;
5154 if committed {
5155 self.state.operator_fixes[request_index].result_head = Some(after.clone());
5156 }
5157 self.state.operator_fixes[request_index].fix = Some(fix);
5158 self.state.save()?;
5161
5162 if committed {
5163 self.state.event(
5164 "fix",
5165 format!(
5166 "operator fix committed {}; opening a follow-up review-only run",
5167 short(&after)
5168 ),
5169 );
5170 let origin =
5173 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5174 match Self::review(
5175 &self.state.repo,
5176 &winner.branch,
5177 self.state.config.clone(),
5178 origin,
5179 )
5180 .await
5181 {
5182 Ok(mut follow_up) => {
5183 follow_up.state.event(
5184 "start",
5185 format!(
5186 "requested by an operator fix on run {} for finding(s) {}",
5187 self.state.id,
5188 self.state.operator_fixes[request_index]
5189 .findings
5190 .iter()
5191 .map(|f| f.id.as_str())
5192 .collect::<Vec<_>>()
5193 .join(", "),
5194 ),
5195 );
5196 follow_up.state.save()?;
5197 let follow_up_id = follow_up.state.id.clone();
5198 let adopted = match crate::direct::adopt(
5201 &follow_up.state,
5202 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5203 ) {
5204 Ok(a) => a,
5205 Err(e) => {
5206 self.state.event(
5209 "fix",
5210 format!(
5211 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5212 ),
5213 );
5214 self.state.save()?;
5215 return Ok(());
5216 }
5217 };
5218 let executed = follow_up.execute().await;
5219 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5220 if let Some(a) = adopted {
5221 a.finish(&follow_up.state, executed);
5222 }
5223 if let Some(e) = failure {
5224 self.state.event(
5225 "fix",
5226 format!(
5227 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5228 ),
5229 );
5230 }
5231 self.state.operator_fixes[request_index].follow_up_review_run =
5232 Some(follow_up_id);
5233 }
5234 Err(e) => {
5235 self.state.event(
5236 "fix",
5237 format!("committed the fix but could not open a follow-up review: {e:#}"),
5238 );
5239 }
5240 }
5241 self.state.save()?;
5242 }
5243
5244 Ok(())
5245 }
5246
5247 async fn ask_fixer(
5267 &mut self,
5268 winner: &Candidate,
5269 node: &'static str,
5270 round: Option<usize>,
5271 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5272 ) -> (SeatJob, SeatState, AgentOutcome) {
5273 let attempts = fixer::attempts(&self.state, &self.roles, winner);
5274 self.ask_fixer_among(attempts, node, round, None, build)
5275 .await
5276 }
5277
5278 async fn ask_fixer_among(
5283 &mut self,
5284 attempts: Vec<(AgentSpec, String)>,
5285 node: &'static str,
5286 round: Option<usize>,
5287 restore: Option<(&Path, &str)>,
5288 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5289 ) -> (SeatJob, SeatState, AgentOutcome) {
5290 let run_id = self.state.id.clone();
5291 let prompts = self.state.config.prompts.clone();
5292 let cache = self.state.config.cache_dir();
5293 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5294 let mut last = None;
5295 for (i, (spec, key)) in attempts.into_iter().enumerate() {
5296 let seat = self.seat(&key, &spec.id);
5297 let mut job = build(spec.clone(), seat);
5298 if i > 0 {
5299 job.stem = format!("{}-{}", job.stem, spec.id);
5300 }
5301 let ctx = WaveCtx {
5302 carry_seats: false,
5303 pauses: Some((&self.pause, &self.interrupt)),
5304 run: &run_id,
5305 node,
5306 prompts: &prompts,
5307 cache: cache.as_deref(),
5308 round,
5309 };
5310 let (seat, out) =
5311 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5312 let advances = match &out {
5315 AgentOutcome::Ok(o) => agent::output_advances(o),
5316 AgentOutcome::ResourceBlocked(_) => false,
5317 _ => true,
5318 };
5319 if let Some(next) = ids.get(i + 1)
5320 && advances
5321 {
5322 self.state.seats.insert(seat.key.clone(), seat.clone());
5323 let class =
5324 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5325 let reason = match &out {
5326 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5327 other => fail_reason(other),
5328 };
5329 record_handover(
5330 &mut self.state,
5331 node,
5332 &seat.key,
5333 &spec.id,
5334 next,
5335 &class,
5336 &reason,
5337 );
5338 if let Some((wt, rev)) = restore
5342 && let Err(e) = git::restore_to(wt, rev).await
5343 {
5344 let why =
5345 format!("could not restore the worktree before the next fixer: {e:#}");
5346 tracing::warn!("{why}");
5347 last = Some((job, seat, AgentOutcome::Failed(why)));
5348 break;
5349 }
5350 continue;
5351 }
5352 last = Some((job, seat, out));
5353 break;
5354 }
5355 last.expect("the fixer chain always has an entry")
5356 }
5357
5358 async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5369 use crate::recovery::{Mode, Repair};
5370 if !self.state.reviews.is_empty() {
5371 return Ok(());
5372 }
5373 let Some(mut rec) = self.state.recovery.clone() else {
5374 return Ok(());
5375 };
5376 if rec.mode != Mode::VerifyRepair {
5377 return Ok(());
5378 }
5379 let wt = winner.worktree.clone();
5380 let head = git::rev_parse(&wt, "HEAD").await?;
5381 match rec.repair.clone() {
5382 Repair::Done { .. } => return Ok(()),
5383 Repair::Started { before } => {
5384 if let Err(e) = Self::graft_restore(&wt, &before).await {
5388 return self
5389 .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5390 .await;
5391 }
5392 rec.repair = Repair::Done {
5393 before: before.clone(),
5394 after: before,
5395 committed: false,
5396 failed: Some("interrupted; rolled back".to_owned()),
5397 };
5398 self.state
5399 .event("recovery", "the repair was interrupted and rolled back");
5400 self.state.recovery = Some(rec);
5401 self.state.save()?;
5402 return Ok(());
5403 }
5404 Repair::NotStarted => {}
5405 }
5406 if crate::recovery::recheck(&mut rec, &head) {
5407 self.state.event("recovery", rec.summary());
5408 self.state.recovery = Some(rec);
5409 self.state.save()?;
5410 return Ok(());
5411 }
5412 rec.repair = Repair::Started {
5413 before: head.clone(),
5414 };
5415 self.state.recovery = Some(rec.clone());
5416 self.state.save()?;
5417
5418 let language = self.state.config.graph.language.clone();
5419 let sessions = self.state.config.graph.sessions;
5420 let artifacts = agent::artifacts_dir(&self.state.dir());
5421 let fix_prompt = prompt::recovery_fix(
5422 &self.state.instruction,
5423 &rec,
5424 self.state.config.graph.review_rounds,
5425 &language,
5426 );
5427 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5428 self.state.event(
5429 "recovery",
5430 format!(
5431 "repair: the fixer starts from the failed verification on {}",
5432 short(&head)
5433 ),
5434 );
5435 let (_job, seat, out) = self
5436 .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5437 prompt: fix_prompt.clone(),
5438 spec,
5439 seat,
5440 cwd: wt.clone(),
5441 timeout,
5442 allow_write: true,
5443 sessions,
5444 artifacts: artifacts.clone(),
5445 stem: "fix-recovery".to_owned(),
5446 handover: None,
5447 })
5448 .await;
5449 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5452 if let Err(e) = Self::graft_restore(&wt, &head).await {
5453 return self
5454 .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5455 .await;
5456 }
5457 rec.repair = Repair::NotStarted;
5458 self.state.recovery = Some(rec);
5459 self.state.save()?;
5460 return self.stop_for_resource();
5461 }
5462 let failed = match &out {
5463 AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5464 AgentOutcome::Quota(o) => {
5465 self.state.quota.push(QuotaLoss {
5466 seat: seat.key.clone(),
5467 node: "fix".to_owned(),
5468 at: Timestamp::now(),
5469 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5470 });
5471 Some("rate limited (quota); the fixer could not run".to_owned())
5472 }
5473 AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5474 AgentOutcome::Failed(e) => Some(e.clone()),
5475 };
5476 self.state.seats.insert(seat.key.clone(), seat);
5477 let failed = match failed {
5478 Some(why) => {
5480 if let Err(e) = git::restore_to(&wt, &head).await {
5481 return self
5482 .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5483 .await;
5484 }
5485 Some(why)
5486 }
5487 None => {
5488 if let Ok(r) = git::rescue_commit(
5489 &wt,
5490 "magi: recovery repair of the failed verification (uncommitted work)",
5491 )
5492 .await
5493 {
5494 self.state.note_withheld("fix", &r.withheld);
5495 }
5496 None
5497 }
5498 };
5499 let after = git::rev_parse(&wt, "HEAD").await?;
5500 self.state.event(
5501 "recovery",
5502 match &failed {
5503 Some(why) => format!("repair: nothing kept ({why})"),
5504 None if after != head => {
5505 format!("repair: committed {}", short(&after))
5506 }
5507 None => "repair: the fixer made no commit".to_owned(),
5508 },
5509 );
5510 rec.repair = Repair::Done {
5511 before: head.clone(),
5512 committed: after != head,
5513 after,
5514 failed,
5515 };
5516 self.state.recovery = Some(rec);
5517 self.state.save()?;
5518 Ok(())
5519 }
5520
5521 async fn stop_recovery(
5524 &mut self,
5525 mut rec: crate::recovery::RecoveryRecord,
5526 head: &str,
5527 why: String,
5528 ) -> Result<()> {
5529 rec.repair = crate::recovery::Repair::Started {
5531 before: head.to_owned(),
5532 };
5533 self.state.recovery = Some(rec);
5534 self.state
5535 .event("recovery", format!("repair could not be undone: {why}"));
5536 self.state.status = RunStatus::Blocked;
5537 self.state.save()?;
5538 bail!("recovery repair could not be rolled back: {why}")
5539 }
5540
5541 async fn review_loop(&mut self) -> Result<()> {
5542 if self
5547 .state
5548 .base_sync
5549 .as_ref()
5550 .is_some_and(|s| s.conflict.is_some())
5551 {
5552 return Ok(());
5553 }
5554 let run_id = self.state.id.clone();
5559 let prompts = self.state.config.prompts.clone();
5560 let Some(winner) = self.state.winner().cloned() else {
5561 return Ok(());
5562 };
5563 let max_rounds = self.state.config.graph.review_rounds;
5564 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5574 if status == RunStatus::Gating {
5577 self.record_contested_handoff();
5578 }
5579 self.state.status = status;
5580 self.state.save()?;
5581 return Ok(());
5582 }
5583 self.state.status = RunStatus::Reviewing;
5584 if self
5594 .state
5595 .reviews
5596 .last()
5597 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5598 {
5599 let shell = self.state.config.shell();
5600 return self
5601 .stop_reviewing(
5602 "the last round's own verification never resolved",
5603 &shell,
5604 &winner.worktree,
5605 )
5606 .await;
5607 }
5608
5609 self.recovery_repair(&winner).await?;
5610 if self.resource_halt()? {
5611 return Ok(());
5612 }
5613
5614 let repo = self.state.repo.clone();
5615 let root = self.state.worktree_root();
5616 let language = self.state.config.graph.language.clone();
5617 let sessions = self.state.config.graph.sessions;
5618 let artifacts = self.merge_repair_artifacts();
5619 let base = self.landing_base();
5620 let base_short = short(&base);
5621 let reviewers = self.roles.reviewers.clone();
5622 let shell = self.state.config.shell();
5623
5624 for round in (self.state.reviews.len() + 1)..=max_rounds {
5625 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5626 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5627 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5628 let prev_verification = self
5637 .state
5638 .reviews
5639 .last()
5640 .and_then(|r| r.verification_summary(&head));
5641 let prior_section = match (&self.state.recovery, round) {
5644 (Some(rec), 1) if self.state.reviews.is_empty() => {
5645 let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5646 (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5647 Some(crate::recovery::head_of(
5648 &git::diff(&winner.worktree, reviewed, "HEAD")
5649 .await
5650 .unwrap_or_default(),
5651 crate::recovery::SINCE_DIFF_MAX,
5652 ))
5653 }
5654 _ => None,
5655 };
5656 Some(prompt::recovery_review(rec, since.as_deref()))
5657 }
5658 _ => None,
5659 };
5660
5661 let mut jobs = Vec::new();
5665 for (r, spec) in reviewers.iter().cloned().enumerate() {
5666 let wt = root.join(format!("review-{}", r + 1));
5667 if wt.exists() {
5670 git::reset_detached(&wt, &head).await?;
5671 } else {
5672 git::worktree_add_detached(&repo, &wt, &head).await?;
5673 }
5674 self.setup_seat_worktree(&repo, &wt, false).await?;
5675 let seat_key = format!("review-{}", r + 1);
5676 let spec = pick_start_spec(
5679 &self.roles.reviewer_roster,
5680 spec,
5681 self.state.seat_history.get(&seat_key),
5682 );
5683 let seat = self.seat(&seat_key, &spec.id);
5684 jobs.push(SeatJob {
5685 prompt: prompt::review(&prompt::ReviewCtx {
5686 instruction: &self.state.instruction,
5687 branch: &winner.branch,
5688 base_short: &base_short,
5689 stat: &stat,
5690 patch: &patch,
5691 verification: prev_verification.as_ref(),
5692 prior: prior_section.as_deref(),
5693 reviewers: reviewers.len(),
5694 round,
5695 rounds: max_rounds,
5696 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5699 lens: Lens::for_seat(r),
5700 language: &language,
5701 github_language: &self.state.config.graph.github_language,
5702 }),
5703 spec,
5704 seat,
5705 cwd: wt,
5706 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5707 allow_write: false,
5708 sessions,
5709 artifacts: artifacts.clone(),
5710 stem: format!("review-{round}-{}", r + 1),
5711 handover: None,
5712 });
5713 }
5714
5715 self.state.event(
5716 "review",
5717 format!(
5718 "round {round}: {} reviewers on {}",
5719 jobs.len(),
5720 short(&head)
5721 ),
5722 );
5723 let mut quota_losses = Vec::new();
5724 let review_retries = self.state.config.graph.retries;
5725 let review_cache = self.state.config.cache_dir();
5726 let ctx = WaveCtx {
5727 carry_seats: true,
5728 pauses: Some((&self.pause, &self.interrupt)),
5729 run: &run_id,
5730 node: "review",
5731 prompts: &prompts,
5732 cache: review_cache.as_deref(),
5733 round: Some(round),
5734 };
5735 let results = ask_json_wave::<Review>(
5736 jobs,
5737 Arc::clone(&self.sem),
5738 review_retries,
5739 &self.roles.reviewer_roster,
5740 &ctx,
5741 &mut quota_losses,
5742 &mut self.state,
5743 &|_: &Review| Ok(()),
5744 )
5745 .await;
5746 if self.state.resource_block.is_some() {
5747 return self.stop_for_resource();
5748 }
5749 let round_quota_missing = quota_losses.len();
5753 self.state.quota.extend(quota_losses);
5754
5755 let mut records = Vec::new();
5756 let mut all_findings = Vec::new();
5757 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5758 let agent_id = seat.agent.clone();
5759 self.state.seats.insert(seat.key.clone(), seat);
5760 let mut record = ReviewRecord {
5761 reviewer: r + 1,
5762 agent: agent_id,
5763 summary: String::new(),
5764 findings: Vec::new(),
5765 vote: None,
5766 failed: None,
5767 duration_ms: 0,
5768 attempts,
5774 };
5775 match res {
5776 Ok((review, out)) => {
5777 record.summary =
5785 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5786 record.vote = Some(review.vote);
5787 record.duration_ms = out.duration_ms;
5788 for (n, mut f) in review.findings.into_iter().enumerate() {
5789 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5792 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5793 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5794 f.file = f
5800 .file
5801 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5802 all_findings.push(f.clone());
5803 record.findings.push(f);
5804 }
5805 self.state.event(
5806 "review",
5807 format!(
5808 "round {round}: reviewer {} voted {} with {} finding(s)",
5809 r + 1,
5810 review.vote.label(),
5811 record.findings.len()
5812 ),
5813 );
5814 }
5815 Err(e) => {
5816 record.failed = Some(e.to_string());
5817 self.state.event(
5818 "review",
5819 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5820 );
5821 }
5822 }
5823 records.push(record);
5824 }
5825
5826 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5833 let vote_split =
5834 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5835 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5836 if vote_split {
5837 self.state.event(
5838 "review",
5839 format!(
5840 "round {round}: votes split ({}) — one round of reconsideration",
5841 initial_votes
5842 .iter()
5843 .map(|v| v.label())
5844 .collect::<Vec<_>>()
5845 .join(", ")
5846 ),
5847 );
5848 let panel: Vec<ReviewSeatReport<'_>> = records
5851 .iter()
5852 .filter_map(|r| {
5853 r.vote.map(|vote| ReviewSeatReport {
5854 reviewer: r.reviewer,
5855 vote,
5856 summary: &r.summary,
5857 findings: &r.findings,
5858 })
5859 })
5860 .collect();
5861
5862 let mut jobs = Vec::new();
5863 let mut seats_at = Vec::new();
5864 for (r, spec) in reviewers.iter().cloned().enumerate() {
5865 if records[r].vote.is_none() {
5869 continue;
5870 }
5871 let wt = root.join(format!("review-{}", r + 1));
5872 let seat_key = format!("review-{}", r + 1);
5873 let spec = self.occupant(&seat_key, spec);
5874 let seat = self.seat(&seat_key, &spec.id);
5875 let build = |with_patch: bool| {
5883 prompt::review_reconsider(&ReviewReconsiderCtx {
5884 instruction: &self.state.instruction,
5885 reviewer: r + 1,
5886 lens: Lens::for_seat(r),
5887 panel: &panel,
5888 patch: with_patch.then_some(ReviewPatch {
5889 branch: &winner.branch,
5890 base_short: &base_short,
5891 stat: &stat,
5892 patch: &patch,
5893 }),
5894 round,
5895 rounds: max_rounds,
5896 language: &language,
5897 })
5898 };
5899 let full = build(true);
5900 let prompt = if has_context(&spec, &seat, sessions) {
5901 build(false)
5902 } else {
5903 full.clone()
5904 };
5905 jobs.push(SeatJob {
5906 prompt,
5907 spec,
5908 seat,
5909 cwd: wt,
5910 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5911 allow_write: false,
5912 sessions,
5913 artifacts: artifacts.clone(),
5914 stem: format!("review-{round}-reconsider-{}", r + 1),
5915 handover: Some(full),
5916 });
5917 seats_at.push(r);
5918 }
5919
5920 let mut recon_quota_losses = Vec::new();
5921 let recon_cache = self.state.config.cache_dir();
5922 let recon_ctx = WaveCtx {
5923 carry_seats: true,
5924 pauses: Some((&self.pause, &self.interrupt)),
5925 run: &run_id,
5926 node: "review",
5927 prompts: &prompts,
5928 cache: recon_cache.as_deref(),
5929 round: Some(round),
5930 };
5931 let recon_results = ask_json_wave::<ReviewRevote>(
5932 jobs,
5933 Arc::clone(&self.sem),
5934 review_retries,
5935 &self.roles.reviewer_roster,
5936 &recon_ctx,
5937 &mut recon_quota_losses,
5938 &mut self.state,
5939 &|_: &ReviewRevote| Ok(()),
5940 )
5941 .await;
5942 if self.state.resource_block.is_some() {
5943 return self.stop_for_resource();
5944 }
5945 self.state.quota.extend(recon_quota_losses);
5946
5947 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5948 let agent_id = seat.agent.clone();
5949 self.state.seats.insert(seat.key.clone(), seat);
5950 let mut rec = ReviewRevoteRecord {
5951 reviewer: r + 1,
5952 agent: agent_id,
5953 vote: None,
5954 reason: String::new(),
5955 failed: None,
5956 };
5957 match res {
5958 Ok((rv, _)) => {
5959 rec.vote = Some(rv.vote);
5960 rec.reason =
5961 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5962 self.state.event(
5963 "review",
5964 format!(
5965 "round {round}: reviewer {} revoted {}",
5966 r + 1,
5967 rv.vote.label()
5968 ),
5969 );
5970 }
5971 Err(e) => {
5972 rec.failed = Some(e.to_string());
5973 self.state.event(
5974 "review",
5975 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5976 );
5977 }
5978 }
5979 reconsideration.push(rec);
5980 }
5981 } else if initial_votes.len() > 1 {
5982 self.state.event(
5983 "review",
5984 format!(
5985 "round {round}: votes agreed ({}) — no reconsideration",
5986 initial_votes[0].label()
5987 ),
5988 );
5989 }
5990
5991 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5992 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5993 let defer_e2e =
6004 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
6005 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
6006 let reason =
6007 format!("{blocking} blocking finding(s) already required a fix this round");
6008 self.state.event(
6009 "verify",
6010 format!(
6011 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
6012 {}); it will run once a round has none left",
6013 short(&head)
6014 ),
6015 );
6016 (Vec::new(), false, true, Some(reason))
6017 } else {
6018 let e2e_commands = self.state.config.verify.e2e.clone();
6019 let cache_dir = self.state.config.cache_dir();
6020 let context = format!("round {round}");
6021 let (e2e, verify_retried) = with_cache_lease(
6022 &mut self.state,
6023 cache_dir.as_deref(),
6024 "e2e",
6025 "e2e",
6026 &winner.worktree,
6027 &head,
6028 verify_timeout,
6029 &context,
6030 |state, budget| {
6031 let shell = shell.clone();
6032 let e2e_commands = e2e_commands.clone();
6033 let worktree = winner.worktree.clone();
6034 let context = context.clone();
6035 async move {
6036 run_e2e_with_retry(
6037 state,
6038 &shell,
6039 &e2e_commands,
6040 &worktree,
6041 budget,
6042 &context,
6043 )
6044 .await
6045 }
6046 },
6047 )
6048 .await;
6049 (e2e, verify_retried, false, None)
6050 };
6051
6052 let expected = records.len();
6053 let answered = records.iter().filter(|r| r.failed.is_none()).count();
6054 let incomplete = answered < expected;
6055 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
6056 let policy = self.state.config.graph.incomplete_review;
6057 let clean = round_is_clean(
6058 blocking,
6059 e2e_ok,
6060 answered,
6061 expected,
6062 round_quota_missing,
6063 policy,
6064 );
6065
6066 let mut round_record = ReviewRound {
6067 round,
6068 head: head.clone(),
6069 verified_head: None,
6070 verified_at: None,
6071 reviews: records,
6072 e2e,
6073 verify_retried,
6074 e2e_deferred,
6075 e2e_defer_reason,
6076 fix: None,
6077 blocking,
6078 answered,
6079 expected,
6080 clean,
6081 progressed: false,
6082 vote_split,
6083 reconsideration,
6084 verdict: None,
6085 };
6086 round_record.verdict = ReviewVote::worst(
6090 round_record
6091 .final_votes()
6092 .into_iter()
6093 .map(|(_, _, vote)| vote),
6094 );
6095 if !matches!(
6106 round_record.e2e_status(),
6107 E2eStatus::Deferred | E2eStatus::NotConfigured
6108 ) {
6109 round_record.verified_head = Some(head.clone());
6110 round_record.verified_at = Some(Timestamp::now());
6111 }
6112 let this_round_verification = round_record.verification_summary(&head);
6113
6114 if incomplete {
6115 let missing: Vec<String> = round_record
6116 .reviews
6117 .iter()
6118 .filter(|r| r.failed.is_some())
6119 .map(|r| format!("review-{}", r.reviewer))
6120 .collect();
6121 self.state.event(
6122 "review",
6123 format!(
6124 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6125 missing.join(", ")
6126 ),
6127 );
6128 }
6129
6130 if clean {
6131 self.state.event(
6132 "review",
6133 if incomplete && policy == IncompleteReviewPolicy::Warn {
6134 format!(
6135 "round {round}: clean (warn policy, incomplete panel) — no \
6136 blocking findings from the seats that answered, verification green"
6137 )
6138 } else if incomplete {
6139 format!(
6140 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6141 quorum) — no blocking findings from the seats that answered, \
6142 verification green",
6143 expected - answered
6144 )
6145 } else {
6146 format!("round {round}: clean — no blocking findings, verification green")
6147 },
6148 );
6149 self.state.reviews.push(round_record);
6150 self.state.status = RunStatus::Gating;
6151 self.state.save()?;
6152 return Ok(());
6153 }
6154
6155 if incomplete && blocking == 0 && e2e_ok {
6163 self.state.reviews.push(round_record);
6164 self.state.save()?;
6165 if round == max_rounds {
6166 self.state.status = RunStatus::Blocked;
6167 self.state.event(
6168 "review",
6169 format!(
6170 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6171 refusing to call it clean",
6172 expected - answered
6173 ),
6174 );
6175 return Ok(());
6176 }
6177 continue;
6178 }
6179
6180 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6192 self.state.reviews.push(round_record);
6193 return self
6194 .stop_reviewing(
6195 "the round's own verification could not run",
6196 &shell,
6197 &winner.worktree,
6198 )
6199 .await;
6200 }
6201
6202 if round == max_rounds {
6203 self.state.reviews.push(round_record);
6204 return self
6205 .stop_reviewing(
6206 &format!(
6207 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6208 ),
6209 &shell,
6210 &winner.worktree,
6211 )
6212 .await;
6213 }
6214
6215 let blocking_findings: Vec<_> = all_findings
6218 .iter()
6219 .filter(|f| f.severity.blocks())
6220 .cloned()
6221 .collect();
6222 let fix_prompt = prompt::fix_in(
6223 &self.state.instruction,
6224 &blocking_findings,
6225 this_round_verification.as_ref(),
6226 round,
6227 max_rounds,
6228 &language,
6229 &self.state.config.graph.github_language,
6230 );
6231 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6232 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6233 let (job, seat, out) = self
6234 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6235 prompt: fix_prompt.clone(),
6236 spec,
6237 seat,
6238 cwd: winner.worktree.clone(),
6239 timeout,
6240 allow_write: true,
6241 sessions,
6242 artifacts: artifacts.clone(),
6243 stem: format!("fix-{round}"),
6244 handover: None,
6245 })
6246 .await;
6247 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6250 return self.stop_for_resource();
6251 }
6252 let agent_id = seat.agent.clone();
6253
6254 let mut fix = FixRecord {
6255 agent: agent_id,
6256 addressed: Vec::new(),
6257 rejected: Vec::new(),
6258 notes: String::new(),
6259 committed: false,
6260 failed: None,
6261 duration_ms: 0,
6262 continuation: None,
6263 };
6264 let mut continuation = ContinuationRecord::not_needed();
6265 let mut final_seat = seat.clone();
6266 match out {
6267 AgentOutcome::Ok(o) => {
6268 fix.duration_ms = o.duration_ms;
6269 let parsed = verdict::extract_json::<FixReport>(&o.text);
6270 let incomplete_reason = match &parsed {
6277 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6278 "the reply parsed, but it reported a command whose own CLI never \
6279 confirmed an exit status"
6280 .to_owned(),
6281 ),
6282 Ok(_) => None,
6283 Err(e) => Some(e.to_string()),
6284 };
6285 match incomplete_reason {
6286 None => {
6287 let report = parsed.expect("checked Ok above");
6288 fix.addressed = report.addressed;
6289 fix.rejected = report.rejected;
6290 fix.notes =
6291 blind::sanitize_prose(&report.notes, &self.state.config.blind);
6292 }
6293 Some(reason) => {
6294 let (resumed_seat, resolved, failure, cont) = self
6295 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6296 .await;
6297 fix.duration_ms += cont.cumulative_wait_ms;
6298 continuation = cont;
6299 final_seat = resumed_seat;
6300 match resolved {
6301 Some(report) => {
6302 fix.addressed = report.addressed;
6303 fix.rejected = report.rejected;
6304 fix.notes = blind::sanitize_prose(
6305 &report.notes,
6306 &self.state.config.blind,
6307 );
6308 }
6309 None => fix.failed = failure,
6310 }
6311 }
6312 }
6313 }
6314 AgentOutcome::Dropped(o) => {
6316 fix.duration_ms = o.duration_ms;
6317 let why = o
6318 .dropped
6319 .as_ref()
6320 .map(|d| d.why.as_str())
6321 .unwrap_or("the CLI ended the stream without delivering its answer");
6322 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6323 }
6324 AgentOutcome::Quota(o) => {
6325 self.state.quota.push(QuotaLoss {
6326 seat: final_seat.key.clone(),
6327 node: "fix".to_owned(),
6328 at: Timestamp::now(),
6329 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6330 });
6331 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6332 }
6333 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6334 }
6335 fix.continuation = Some(continuation);
6336 self.state.seats.insert(final_seat.key.clone(), final_seat);
6337 if let Ok(r) = git::rescue_commit(
6338 &winner.worktree,
6339 &format!("magi: review round {round} fixes (uncommitted work)"),
6340 )
6341 .await
6342 {
6343 self.state.note_withheld("fix", &r.withheld);
6344 }
6345 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6346 fix.committed = after != before;
6347 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6355 let progressed = diff_after != patch;
6356 let commit_note = if fix.committed {
6357 "committed"
6358 } else {
6359 "NO new commit"
6360 };
6361 let tree_note = if progressed {
6362 "changed vs base"
6363 } else {
6364 "unchanged vs base"
6365 };
6366 self.state.event(
6367 "fix",
6368 match &fix.failed {
6369 Some(reason) => {
6375 format!(
6376 "round {round}: fixer's adoption report was lost ({reason}); \
6377 {commit_note}, tree {tree_note}"
6378 )
6379 }
6380 None => format!(
6381 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6382 {tree_note}{}",
6383 fix.addressed.len(),
6384 fix.rejected.len(),
6385 if continuation.outcome == ContinuationOutcome::Resumed {
6386 format!(
6387 " (adoption report recovered after {} continuation(s))",
6388 continuation.attempts
6389 )
6390 } else {
6391 String::new()
6392 },
6393 ),
6394 },
6395 );
6396 round_record.fix = Some(fix);
6397 round_record.progressed = progressed;
6398 self.state.reviews.push(round_record);
6399 self.state.save()?;
6400
6401 if matches!(
6414 continuation.outcome,
6415 ContinuationOutcome::Exhausted
6416 | ContinuationOutcome::QuotaLost
6417 | ContinuationOutcome::NoSession
6418 ) {
6419 return self
6420 .stop_reviewing(
6421 "the fixer's adoption report never came back, even after resuming its \
6422 own seat; refusing to start another round against the same worktree \
6423 while that is unresolved",
6424 &shell,
6425 &winner.worktree,
6426 )
6427 .await;
6428 }
6429
6430 let streak = self
6431 .state
6432 .reviews
6433 .iter()
6434 .rev()
6435 .take_while(|r| !r.progressed)
6436 .count();
6437 if streak >= STAGNANT_LIMIT {
6438 return self
6439 .stop_reviewing(
6440 &format!(
6441 "the tree has not moved against base for {streak} round(s) in a row"
6442 ),
6443 &shell,
6444 &winner.worktree,
6445 )
6446 .await;
6447 }
6448 }
6449 Ok(())
6450 }
6451
6452 fn record_contested_handoff(&mut self) {
6486 if self.state.contested_handoff.is_some() {
6487 return;
6488 }
6489 let Some(contested) = self
6490 .state
6491 .reviews
6492 .last()
6493 .and_then(ReviewRound::contested_handoff)
6494 else {
6495 return;
6496 };
6497 self.state.event(
6498 "review",
6499 format!(
6500 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6501 wait for the owner's approval",
6502 contested.findings.len(),
6503 contested.rejecters.len()
6504 ),
6505 );
6506 self.state.contested_handoff = Some(contested);
6507 }
6508
6509 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6510 let round_idx = self.state.reviews.len() - 1;
6511 let needs_catchup_run = matches!(
6519 self.state.reviews[round_idx].e2e_status(),
6520 E2eStatus::Deferred | E2eStatus::ResourceBlocked
6521 );
6522 if needs_catchup_run {
6523 let round = self.state.reviews[round_idx].round;
6524 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6525 let commands = self.state.config.verify.e2e.clone();
6526 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6527 let cache_dir = self.state.config.cache_dir();
6528 let context = format!(
6529 "round {round}: verification unresolved, catching up before the final decision"
6530 );
6531 let (outcomes, verify_retried) = with_cache_lease(
6532 &mut self.state,
6533 cache_dir.as_deref(),
6534 "e2e",
6535 "e2e",
6536 worktree,
6537 &attempted_head,
6538 timeout,
6539 &context,
6540 |state, budget| {
6541 let shell = shell.to_vec();
6542 let commands = commands.clone();
6543 let context = context.clone();
6544 async move {
6545 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6546 .await
6547 }
6548 },
6549 )
6550 .await;
6551 let last = &mut self.state.reviews[round_idx];
6552 last.e2e = outcomes;
6553 last.verify_retried = verify_retried;
6554 last.verified_head = Some(attempted_head);
6561 last.verified_at = Some(Timestamp::now());
6562 if verify_inconclusive(&last.e2e) {
6563 self.state.save()?;
6570 return Ok(());
6571 }
6572 last.e2e_deferred = false;
6573 }
6574 let last = &self.state.reviews[round_idx];
6575 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6576
6577 match last.e2e_status() {
6578 E2eStatus::Failed => {
6579 let red: Vec<String> = last
6580 .e2e
6581 .iter()
6582 .filter(|o| !o.ok())
6583 .map(|o| {
6584 format!(
6585 "`{}` -> {:?}\n{}",
6586 o.command,
6587 o.code,
6588 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6589 )
6590 })
6591 .collect();
6592 self.state
6593 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6594 self.state.status = RunStatus::Blocked;
6595 }
6596 E2eStatus::ResourceBlocked => {
6601 self.state.event(
6602 "review",
6603 format!(
6604 "{why}; e2e could not run (shared build cache unavailable); not \
6605 deciding yet"
6606 ),
6607 );
6608 }
6609 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6610 self.state.event(
6611 "review",
6612 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6613 );
6614 self.record_contested_handoff();
6615 self.state.status = RunStatus::Gating;
6616 }
6617 }
6618 self.state.save()?;
6619 Ok(())
6620 }
6621
6622 async fn gate(&mut self) -> Result<()> {
6625 if self.state.status == RunStatus::Failed
6637 || self
6638 .state
6639 .base_sync
6640 .as_ref()
6641 .is_some_and(|s| s.conflict.is_some())
6642 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6643 != Some(RunStatus::Gating)
6644 {
6645 return Ok(());
6646 }
6647 if self.state.gate_ran {
6648 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6659 self.state.status = RunStatus::Blocked;
6660 self.state.save()?;
6661 }
6662 return Ok(());
6663 }
6664 let Some(winner) = self.state.winner().cloned() else {
6665 return Ok(());
6666 };
6667 self.state.status = RunStatus::Gating;
6668 let mut outcomes = self.run_gate(&winner).await?;
6669 loop {
6670 if verify_inconclusive(&outcomes) {
6681 self.state.save()?;
6682 return Ok(());
6683 }
6684 if outcomes.iter().all(CommandOutcome::ok) {
6685 break;
6686 }
6687 match self.gate_fix_round(&winner, &outcomes).await? {
6688 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6689 GateFix::Stop => break,
6690 GateFix::Defer => {
6691 self.state.save()?;
6692 return Ok(());
6693 }
6694 }
6695 }
6696 let passed = outcomes.iter().all(CommandOutcome::ok);
6697 self.state.gate = outcomes;
6698 self.state.gate_ran = true;
6699 self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6700 if !passed {
6701 self.state.status = RunStatus::Blocked;
6702 let spent = self.state.gate_fixes.len();
6703 self.state.event(
6704 "gate",
6705 if spent == 0 {
6706 "gate failed; not merging".to_owned()
6707 } else {
6708 format!("gate failed after {spent} gate-fix round(s); not merging")
6709 },
6710 );
6711 }
6712 self.state.save()?;
6713 Ok(())
6714 }
6715
6716 async fn run_pre_gate(&mut self, winner: &Candidate) {
6726 let commands = self.state.config.verify.pre_gate.clone();
6727 if commands.is_empty() {
6728 return;
6729 }
6730 let shell = self.state.config.shell();
6731 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6732 let (outcomes, _) = run_commands(
6733 &mut self.state,
6734 "pre_gate",
6735 "pre_gate",
6736 0,
6737 &shell,
6738 &commands,
6739 &winner.worktree,
6740 timeout,
6741 )
6742 .await;
6743 for o in &outcomes {
6744 if !o.ok() {
6745 tracing::warn!(
6746 "pre_gate `{}` failed ({:?}); the gate decides",
6747 o.command,
6748 o.code
6749 );
6750 }
6751 self.state.event(
6752 "pre_gate",
6753 format!(
6754 "`{}` -> {}",
6755 o.command,
6756 if o.ok() {
6757 "pass".to_owned()
6758 } else {
6759 format!(
6760 "FAIL ({:?})\n{}",
6761 o.code,
6762 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6763 )
6764 }
6765 ),
6766 );
6767 }
6768 self.state.pre_gate = outcomes;
6769 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6770 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6771 Ok(head) => {
6772 self.state
6773 .event("pre_gate", format!("committed mechanical fixes ({head})"));
6774 self.state.pre_gate_commit = Some(head);
6775 }
6776 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6777 },
6778 Ok(false) => {}
6779 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6780 }
6781 if let Err(e) = self.state.save() {
6782 tracing::warn!("could not persist the pre_gate record: {e:#}");
6783 }
6784 }
6785
6786 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6789 self.run_pre_gate(winner).await;
6790 let shell = self.state.config.shell();
6791 let gate_commands = self.state.config.verify.gate.clone();
6792 let outcomes = if gate_commands.is_empty() {
6801 Vec::new()
6802 } else {
6803 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6804 let cache_dir = self.state.config.cache_dir();
6805 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6806 let (outcomes, _) = with_cache_lease(
6807 &mut self.state,
6808 cache_dir.as_deref(),
6809 "gate",
6810 "gate",
6811 &winner.worktree,
6812 &head,
6813 timeout,
6814 "final gate",
6815 |state, budget| {
6816 let shell = shell.clone();
6817 let gate_commands = gate_commands.clone();
6818 let worktree = winner.worktree.clone();
6819 async move {
6820 let (outcomes, timed_out_pids) = run_commands(
6821 state,
6822 "gate",
6823 "gate",
6824 0,
6825 &shell,
6826 &gate_commands,
6827 &worktree,
6828 budget,
6829 )
6830 .await;
6831 (outcomes, false, timed_out_pids)
6832 }
6833 },
6834 )
6835 .await;
6836 outcomes
6837 };
6838 if outcomes.is_empty() {
6839 self.state.event(
6844 "gate",
6845 "no gate commands configured; nothing to check, passing",
6846 );
6847 }
6848 for o in &outcomes {
6849 self.state.event(
6850 "gate",
6851 format!(
6852 "`{}` -> {}",
6853 o.command,
6854 if o.ok() {
6855 "pass".to_owned()
6856 } else {
6857 format!(
6858 "FAIL ({:?})\n{}",
6859 o.code,
6860 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6861 )
6862 }
6863 ),
6864 );
6865 }
6866 Ok(outcomes)
6867 }
6868
6869 async fn gate_fix_round(
6882 &mut self,
6883 winner: &Candidate,
6884 outcomes: &[CommandOutcome],
6885 ) -> Result<GateFix> {
6886 let cap = self.state.config.graph.gate_fix_rounds;
6887 let spent = self.state.gate_fixes.len();
6888 if spent >= cap {
6889 if cap > 0 {
6890 self.state.event(
6891 "gate",
6892 format!("{spent} gate-fix round(s) spent and the gate still fails"),
6893 );
6894 }
6895 return Ok(GateFix::Stop);
6896 }
6897 if !gate_fixable(outcomes) {
6898 self.state.event(
6899 "gate",
6900 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6901 command or similar); not spending a fix round on it",
6902 );
6903 return Ok(GateFix::Stop);
6904 }
6905 let min_free = self.state.config.disk.min_free_bytes;
6906 if min_free > 0 {
6907 match crate::disk::free_bytes(&winner.worktree) {
6908 Ok(free) if crate::disk::enough_space(free, min_free) => {}
6909 Ok(free) => {
6910 self.state.event(
6911 "gate",
6912 format!(
6913 "only {free} bytes free ({min_free} required by `[disk] \
6914 min_free_bytes`); not spending a fix round on a failure the disk \
6915 may explain"
6916 ),
6917 );
6918 return Ok(GateFix::Stop);
6919 }
6920 Err(e) => {
6921 self.state.event(
6922 "gate",
6923 format!("free disk space could not be measured ({e:#}); no fix round"),
6924 );
6925 return Ok(GateFix::Stop);
6926 }
6927 }
6928 }
6929
6930 let attempt = spent + 1;
6931 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6932 let base = self.landing_base();
6933 let fix_prompt = prompt::gate_fix_in(
6934 &self.state.instruction,
6935 &failed,
6936 attempt,
6937 cap,
6938 &self.state.config.graph.language,
6939 &self.state.config.graph.github_language,
6940 );
6941 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6942 let sessions = self.state.config.graph.sessions;
6943 let artifacts = self.merge_repair_artifacts();
6944 self.state.event(
6945 "gate",
6946 format!("gate failed; gate-fix round {attempt} of {cap}"),
6947 );
6948 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6949 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6950 let (_, seat, out) = self
6951 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6952 prompt: fix_prompt.clone(),
6953 spec,
6954 seat,
6955 cwd: winner.worktree.clone(),
6956 timeout,
6957 allow_write: true,
6958 sessions,
6959 artifacts: artifacts.clone(),
6960 stem: format!("gate-fix-{attempt}"),
6961 handover: None,
6962 })
6963 .await;
6964 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6967 return Ok(GateFix::Defer);
6968 }
6969 let mut record = GateFixRecord {
6970 agent: seat.agent.clone(),
6971 failed,
6972 notes: String::new(),
6973 committed: false,
6974 error: None,
6975 };
6976 match out {
6977 AgentOutcome::Ok(o) => {
6978 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6981 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6982 }
6983 }
6984 AgentOutcome::Dropped(_) => {
6985 record.error = Some("the CLI dropped the stream".to_owned());
6986 }
6987 AgentOutcome::Quota(o) => {
6988 self.state.quota.push(QuotaLoss {
6989 seat: seat.key.clone(),
6990 node: "gate-fix".to_owned(),
6991 at: Timestamp::now(),
6992 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6993 });
6994 record.error = Some("rate limited (quota); fixer could not run".to_owned());
6995 }
6996 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6997 }
6998 self.state.seats.insert(seat.key.clone(), seat);
6999 if let Ok(r) = git::rescue_commit(
7000 &winner.worktree,
7001 &format!("magi: gate fix {attempt} (uncommitted work)"),
7002 )
7003 .await
7004 {
7005 self.state.note_withheld("gate-fix", &r.withheld);
7006 }
7007 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
7008 record.committed = after != before;
7009 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
7010 let note = record.error.clone();
7011 self.state.gate_fixes.push(record);
7012 self.state.save()?;
7013 if !changed {
7014 self.state.event(
7015 "gate",
7016 match note {
7017 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
7018 None => format!("gate-fix round {attempt}: the tree did not change"),
7019 },
7020 );
7021 return Ok(GateFix::Stop);
7022 }
7023 self.state.event(
7024 "gate",
7025 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
7026 );
7027
7028 let commands = self.state.config.verify.e2e.clone();
7029 if !commands.is_empty() {
7030 let shell = self.state.config.shell();
7031 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
7032 let cache_dir = self.state.config.cache_dir();
7033 let context = format!("gate-fix round {attempt}");
7034 let (e2e, _) = with_cache_lease(
7035 &mut self.state,
7036 cache_dir.as_deref(),
7037 "e2e",
7038 "e2e",
7039 &winner.worktree,
7040 &after,
7041 timeout,
7042 &context,
7043 |state, budget| {
7044 let shell = shell.clone();
7045 let commands = commands.clone();
7046 let context = context.clone();
7047 let worktree = winner.worktree.clone();
7048 async move {
7049 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
7050 .await
7051 }
7052 },
7053 )
7054 .await;
7055 if verify_inconclusive(&e2e) {
7056 return Ok(GateFix::Defer);
7057 }
7058 if e2e.iter().any(|o| !o.ok()) {
7059 self.state.event(
7060 "gate",
7061 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7062 );
7063 return Ok(GateFix::Stop);
7064 }
7065 }
7066 Ok(GateFix::Retry)
7067 }
7068
7069 async fn guarded_pr_message(
7076 &mut self,
7077 winner: &Candidate,
7078 facts: Option<&BranchFacts>,
7079 posting: bool,
7080 ) -> Result<Option<PrMessage>> {
7081 let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7082 let head = match final_head.as_deref() {
7083 Some(h) => crate::pr_outcome::FinalHead::At(h),
7084 None => crate::pr_outcome::FinalHead::Unknown,
7085 };
7086 let summarized = if posting {
7088 Some(self.summarize_change(winner, final_head.as_deref()).await)
7089 } else {
7090 None
7091 };
7092 if self.state.resource_block.is_some() {
7097 self.state.event(
7098 "pr-summary",
7099 "a shared resource was busy; nothing was published, resume to summarize again",
7100 );
7101 return Ok(None);
7102 }
7103 if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7104 anyhow::bail!(
7105 "the final commit moved during public description generation; retry before publishing"
7106 );
7107 }
7108 let note = match (&summarized, &self.state.change_summary) {
7109 (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7110 (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7111 (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7112 (None, _) => ChangeNote::NotAsked,
7113 };
7114 let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7115 if !posting {
7116 let identity = crate::scrub::Identity::current();
7117 return Ok(Some(PrMessage {
7118 title: crate::scrub::scrub(&pr.title, &identity),
7119 body: crate::scrub::scrub(&pr.body, &identity),
7120 }));
7121 }
7122 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7125 if let Some(known) = self
7126 .state
7127 .github_text
7128 .clone()
7129 .filter(|g| g.fingerprint == raw_fp)
7130 {
7131 return self.settle_withheld(winner, &pr, known).await;
7132 }
7133 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7134 let mut violations =
7135 crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7136 let identity = crate::scrub::Identity::current();
7137 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7138 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7139 && !violations.contains(&crate::github_text::Violation::SensitiveData)
7140 {
7141 violations.push(crate::github_text::Violation::SensitiveData);
7142 }
7143 let has_language = violations
7147 .iter()
7148 .any(|v| *v != crate::github_text::Violation::SensitiveData);
7149 if self.state.config.graph.github_text_guard && !violations.is_empty() {
7150 self.state.event(
7151 "github-text",
7152 if has_language {
7153 format!("description rejected: {violations:?}; requesting one rewrite")
7154 } else {
7155 format!("description rejected: {violations:?}; asking the owner")
7156 },
7157 );
7158 let mut rewritten = false;
7159 let mut rejected_rewrite = None;
7160 if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7161 {
7162 let output = text;
7163 if let Some(title) = summary_title(&output) {
7164 let raw_body = summary_without_title(&output);
7167 let clean_title = crate::scrub::scrub(&title, &identity);
7168 let mut body = crate::scrub::scrub(&raw_body, &identity);
7169 let redacted = clean_title != title || body != raw_body;
7170 let title = clean_title;
7171 let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7172 if !complete {
7173 self.state.event(
7174 "github-text",
7175 "description rewrite omitted required findings",
7176 );
7177 }
7178 if !body.trim().is_empty() {
7179 body.push_str("\n\n");
7180 body.push_str(&public_pr_evidence(&self.state, head));
7181 }
7182 let marker = format!("magi:run/{}", self.state.id);
7183 if !body.trim().is_empty() && !body.contains(&marker) {
7184 body.push_str(&format!("\n\n{marker}"));
7185 }
7186 let rewrite = PrMessage {
7187 title: title.clone(),
7188 body: body.clone(),
7189 };
7190 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7191 let mut failed = crate::github_text::check_for(
7192 &self.state.config.graph,
7193 &title,
7194 &body,
7195 redecision,
7196 );
7197 if (!crate::github_text::shareable(&title)
7198 || !crate::github_text::shareable(&body))
7199 && !failed.contains(&crate::github_text::Violation::SensitiveData)
7200 {
7201 failed.push(crate::github_text::Violation::SensitiveData);
7202 }
7203 if complete
7204 && !title.trim().is_empty()
7205 && !body.trim().is_empty()
7206 && !failed.is_empty()
7207 {
7208 rejected_rewrite = Some(crate::run::GithubTextRewrite {
7209 title: title.clone(),
7210 body: body.clone(),
7211 failed: failed
7212 .iter()
7213 .map(|v| crate::github_text::category(*v).to_owned())
7214 .collect(),
7215 });
7216 }
7217 if complete
7218 && !title.trim().is_empty()
7219 && !raw_body.trim().is_empty()
7220 && failed.is_empty()
7221 {
7222 decision = redecision;
7223 pr = PrMessage { title, body };
7224 rewritten = true;
7225 self.state
7226 .event("github-text", "description rewrite passed");
7227 if redacted {
7228 self.state.event(
7229 "github-text",
7230 "sensitive data removed from description rewrite",
7231 );
7232 }
7233 }
7234 }
7235 }
7236 if !rewritten {
7237 self.state.event(
7238 "github-text",
7239 "description rewrite unavailable or rejected; asking the owner",
7240 );
7241 if let Some(w) =
7242 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7243 {
7244 self.retire_github_text_question("the withheld text changed");
7247 let record = crate::run::GithubTextAsk {
7248 rewrite: rejected_rewrite,
7249 fingerprint: raw_fp,
7250 title: w.title,
7251 body: w.body,
7252 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7253 question: None,
7254 asks: 0,
7255 resolved: false,
7256 chosen_title: None,
7257 choice: None,
7258 };
7259 return self.settle_withheld(winner, &pr, record).await;
7260 }
7261 }
7262 }
7263 let (title, body) =
7264 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7265 Ok(Some(PrMessage { title, body }))
7266 }
7267
7268 async fn summarize_change(
7279 &mut self,
7280 winner: &Candidate,
7281 head: Option<&str>,
7282 ) -> std::result::Result<(), String> {
7283 let Some(head) = head else {
7284 return Err("the final commit could not be read".to_owned());
7285 };
7286 let remote = self.state.config.merge.remote.clone();
7287 let tracking = format!("{remote}/{}", self.state.base_branch);
7288 if !git::rev_exists(&winner.worktree, &tracking).await {
7289 return Err(format!("the remote base `{tracking}` could not be read"));
7290 }
7291 let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7292 Ok(b) if !b.is_empty() => b,
7293 _ => return Err("the merge base with the remote base could not be read".to_owned()),
7294 };
7295 let evidence = crate::pr_outcome::summary_evidence(
7296 &self.state,
7297 crate::pr_outcome::FinalHead::At(head),
7298 );
7299 let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7300 if self
7301 .state
7302 .change_summary
7303 .as_ref()
7304 .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7305 {
7306 return Ok(());
7307 }
7308 if self.state.change_summary.take().is_some() {
7309 self.state.event(
7310 "pr-summary",
7311 "the final diff or evidence changed; the earlier change summary was discarded",
7312 );
7313 }
7314 if let Err(e) = write_artifact(
7316 &self.state,
7317 &format!("public-description-{fingerprint}.md"),
7318 &format!("Final diff: {base}..{head}\n\n{evidence}"),
7319 ) {
7320 tracing::warn!("could not save public description evidence: {e:#}");
7321 }
7322 let chain = match agent::pick_chain(
7323 &self.state.config.agents,
7324 self.state.config.roles.synthesizer.as_ref(),
7325 &agent::installed,
7326 "synthesizer",
7327 ) {
7328 Ok(chain) => chain,
7329 Err(e) => {
7330 tracing::warn!("no agent for the change summary: {e:#}");
7331 self.state.event("pr-summary", "no summary agent available");
7332 return Err("no agent was available to summarize it".to_owned());
7333 }
7334 };
7335 let subjects = git::subjects(&winner.worktree, &base, head)
7336 .await
7337 .unwrap_or_default();
7338 let stat = git::diff_stat(&winner.worktree, &base, head)
7339 .await
7340 .unwrap_or_default();
7341 let stat: String = stat.chars().take(6_000).collect();
7342 let mut prompt = crate::prompt::change_summary_in(
7343 &base,
7344 head,
7345 &subjects,
7346 stat.trim(),
7347 &self.state.config.graph.github_language,
7348 );
7349 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."));
7350 let sessions = self.state.config.graph.sessions;
7351 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7352 let prompts = self.state.config.prompts.clone();
7353 let cache = self.state.config.cache_dir();
7354 let run = self.state.id.clone();
7355 for (i, spec) in chain.into_iter().enumerate() {
7356 let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7357 let job = SeatJob {
7358 spec: spec.clone(),
7359 seat,
7360 cwd: winner.worktree.clone(),
7361 prompt: prompt.clone(),
7362 timeout,
7363 allow_write: false,
7364 sessions,
7365 artifacts: agent::artifacts_dir(&self.state.dir()),
7366 stem: if i == 0 {
7367 "change-summary".to_owned()
7368 } else {
7369 format!("change-summary-{}", spec.id)
7370 },
7371 handover: None,
7372 };
7373 let ctx = WaveCtx {
7374 run: &run,
7375 node: "pr-summary",
7376 prompts: &prompts,
7377 cache: cache.as_deref(),
7378 round: None,
7379 carry_seats: false,
7380 pauses: Some((&self.pause, &self.interrupt)),
7381 };
7382 let (seat, outcome) =
7383 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7384 self.state.seats.insert(seat.key.clone(), seat);
7385 let text = match outcome {
7386 AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7387 AgentOutcome::ResourceBlocked(why) => {
7388 if self.state.resource_block.is_none() {
7391 self.state.resource_block = Some(crate::run::ResourceBlock {
7392 node: "pr-summary".to_owned(),
7393 kind: "shared resource".to_owned(),
7394 detail: why,
7395 at: Timestamp::now(),
7396 });
7397 }
7398 return Err("a shared resource was busy".to_owned());
7399 }
7400 _ => continue,
7401 };
7402 let blind = &self.state.config.blind;
7403 let Some(title) = summary_title(&text) else {
7404 continue;
7405 };
7406 let body: String = summary_without_title(&text)
7407 .lines()
7408 .filter(|l| !l.contains("magi:run/"))
7409 .collect::<Vec<_>>()
7410 .join("\n");
7411 let body = blind::sanitize_prose(body.trim(), blind);
7412 let title = blind::sanitize_prose(&title, blind);
7413 if body.trim().is_empty() || title.trim().is_empty() {
7414 continue;
7415 }
7416 if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7417 continue;
7418 }
7419 if git::rev_parse(&winner.worktree, "HEAD")
7421 .await
7422 .ok()
7423 .as_deref()
7424 != Some(head)
7425 {
7426 return Err("the branch moved while the summary was written".to_owned());
7427 }
7428 if git::merge_base(&winner.worktree, &tracking, head)
7429 .await
7430 .ok()
7431 .as_deref()
7432 != Some(base.as_str())
7433 || crate::pr_outcome::summary_evidence(
7434 &self.state,
7435 crate::pr_outcome::FinalHead::At(head),
7436 ) != evidence
7437 {
7438 return Err("the summary evidence changed while it was written".to_owned());
7439 }
7440 self.state.change_summary = Some(crate::run::ChangeSummary {
7441 head: head.to_owned(),
7442 base,
7443 evidence: fingerprint,
7444 title,
7445 body,
7446 });
7447 self.state.event(
7448 "pr-summary",
7449 format!("change summary written by {} for the final diff", spec.id),
7450 );
7451 if let Err(e) = self.state.save() {
7452 tracing::warn!("could not save the change summary: {e:#}");
7453 }
7454 return Ok(());
7455 }
7456 self.state
7457 .event("pr-summary", "no agent produced a change summary");
7458 Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7459 }
7460
7461 async fn rewrite_pr_text(
7469 &mut self,
7470 winner: &Candidate,
7471 pr: &PrMessage,
7472 violations: &[crate::github_text::Violation],
7473 ) -> Option<String> {
7474 let prompt = format!(
7475 "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{}",
7476 pr.title, pr.body
7477 );
7478 let sessions = self.state.config.graph.sessions;
7479 let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7480 let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7482 let key = format!("impl-{}", winner.label);
7483 if let Some(spec) = self
7484 .state
7485 .config
7486 .agents
7487 .iter()
7488 .find(|a| a.id == winner.agent)
7489 .cloned()
7490 && let Some(existing) = self.state.seats.get(&key)
7491 && existing.agent == spec.id
7492 && agent::has_session(spec.kind, existing, sessions)
7493 {
7494 attempts.push((spec, existing.clone(), true));
7495 } else {
7496 let chain = agent::pick_chain(
7497 &self.state.config.agents,
7498 self.state.config.roles.language_judge.as_ref(),
7499 &agent::installed,
7500 "language_judge",
7501 );
7502 match chain {
7503 Ok(chain) => {
7504 for spec in chain {
7505 let seat = crate::fixer::seat_for(
7506 &mut self.state,
7507 "github-text-rewrite",
7508 &spec.id,
7509 );
7510 attempts.push((spec, seat, false));
7511 }
7512 }
7513 Err(e) => {
7514 tracing::warn!("no agent for the description rewrite: {e:#}");
7515 self.state
7516 .event("github-text", "no rewrite agent available");
7517 return None;
7518 }
7519 }
7520 }
7521 let prompts = self.state.config.prompts.clone();
7522 let cache = self.state.config.cache_dir();
7523 let run = self.state.id.clone();
7524 for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7525 let job = SeatJob {
7526 spec: spec.clone(),
7527 seat,
7528 cwd: winner.worktree.clone(),
7529 prompt: prompt.clone(),
7530 timeout: retry_budget(timeout_full, resumed),
7531 allow_write: false,
7532 sessions,
7533 artifacts: agent::artifacts_dir(&self.state.dir()),
7534 stem: if i == 0 {
7535 "github-text-rewrite".to_owned()
7536 } else {
7537 format!("github-text-rewrite-{}", spec.id)
7538 },
7539 handover: None,
7540 };
7541 let ctx = WaveCtx {
7542 run: &run,
7543 node: "github-text",
7544 prompts: &prompts,
7545 cache: cache.as_deref(),
7546 round: None,
7547 carry_seats: false,
7548 pauses: Some((&self.pause, &self.interrupt)),
7549 };
7550 let (seat, outcome) =
7551 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7552 self.state.seats.insert(seat.key.clone(), seat);
7553 match outcome {
7554 AgentOutcome::Ok(o)
7555 if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7556 {
7557 return Some(o.text);
7558 }
7559 AgentOutcome::ResourceBlocked(_) => return None,
7560 _ => {}
7561 }
7562 }
7563 None
7564 }
7565
7566 fn retire_github_text_question(&mut self, why: &str) {
7568 let Some(id) = self
7569 .state
7570 .github_text
7571 .as_ref()
7572 .and_then(|g| g.question.clone())
7573 else {
7574 return;
7575 };
7576 let _ = ask::Questions::open().update(&id, |q| {
7577 q.abandon(why);
7578 Ok(())
7579 });
7580 }
7581
7582 async fn settle_withheld(
7586 &mut self,
7587 winner: &Candidate,
7588 pr: &PrMessage,
7589 mut g: crate::run::GithubTextAsk,
7590 ) -> Result<Option<PrMessage>> {
7591 use crate::github_text as gt;
7592 let store = ask::Questions::open();
7593 let timeout = self.state.config.graph.answer_timeout;
7594 if !g.resolved {
7595 let mut standing = g
7596 .question
7597 .as_ref()
7598 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7599 if let Some(q) = standing.as_ref()
7600 && q.status.open()
7601 && gt::expired(q, timeout)
7602 {
7603 let why = format!("no answer within {}s of asking", timeout.max(1));
7604 standing = store
7605 .update(&q.id, |q| {
7606 q.abandon(&why);
7607 Ok(())
7608 })
7609 .ok()
7610 .map(|(q, ())| q);
7611 }
7612 let mut ask_again: Option<Option<Vec<String>>> = None;
7613 match standing {
7614 None if g.asks <= 1 => {
7617 g.asks = 0;
7618 ask_again = Some(None);
7619 }
7620 None => {
7621 g.resolved = true;
7622 g.chosen_title = None;
7623 g.choice = Some(GithubTextChoice::Fallback);
7624 }
7625 Some(q) if q.status.open() => {
7626 self.park_on_github_text(&g)?;
7627 return Ok(None);
7628 }
7629 Some(q) => match gt::read_reply(&q) {
7630 gt::Reply::Fallback => {
7631 g.resolved = true;
7632 g.chosen_title = None;
7633 g.choice = Some(GithubTextChoice::Fallback);
7634 }
7635 gt::Reply::Original => {
7636 g.resolved = true;
7637 g.chosen_title = None;
7638 g.choice = Some(GithubTextChoice::Original);
7639 }
7640 gt::Reply::Rewrite => {
7641 g.resolved = true;
7642 g.chosen_title = None;
7643 g.choice = Some(if g.rewrite.is_some() {
7644 GithubTextChoice::Rewrite
7645 } else {
7646 GithubTextChoice::Fallback
7647 });
7648 }
7649 gt::Reply::Title(text) => {
7650 let first = text
7651 .lines()
7652 .map(str::trim)
7653 .find(|l| !l.is_empty())
7654 .unwrap_or("");
7655 let decision = if first.is_empty() {
7656 None
7657 } else {
7658 let candidate = PrMessage {
7659 title: first.to_owned(),
7660 body: String::new(),
7661 };
7662 self.judge_pr_language(&winner.worktree, &candidate).await
7663 };
7664 match gt::vet_title(
7665 &text,
7666 decision,
7667 self.state.config.graph.github_english(),
7668 ) {
7669 Ok(title) => {
7670 g.resolved = true;
7671 g.chosen_title = Some(title);
7672 g.choice = Some(GithubTextChoice::MyTitle);
7673 }
7674 Err(cats) if g.asks < 2 => {
7675 ask_again = Some(Some(
7676 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7677 ))
7678 }
7679 Err(_) => {
7680 g.resolved = true;
7681 g.chosen_title = None;
7682 g.choice = Some(GithubTextChoice::Fallback);
7683 }
7684 }
7685 }
7686 },
7687 }
7688 if let Some(rejected) = ask_again {
7689 let w = gt::Withheld {
7692 title: g.title,
7693 body: g.body,
7694 categories: g.categories.clone(),
7695 };
7696 let artifact = match crate::run::write_artifact(
7699 &self.state,
7700 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
7701 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7702 ) {
7703 Ok(p) => Some(p),
7704 Err(e) => {
7705 tracing::warn!("could not write the withheld-text artifact: {e:#}");
7706 None
7707 }
7708 };
7709 let (detail, comparison) = gt::question_content(
7710 &w,
7711 &pr.title,
7712 &pr.body,
7713 &self.state.id,
7714 rejected.as_deref(),
7715 artifact.as_deref(),
7716 g.rewrite.as_ref(),
7717 );
7718 let mut q = ask::Question::new(
7719 self.state.id.clone(),
7720 gt::ASK_NODE.to_owned(),
7721 gt::ASK_SEAT.to_owned(),
7722 gt::question_summary(&self.state.config.graph.language, &w),
7723 detail,
7724 gt::question_choices(&w, g.rewrite.as_ref()),
7725 );
7726 q.posting_comparison = Some(comparison);
7727 q.answer_timeout = timeout;
7728 g.question = Some(q.id.clone());
7731 g.asks += 1;
7732 self.state.github_text = Some(g.clone());
7733 self.state.event(
7734 "github-text",
7735 format!("asking the owner about the withheld text ({})", q.short()),
7736 );
7737 self.state.save()?;
7738 store
7739 .put(&mut q)
7740 .context("file the posting-gate question")?;
7741 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7742 tracing::warn!(
7743 "could not notify about posting-gate question {}: {e:#}",
7744 q.short()
7745 );
7746 }
7747 self.park_on_github_text(&g)?;
7748 return Ok(None);
7749 }
7750 }
7751 let resolved = g.chosen_title.clone();
7752 let original = g.choice == Some(GithubTextChoice::Original);
7753 self.state.event(
7755 "github-text",
7756 if original {
7757 format!(
7758 "posting the original text for: {}",
7759 match (g.title, g.body) {
7760 (true, true) => "title, description",
7761 (true, false) => "title",
7762 _ => "description",
7763 }
7764 )
7765 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7766 "posting the saved rewrite title and description".to_owned()
7767 } else if resolved.is_some() {
7768 "posting the owner's replacement title".to_owned()
7769 } else {
7770 "posting the neutral text for the withheld fields".to_owned()
7771 },
7772 );
7773 self.state.github_text = Some(g.clone());
7774 self.state.save()?;
7775 if g.choice == Some(GithubTextChoice::Rewrite)
7776 && let Some(r) = g.rewrite.as_ref()
7777 {
7778 return Ok(Some(PrMessage {
7779 title: r.title.clone(),
7780 body: r.body.clone(),
7781 }));
7782 }
7783 let w = gt::Withheld {
7784 title: g.title,
7785 body: g.body,
7786 categories: g.categories.clone(),
7787 };
7788 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7789 if let Some(t) = resolved {
7790 title = t;
7791 }
7792 let english = gt::LanguageDecision {
7794 title_english: true,
7795 body_english: true,
7796 body_complete: true,
7797 };
7798 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7799 if original {
7800 if g.title {
7803 title = pr.title.clone();
7804 }
7805 if g.body {
7806 body = pr.body.clone();
7807 }
7808 }
7809 Ok(Some(PrMessage { title, body }))
7810 }
7811
7812 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7813 self.state.github_text = Some(g.clone());
7814 self.state.end_pass_parked(ParkCause::Question);
7815 self.state.parked = true;
7816 self.state.event(
7817 "github-text",
7818 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7819 );
7820 self.state.save()
7821 }
7822
7823 async fn judge_pr_language(
7826 &mut self,
7827 cwd: &Path,
7828 pr: &PrMessage,
7829 ) -> Option<crate::github_text::LanguageDecision> {
7830 self.state.config.roles.language_judge.as_ref()?;
7831 if !self.state.config.graph.language_gate() {
7832 return None;
7833 }
7834 let decision =
7835 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7836 self.state.event(
7837 "github-text",
7838 if decision.is_some() {
7839 "language decided by the language judge"
7840 } else {
7841 "language judge unavailable; using built-in heuristics"
7842 },
7843 );
7844 decision
7845 }
7846
7847 async fn merge(&mut self) -> Result<()> {
7848 if self
7863 .state
7864 .base_sync
7865 .as_ref()
7866 .is_some_and(|s| s.conflict.is_some())
7867 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7868 != Some(RunStatus::Gating)
7869 || !self.state.gate_status().ok()
7878 {
7879 return Ok(());
7880 }
7881 if self.state.merge.is_some() && !self.state.pr_publication_pending {
7890 return Ok(());
7891 }
7892 let Some(winner) = self.state.winner().cloned() else {
7893 return Ok(());
7894 };
7895 let repo = self.state.repo.clone();
7896 let base = self.state.base_branch.clone();
7897 let mode = self.state.config.merge.mode;
7898 let style = self.state.config.merge.style;
7899 let facts = if is_review_run(&self.state) {
7900 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7901 let start = review_base(
7902 &repo,
7903 &self.state.config.merge.remote,
7904 &base,
7905 &self.state.base_commit,
7906 &winner.branch,
7907 )
7908 .await;
7909 branch_facts(&repo, &start, &winner.branch).await
7910 } else {
7911 None
7912 };
7913 let leaked = if is_review_run(&self.state) {
7916 leaked_subjects(&self.state, &winner.branch).await
7917 } else {
7918 Some(Vec::new())
7919 };
7920 let Some(pr) = self
7924 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7925 .await?
7926 else {
7927 return Ok(());
7928 };
7929 let message = pr.commit_message();
7930
7931 let outcome = match mode {
7932 MergeMode::None => MergeOutcome {
7933 mode,
7934 ok: true,
7935 detail: manual_merge_command(style, &repo, &winner.branch, &message),
7936 empty: false,
7937 },
7938 MergeMode::Pr | MergeMode::Local
7939 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7940 {
7941 MergeOutcome {
7942 mode,
7943 ok: false,
7944 detail: empty_candidate_detail(&self.state, &base),
7945 empty: true,
7946 }
7947 }
7948 MergeMode::Local => {
7949 let on = git::current_branch(&repo).await?;
7950 if on.as_deref() != Some(base.as_str()) {
7951 MergeOutcome {
7952 mode,
7953 ok: false,
7954 detail: format!(
7955 "{} has {} checked out, not the base branch {base}",
7956 repo.display(),
7957 on.unwrap_or_else(|| "a detached HEAD".to_owned())
7958 ),
7959 empty: false,
7960 }
7961 } else if !git::is_clean(&repo).await? {
7962 MergeOutcome {
7963 mode,
7964 ok: false,
7965 detail: format!("{} is dirty; refusing to merge", repo.display()),
7966 empty: false,
7967 }
7968 } else {
7969 let out = match style {
7970 MergeStyle::Merge => {
7971 git::merge_no_ff(&repo, &winner.branch, &message).await?
7972 }
7973 MergeStyle::Squash => {
7974 git::merge_squash(&repo, &winner.branch, &message).await?
7975 }
7976 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7977 };
7978 MergeOutcome {
7979 mode,
7980 ok: out.ok(),
7981 detail: if out.ok() { out.stdout } else { out.stderr },
7982 empty: false,
7983 }
7984 }
7985 }
7986 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7987 self.state.pr_publication_pending = true;
7988 self.state.save()?;
7989 let out =
7990 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7991 self.state.pr_publication_pending = out.is_err();
7992 MergeOutcome {
7993 mode,
7994 ok: out.is_ok(),
7995 detail: match out {
7996 Ok(url) => url,
7997 Err(e) => format!("{e:#}"),
7998 },
7999 empty: false,
8000 }
8001 }
8002 MergeMode::Pr => {
8003 let remote = self.state.config.merge.remote.clone();
8004 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
8005 if !pushed.ok() {
8006 MergeOutcome {
8007 mode,
8008 ok: false,
8009 detail: pushed.stderr,
8010 empty: false,
8011 }
8012 } else {
8013 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
8019 let out = match pr_merge_plan(found) {
8020 PrPlan::Create => {
8021 gh_pr_create(
8022 &winner.worktree,
8023 &base,
8024 &winner.branch,
8025 &pr.title,
8026 &pr.body,
8027 )
8028 .await
8029 }
8030 PrPlan::Adopt { url, title } => {
8031 self.state
8032 .event("merge", format!("Pr: adopted open pull request {url}"));
8033 if self.state.github_text.as_ref().is_some_and(|g| {
8034 g.resolved
8035 && g.choice == Some(GithubTextChoice::Rewrite)
8036 && g.rewrite.is_some()
8037 }) {
8038 land::set_pr_text_verbatim(
8041 &winner.worktree,
8042 &url,
8043 &pr.title,
8044 &pr.body,
8045 )
8046 .await
8047 .map(|()| url)
8048 } else {
8049 if title != pr.title
8050 && (!is_review_run(&self.state)
8051 || leaked
8052 .as_deref()
8053 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
8054 && let Err(e) =
8055 if self.state.github_text.as_ref().is_some_and(|g| {
8056 g.resolved
8057 && g.title
8058 && g.choice == Some(GithubTextChoice::Original)
8059 }) {
8060 land::set_pr_title_verbatim(
8062 &winner.worktree,
8063 &url,
8064 &pr.title,
8065 )
8066 .await
8067 } else {
8068 land::set_pr_title(
8069 &mut self.state,
8070 &winner.worktree,
8071 &url,
8072 &pr.title,
8073 )
8074 .await
8075 }
8076 {
8077 tracing::warn!("could not refresh title of {url}: {e:#}");
8078 self.state
8079 .event("merge", format!("Pr: title refresh failed: {e:#}"));
8080 }
8081 Ok(url)
8082 }
8083 }
8084 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8085 };
8086 match out {
8087 Ok(url) => MergeOutcome {
8088 mode,
8089 ok: true,
8090 detail: url,
8091 empty: false,
8092 },
8093 Err(e) => MergeOutcome {
8094 mode,
8095 ok: false,
8096 detail: e.to_string(),
8097 empty: false,
8098 },
8099 }
8100 }
8101 }
8102 };
8103
8104 self.state.status = match (mode, outcome.ok) {
8105 (MergeMode::None, _) => RunStatus::Ready,
8106 (_, true) => RunStatus::Merged,
8107 (_, false) => RunStatus::Blocked,
8108 };
8109 self.state.event(
8110 "merge",
8111 format!(
8112 "{:?}: {}",
8113 mode,
8114 outcome.detail.lines().next().unwrap_or("")
8115 ),
8116 );
8117 self.state.merge = Some(outcome);
8118 self.state.save()?;
8119
8120 if self.state.config.graph.land
8126 && mode == MergeMode::Pr
8127 && self.state.status == RunStatus::Merged
8128 {
8129 self.run_land().await?;
8130 }
8131 self.settle_questions();
8136 Ok(())
8137 }
8138
8139 async fn run_land(&mut self) -> Result<()> {
8150 crate::pr_head::ensure_owner(&self.state)?;
8151 let url = self
8152 .state
8153 .merge
8154 .as_ref()
8155 .map(|m| m.detail.clone())
8156 .unwrap_or_default();
8157 let url = url.lines().next().unwrap_or("").trim().to_owned();
8158 if !url.starts_with("http") {
8159 return Ok(());
8160 }
8161 loop {
8164 match land::land(&mut self.state, &url).await {
8165 Ok(_)
8166 if self
8167 .state
8168 .merge_repairs
8169 .last()
8170 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8171 {
8172 if self.repair_merge_review().await? {
8173 continue;
8174 }
8175 }
8176 Ok(pr) if self.state.parked => {
8177 let _ = pr;
8181 }
8182 Ok(_) if self.state.merge_decision.as_ref().is_some_and(|d| d.closed) => {
8183 self.state.status = RunStatus::Superseded;
8184 self.state.save()?;
8185 }
8186 Ok(pr) => {
8187 self.state.status = match pr.state {
8188 land::PrLifecycle::Merged => RunStatus::Merged,
8189 _ => RunStatus::Blocked,
8190 };
8191 if bump::should_release_bump(self.state.status)
8198 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8199 {
8200 self.state
8206 .event("bump", format!("release bump skipped: {e:#}"));
8207 }
8208 if self.state.status == RunStatus::Merged {
8211 crate::followup::after_merge(&mut self.state, &pr.url).await;
8212 }
8213 self.state.save()?;
8214 }
8215 Err(e) => {
8216 self.state.status = RunStatus::Blocked;
8217 self.state.event("land", format!("gave up: {e}"));
8218 self.state.save()?;
8219 }
8220 }
8221 break;
8222 }
8223 Ok(())
8224 }
8225
8226 async fn repair_merge_review(&mut self) -> Result<bool> {
8229 use crate::run::MergeRepairPhase as Phase;
8230 crate::pr_head::ensure_owner(&self.state)?;
8231 let i = self.state.merge_repairs.len() - 1;
8232 let request = self.state.merge_repairs[i].clone();
8233 if request.phase == Phase::Fixing {
8234 let restored = match self.state.winner() {
8235 Some(winner) => {
8236 crate::recovery::restore_repair(&winner.worktree, &request.head).await
8237 }
8238 None => Err(anyhow::anyhow!("repair has no winning branch")),
8239 };
8240 let why = match restored {
8241 Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8242 Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8243 };
8244 self.hold_merge_repair(i, why)?;
8245 return Ok(false);
8246 }
8247 let failure = if i >= self.state.config.graph.land_rounds
8248 || self.state.config.graph.review_rounds == 0
8249 {
8250 Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8251 } else {
8252 None
8253 };
8254 if let Some(why) = failure {
8255 self.hold_merge_repair(i, why)?;
8256 return Ok(false);
8257 }
8258 if request.phase == Phase::Requested {
8259 let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8260 Ok(pr) => pr,
8261 Err(e) => {
8262 self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8263 return Ok(false);
8264 }
8265 };
8266 self.state.merge_repairs[i].phase = Phase::Fixing;
8267 self.state.land_approval = None;
8268 self.state.land_armed_head = None;
8269 self.state.save()?;
8270 let evidence = serde_json::to_string_pretty(&request)?;
8273 let reason = format!(
8274 "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}"
8275 );
8276 let budget = self.state.config.graph.land_rounds;
8277 let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8278 match result {
8279 Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8280 Ok(land::Fixed::Failed(why)) => {
8281 self.hold_merge_repair(
8282 i,
8283 format!(
8284 "repair failed: {why}; inspect the branch and run an explicit review"
8285 ),
8286 )?;
8287 return Ok(false);
8288 }
8289 Err(e) => {
8290 self.hold_merge_repair(
8291 i,
8292 format!(
8293 "repair failed: {e:#}; inspect the branch and run an explicit review"
8294 ),
8295 )?;
8296 return Ok(false);
8297 }
8298 }
8299 self.begin_merge_repair_review(i)?;
8303 }
8304 self.review_loop().await?;
8305 self.gate().await?;
8306 if self.resource_halt()? || self.state.parked {
8307 return Ok(false);
8308 }
8309 if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8310 != Some(RunStatus::Gating)
8311 || !self.state.gate_status().ok()
8312 {
8313 self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8314 return Ok(false);
8315 }
8316 let winner = self
8319 .state
8320 .winner()
8321 .cloned()
8322 .context("repair has no winning branch")?;
8323 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8324 if self.state.gate_head.as_deref() != Some(after.as_str())
8325 || !git::is_clean(&winner.worktree).await?
8326 {
8327 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())?;
8328 return Ok(false);
8329 }
8330 let expected = self
8331 .state
8332 .pr_pushed_head
8333 .as_deref()
8334 .unwrap_or(&request.head)
8335 .to_owned();
8336 let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8337 let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8340 let refspec = format!("{after}:refs/heads/{public}");
8341 let push = git::git_raw(
8342 &winner.worktree,
8343 &["push", &lease, &self.state.config.merge.remote, &refspec],
8344 )
8345 .await;
8346 match push {
8347 Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8348 Ok(out) => {
8349 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8350 return Ok(false);
8351 }
8352 Err(e) => {
8353 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8354 return Ok(false);
8355 }
8356 }
8357 self.state.merge_repairs[i].phase = Phase::Complete;
8358 self.state.status = RunStatus::Landing;
8359 self.state.event(
8360 "repair",
8361 "new review and gate completed; fresh owner merge decision required",
8362 );
8363 self.state.save()?;
8364 Ok(true)
8365 }
8366
8367 fn merge_repair_artifacts(&self) -> PathBuf {
8368 let artifacts = agent::artifacts_dir(&self.state.dir());
8369 match self
8370 .state
8371 .merge_repairs
8372 .last()
8373 .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8374 {
8375 Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8376 None => artifacts,
8377 }
8378 }
8379
8380 fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8381 self.state.reviews.clear();
8382 self.state.gate.clear();
8383 self.state.gate_ran = false;
8384 self.state.gate_head = None;
8385 self.state.gate_fixes.clear();
8386 self.state.pre_gate.clear();
8387 self.state.pre_gate_commit = None;
8388 self.state.contested_handoff = None;
8389 self.state.land_reruns = None;
8390 self.state.land_approval = None;
8391 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8392 self.state.status = RunStatus::Reviewing;
8393 self.state.save()
8394 }
8395
8396 fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8397 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8398 self.state.merge_repairs[index].failure = Some(why.clone());
8399 self.state.status = RunStatus::Blocked;
8400 self.state.event("repair", why);
8401 self.state.save()
8402 }
8403
8404 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8408 if let Some(existing) = self.state.seats.get(key)
8409 && existing.agent == agent
8410 {
8411 return existing.clone();
8412 }
8413 let fresh = if self.state.seats.contains_key(key) {
8416 handover_seat(key, agent, self.state.next_seat_seed())
8417 } else {
8418 SeatState::new(key, agent, self.state.seed)
8419 };
8420 self.state.seats.insert(key.to_owned(), fresh.clone());
8421 fresh
8422 }
8423
8424 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8430 match self.state.seats.get(key) {
8431 Some(s) if s.agent != spec.id => {
8432 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8433 }
8434 _ => spec,
8435 }
8436 }
8437
8438 fn view(&self, c: &Candidate) -> CandidateView {
8440 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8441 .unwrap_or_default();
8442 let (patch, _) = blind::sanitize_patch(
8443 &format!("candidate {} patch", c.label),
8444 &raw,
8445 &self.state.config.blind,
8446 );
8447 CandidateView {
8448 label: c.label,
8449 branch: c.branch.clone(),
8450 summary: c.summary.clone(),
8451 stat: c.stat.clone(),
8452 patch,
8453 }
8454 }
8455
8456 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8458 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8459 prompt::judge(
8460 "(see above)",
8461 &views,
8462 self.roles.judges.len(),
8463 base_short,
8464 "en",
8465 )
8466 }
8467
8468 fn vote_prompt_full(
8474 &self,
8475 j: usize,
8476 labels: &[char],
8477 language: &str,
8478 candidates: &[Candidate],
8479 base_short: &str,
8480 ) -> String {
8481 let mut text = format!(
8482 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8483 prompt::final_vote(labels, language),
8484 self.state.instruction,
8485 self.candidate_block(candidates, base_short)
8486 );
8487 if let Some(own) = self
8488 .state
8489 .judgements
8490 .get(j)
8491 .filter(|r| !r.ranking.is_empty())
8492 {
8493 let reasons = own
8494 .reasons
8495 .iter()
8496 .map(|(k, v)| format!("- {k}: {v}"))
8497 .collect::<Vec<_>>()
8498 .join("\n");
8499 text.push_str(&format!(
8500 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8501 own.ranking.iter().collect::<String>(),
8502 if reasons.is_empty() {
8503 ""
8504 } else {
8505 ", because:\n"
8506 }
8507 ));
8508 }
8509 if !self.state.deliberation.is_empty() {
8510 text.push_str("\n# What was argued before this vote\n");
8511 for t in self.transcript(&[], j) {
8512 text.push_str(&format!(
8513 "\n## {}{}\n\n{}\n",
8514 t.who,
8515 if t.is_self { " (you)" } else { "" },
8516 t.body.trim()
8517 ));
8518 }
8519 }
8520 text
8521 }
8522
8523 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8530 let mut turns = Vec::new();
8531 for j in &self.state.judgements {
8532 if j.ranking.is_empty() {
8533 continue;
8534 }
8535 let reasons = j
8536 .reasons
8537 .iter()
8538 .map(|(k, v)| format!("- {k}: {v}"))
8539 .collect::<Vec<_>>()
8540 .join("\n");
8541 turns.push(Turn {
8542 who: format!("Judge {} (opening ranking)", j.judge),
8543 is_self: j.judge == self_idx + 1,
8544 body: format!(
8545 "Ranked {}{}{reasons}",
8546 j.ranking.iter().collect::<String>(),
8547 if reasons.is_empty() {
8548 ""
8549 } else {
8550 ", because:\n"
8551 }
8552 ),
8553 });
8554 }
8555 for t in self
8556 .state
8557 .deliberation
8558 .iter()
8559 .flat_map(|r| r.turns.iter())
8560 .chain(current)
8561 {
8562 turns.push(Turn {
8563 who: format!("Judge {}", t.judge),
8564 is_self: t.judge == self_idx + 1,
8565 body: t.body.clone(),
8566 });
8567 }
8568 turns
8569 }
8570}
8571
8572fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8574 agent::has_session(spec.kind, seat, sessions)
8575}
8576
8577fn next_untried_in_roster<'a>(
8598 roster: &'a [AgentSpec],
8599 start: usize,
8600 tried: &BTreeSet<String>,
8601) -> Option<&'a AgentSpec> {
8602 roster
8603 .get(start + 1..)?
8604 .iter()
8605 .find(|s| !tried.contains(&s.id))
8606}
8607
8608fn pick_successor<'a>(
8629 roster: &'a [AgentSpec],
8630 start: usize,
8631 tried: &BTreeSet<String>,
8632 carried: Option<&BTreeSet<String>>,
8633 others: &BTreeSet<String>,
8634) -> Option<&'a AgentSpec> {
8635 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8636 roster
8637 .get(start + 1..)
8638 .and_then(|tail| {
8639 tail.iter()
8640 .filter(free)
8641 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8642 })
8643 .or_else(|| {
8644 carried
8645 .is_some()
8646 .then(|| roster.iter().find(free))
8647 .flatten()
8648 })
8649 .or_else(|| match carried {
8650 Some(c) => next_for_seat(roster, start, tried, c),
8651 None => next_untried_in_roster(roster, start, tried),
8652 })
8653}
8654
8655fn next_for_seat<'a>(
8665 roster: &'a [AgentSpec],
8666 start: usize,
8667 round_tried: &BTreeSet<String>,
8668 carried_failed: &BTreeSet<String>,
8669) -> Option<&'a AgentSpec> {
8670 roster
8671 .get(start + 1..)?
8672 .iter()
8673 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8674 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8675}
8676
8677fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8683 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8684 return spec;
8685 };
8686 let ok = |id: &str| !h.failed.contains(id);
8687 if let Some(last) = h.last_ok.as_deref()
8688 && ok(last)
8689 && let Some(s) = roster.iter().find(|s| s.id == last)
8690 {
8691 return s.clone();
8692 }
8693 if ok(&spec.id) {
8694 return spec;
8695 }
8696 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8697 roster
8698 .get(from + 1..)
8699 .into_iter()
8700 .flatten()
8701 .chain(roster.iter())
8702 .find(|s| ok(&s.id))
8703 .cloned()
8704 .unwrap_or(spec)
8705}
8706
8707pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8711 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8712}
8713
8714const TIMED_OUT: &str = "timed out";
8717
8718impl FailClass {
8719 fn of(out: &AgentOutcome) -> Option<Self> {
8721 match out {
8722 AgentOutcome::Ok(_) => None,
8723 AgentOutcome::Quota(_) => Some(Self::Quota),
8724 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8725 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8726 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8727 AgentOutcome::ResourceBlocked(_) => None,
8730 }
8731 }
8732
8733 fn stem_word(&self) -> &'static str {
8735 match self {
8736 Self::Quota => "quota",
8737 _ => "handover",
8738 }
8739 }
8740}
8741
8742fn failure_signature(msg: &str) -> String {
8746 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8747 let mut out = Vec::new();
8748 for word in line.split_whitespace() {
8749 if word.contains('/') || word.contains('\\') {
8750 out.push("<path>".to_owned());
8751 continue;
8752 }
8753 let mut w = String::new();
8754 let mut in_digits = false;
8755 for c in word.chars() {
8756 if c.is_ascii_digit() {
8757 if !in_digits {
8758 w.push('#');
8759 }
8760 in_digits = true;
8761 } else {
8762 in_digits = false;
8763 w.push(c);
8764 }
8765 }
8766 out.push(w);
8767 }
8768 out.join(" ").chars().take(120).collect()
8769}
8770
8771fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8778 match cur {
8779 FailClass::Quota | FailClass::Timeout => true,
8780 FailClass::Other(_) => prev != Some(cur),
8781 }
8782}
8783
8784fn fail_reason(out: &AgentOutcome) -> String {
8786 match out {
8787 AgentOutcome::Ok(_) => String::new(),
8788 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8789 AgentOutcome::Dropped(o) => format!(
8790 "the CLI dropped the stream ({})",
8791 o.dropped
8792 .as_ref()
8793 .map(|d| d.why.as_str())
8794 .unwrap_or("it ended without delivering its answer")
8795 ),
8796 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8797 e.lines().next().unwrap_or("").chars().take(160).collect()
8798 }
8799 }
8800}
8801
8802pub(crate) fn record_handover(
8805 state: &mut RunState,
8806 node: &str,
8807 seat: &str,
8808 from: &str,
8809 to: &str,
8810 class: &FailClass,
8811 reason: &str,
8812) {
8813 let message = if *class == FailClass::Quota {
8814 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8815 } else {
8816 format!("{seat}: handed over {from} -> {to} ({reason})")
8817 };
8818 state.event(node, message);
8819 state.handovers.push(Handover {
8820 at: Timestamp::now(),
8821 node: node.to_owned(),
8822 seat: seat.to_owned(),
8823 from: from.to_owned(),
8824 to: to.to_owned(),
8825 reason: reason.to_owned(),
8826 });
8827}
8828
8829fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8843 commands.iter().any(|c| c.exit_code.is_none())
8844}
8845
8846fn verified_noop_claim(
8859 usable: bool,
8860 commands: &[agent::CommandEvidence],
8861 text: &str,
8862) -> Option<String> {
8863 (usable && !has_unconfirmed_command(commands))
8864 .then(|| verdict::verified_noop(text))
8865 .flatten()
8866}
8867
8868fn short(commit: &str) -> String {
8869 commit.chars().take(7).collect()
8870}
8871
8872fn make_executable(path: &Path) -> Result<()> {
8873 #[cfg(unix)]
8874 {
8875 use std::os::unix::fs::PermissionsExt as _;
8876 let mut perms = std::fs::metadata(path)?.permissions();
8877 perms.set_mode(0o755);
8878 std::fs::set_permissions(path, perms)?;
8879 }
8880 #[cfg(not(unix))]
8881 {
8882 let _ = path;
8883 }
8884 Ok(())
8885}
8886
8887struct WaveCtx<'a> {
8894 run: &'a str,
8897 node: &'a str,
8899 prompts: &'a Prompts,
8900 cache: Option<&'a Path>,
8902 round: Option<usize>,
8905 carry_seats: bool,
8909 pauses: Option<(&'a Pause, &'a Pause)>,
8912}
8913
8914async fn run_one(
8916 job: SeatJob,
8917 sem: Arc<Semaphore>,
8918 ctx: &WaveCtx<'_>,
8919 state: &mut RunState,
8920 attempt: usize,
8921) -> (SeatState, AgentOutcome) {
8922 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8923 .await
8924 .pop()
8925 .expect("one job in, one result out");
8926 (seat, out)
8927}
8928
8929async fn wave(
8935 jobs: Vec<SeatJob>,
8936 sem: Arc<Semaphore>,
8937 ctx: &WaveCtx<'_>,
8938 state: &mut RunState,
8939 attempt: usize,
8940) -> Vec<(usize, SeatState, AgentOutcome)> {
8941 let WaveCtx {
8942 run,
8943 node,
8944 prompts,
8945 cache,
8946 round,
8947 carry_seats: _,
8948 pauses,
8949 } = *ctx;
8950 for job in &jobs {
8951 state.seat_started(node, &job.seat.key, job.timeout, attempt);
8952 }
8953 if let Err(e) = state.save() {
8954 tracing::warn!("could not persist in-progress seats: {e:#}");
8959 }
8960 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8976 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8980 let cache_guard = if let Some(cache_dir) = cache {
8981 if jobs_had_a_writer {
8982 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8983 let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8984 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8985 {
8986 Ok(Some(g)) => Some(g),
8987 Ok(None) => {
8990 state.resource_block = Some(crate::run::ResourceBlock {
8991 node: node.to_owned(),
8992 kind: "shared build cache".to_owned(),
8993 detail: "the run is parking".to_owned(),
8994 at: Timestamp::now(),
8995 });
8996 return block_wave(
8997 jobs,
8998 state,
8999 node,
9000 attempt,
9001 "parked while waiting for the shared build cache",
9002 );
9003 }
9004 Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
9007 Err(e) => {
9008 let why = format!("{e:#}");
9012 state.resource_block = Some(crate::run::ResourceBlock {
9013 node: node.to_owned(),
9014 kind: "shared build cache".to_owned(),
9015 detail: why.clone(),
9016 at: Timestamp::now(),
9017 });
9018 state.event(
9019 "resource",
9020 format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
9021 );
9022 return block_wave(jobs, state, node, attempt, &why);
9023 }
9024 }
9025 } else {
9026 None
9027 }
9028 } else {
9029 None
9030 };
9031 state.seats_spawning(node, attempt);
9034 if let Err(e) = state.save() {
9035 tracing::warn!("could not persist the start of a wave: {e:#}");
9036 }
9037 let slot_limit = resource_limit
9041 + jobs
9042 .iter()
9043 .map(|j| j.timeout)
9044 .max()
9045 .unwrap_or_default()
9046 .saturating_mul(jobs.len() as u32);
9047 let mut set = tokio::task::JoinSet::new();
9048 let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
9051 let overlay = prompts.overlay(node);
9052 for (i, mut job) in jobs.into_iter().enumerate() {
9053 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
9054 if cache.is_some() {
9055 job.prompt.push('\n');
9056 job.prompt
9057 .push_str(&prompt::build_cache_note(node, job.allow_write));
9058 }
9059 let sem = Arc::clone(&sem);
9060 let run = run.to_owned();
9061 let node = node.to_owned();
9062 let attachments = if node == "implement" {
9065 state.attachments.clone()
9066 } else {
9067 Vec::new()
9068 };
9069 let cache = cache
9080 .filter(|_| job.allow_write && cache_guard.is_some())
9081 .map(Path::to_path_buf);
9082 let spawned_tx = spawned_tx.clone();
9083 set.spawn(async move {
9084 let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9088 Ok(p) => p,
9089 Err(_) => {
9090 return (
9091 i,
9092 job.seat,
9093 AgentOutcome::ResourceBlocked(format!(
9094 "no parallel slot became free within {}s",
9095 slot_limit.as_secs()
9096 )),
9097 );
9098 }
9099 };
9100 spawned_tx.send(job.seat.key.clone()).ok();
9101 let mut seat = job.seat;
9102 let out = agent::invoke(
9103 &job.spec,
9104 &mut seat,
9105 &Invocation {
9106 cwd: &job.cwd,
9107 prompt: &job.prompt,
9108 timeout: job.timeout,
9109 allow_write: job.allow_write,
9110 unsandboxed: false,
9111 sessions: job.sessions,
9112 artifacts: &job.artifacts,
9113 stem: &job.stem,
9114 run: &run,
9115 node: &node,
9116 cache_dir: cache.as_deref(),
9117 attachments: &attachments,
9118 writable: &[],
9119 },
9120 )
9121 .await;
9122 let out = match out {
9123 Ok(o) if o.usable() => AgentOutcome::Ok(o),
9124 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9125 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9133 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9134 Ok(o) => AgentOutcome::Failed(format!(
9135 "exited with {:?} and no usable output",
9136 o.exit_code
9137 )),
9138 Err(e) => AgentOutcome::Failed(e.to_string()),
9139 };
9140 (i, seat, out)
9141 });
9142 }
9143 drop(spawned_tx);
9144 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9145 loop {
9146 let joined = tokio::select! {
9147 Some(key) = spawned_rx.recv() => {
9148 state.seat_spawned(&key);
9150 if let Err(e) = state.save() {
9151 tracing::warn!("could not persist a seat's start: {e:#}");
9152 }
9153 continue;
9154 }
9155 joined = set.join_next() => joined,
9156 };
9157 let Some(joined) = joined else {
9158 break;
9159 };
9160 let (i, seat, out) = match joined {
9161 Ok(v) => v,
9162 Err(e) => {
9166 tracing::error!("agent task panicked: {e}");
9167 continue;
9168 }
9169 };
9170 state.seat_finished(&seat.key);
9171 if let AgentOutcome::ResourceBlocked(why) = &out
9172 && state.resource_block.is_none()
9173 {
9174 state.resource_block = Some(crate::run::ResourceBlock {
9175 node: node.to_owned(),
9176 kind: "parallel slot".to_owned(),
9177 detail: why.clone(),
9178 at: Timestamp::now(),
9179 });
9180 state.event("resource", format!("{node}: no agent was started - {why}"));
9181 }
9182 record_jobs(state, node, round, &seat.key, &out);
9183 if let Err(e) = state.save() {
9184 tracing::warn!("could not persist a seat's completion: {e:#}");
9185 }
9186 if collected.len() <= i {
9187 collected.resize_with(i + 1, || None);
9188 }
9189 collected[i] = Some((i, seat, out));
9190 }
9191 if state
9197 .active
9198 .values()
9199 .any(|a| a.node == node && a.attempt == attempt)
9200 {
9201 state
9202 .active
9203 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9204 if let Err(e) = state.save() {
9205 tracing::warn!("could not persist the end of a wave: {e:#}");
9206 }
9207 }
9208 if let Some(cache_dir) = cache
9215 && jobs_had_a_writer
9216 {
9217 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9218 }
9219 if let Some(guard) = cache_guard {
9220 guard.release();
9221 }
9222 collected.into_iter().flatten().collect()
9223}
9224
9225fn record_jobs(
9236 state: &mut RunState,
9237 node: &str,
9238 round: Option<usize>,
9239 seat: &str,
9240 out: &AgentOutcome,
9241) {
9242 let commands: &[agent::CommandEvidence] = match out {
9243 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9244 AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9245 };
9246 let checked_at = Timestamp::now();
9247 for c in commands {
9248 state.jobs.push(JobRecord {
9249 node: node.to_owned(),
9250 round,
9251 seat: seat.to_owned(),
9252 id: c.id.clone(),
9253 description: c.description.clone(),
9254 checked_at,
9255 status: match c.exit_code {
9256 Some(0) => JobStatus::Completed,
9257 Some(_) => JobStatus::Failed,
9258 None => JobStatus::Unknown,
9259 },
9260 exit_code: c.exit_code,
9261 result_summary: c.result_summary.clone(),
9262 source: c.source.clone(),
9263 });
9264 }
9265}
9266
9267fn round_is_clean(
9288 blocking: usize,
9289 e2e_ok: bool,
9290 answered: usize,
9291 expected: usize,
9292 quota_missing: usize,
9293 policy: IncompleteReviewPolicy,
9294) -> bool {
9295 if blocking != 0 || !e2e_ok {
9296 return false;
9297 }
9298 if answered == expected || policy == IncompleteReviewPolicy::Warn {
9299 return true;
9300 }
9301 answered > 0 && expected - answered <= quota_missing
9302}
9303
9304fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9328 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9329 return Some(RunStatus::Gating);
9330 }
9331 let last = reviews.last()?;
9332 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9333 if reviews.len() < max_rounds && !stagnant {
9334 return None;
9335 }
9336 if last.incomplete() && last.blocking == 0 {
9337 return Some(RunStatus::Blocked);
9338 }
9339 if last.e2e_status() == E2eStatus::ResourceBlocked {
9340 return None;
9341 }
9342 Some(if last.e2e.iter().all(CommandOutcome::ok) {
9343 RunStatus::Gating
9344 } else {
9345 RunStatus::Blocked
9346 })
9347}
9348
9349pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9364 if nudged {
9365 (full / 4).max(Duration::from_secs(120)).min(full)
9366 } else {
9367 full
9368 }
9369}
9370
9371#[allow(clippy::too_many_arguments)]
9400async fn ask_json_wave<T>(
9401 jobs: Vec<SeatJob>,
9402 sem: Arc<Semaphore>,
9403 retries: usize,
9404 roster: &[AgentSpec],
9405 ctx: &WaveCtx<'_>,
9406 losses: &mut Vec<QuotaLoss>,
9407 state: &mut RunState,
9408 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9409) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9410where
9411 T: serde::de::DeserializeOwned + Send + 'static,
9412{
9413 ask_wave_with(
9414 jobs,
9415 sem,
9416 retries,
9417 roster,
9418 ctx,
9419 losses,
9420 state,
9421 &|text: &str| {
9422 let v = verdict::extract_json::<T>(text)?;
9423 validate(&v)?;
9424 Ok(v)
9425 },
9426 )
9427 .await
9428}
9429
9430#[allow(clippy::too_many_arguments)]
9439async fn ask_wave_with<T>(
9440 jobs: Vec<SeatJob>,
9441 sem: Arc<Semaphore>,
9442 retries: usize,
9443 roster: &[AgentSpec],
9444 ctx: &WaveCtx<'_>,
9445 losses: &mut Vec<QuotaLoss>,
9446 state: &mut RunState,
9447 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9448) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9449where
9450 T: Send + 'static,
9451{
9452 let n = jobs.len();
9453 let originals: Vec<SeatJob> = jobs;
9454 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9455 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9456 let mut nudges: Vec<usize> = vec![0; n];
9463 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9467 let mut tried: Vec<BTreeSet<String>> = specs
9468 .iter()
9469 .map(|s| BTreeSet::from([s.id.clone()]))
9470 .collect();
9471 let starts: Vec<usize> = specs
9472 .iter()
9473 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9474 .collect();
9475 let carry = ctx.carry_seats && !roster.is_empty();
9476 let carried: Vec<BTreeSet<String>> = originals
9479 .iter()
9480 .map(|j| {
9481 state
9482 .seat_history
9483 .get(&j.seat.key)
9484 .filter(|_| carry)
9485 .map(|h| h.failed.clone())
9486 .unwrap_or_default()
9487 })
9488 .collect();
9489 let mut prev: Vec<Option<FailClass>> = originals
9490 .iter()
9491 .map(|j| {
9492 state
9493 .seat_history
9494 .get(&j.seat.key)
9495 .filter(|_| carry)
9496 .and_then(|h| h.last_fail.clone())
9497 })
9498 .collect();
9499 let next_agent =
9500 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9501 let others: BTreeSet<String> = specs
9502 .iter()
9503 .enumerate()
9504 .filter(|(j, _)| *j != i)
9505 .map(|(_, s)| s.id.clone())
9506 .collect();
9507 pick_successor(
9508 roster,
9509 starts[i],
9510 tried,
9511 carry.then(|| &carried[i]),
9512 &others,
9513 )
9514 .cloned()
9515 };
9516 let mut fresh: Vec<Option<String>> = vec![None; n];
9517 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9518 let mut pending: Vec<usize> = (0..n).collect();
9519
9520 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9525 for round in 0..max_rounds {
9526 if pending.is_empty() {
9527 break;
9528 }
9529 let mut batch = Vec::with_capacity(pending.len());
9530 let mut renudged: Vec<&str> = Vec::new();
9531 for &i in &pending {
9532 let src = &originals[i];
9533 let full: &str = match &src.handover {
9538 Some(h) if specs[i].id != src.spec.id => h,
9539 _ => &src.prompt,
9540 };
9541 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9544 (
9545 full.to_owned(),
9546 src.timeout,
9547 format!("{}-{word}-{}", src.stem, specs[i].id),
9548 )
9549 } else if nudges[i] == 0 {
9550 (full.to_owned(), src.timeout, src.stem.clone())
9551 } else {
9552 renudged.push(src.seat.key.as_str());
9553 let why = done[i]
9554 .as_ref()
9555 .and_then(|r| r.as_ref().err().map(ToString::to_string))
9556 .unwrap_or_else(|| "no parsable answer".to_owned());
9557 let nudge = prompt::nudge(&why);
9558 let nudged = has_context(&specs[i], &seats[i], src.sessions);
9559 let prompt = if nudged {
9560 nudge
9561 } else {
9562 format!("{full}\n\n---\n\n{nudge}")
9563 };
9564 (
9565 prompt,
9566 retry_budget(src.timeout, nudged),
9567 format!("{}-retry{}", src.stem, nudges[i]),
9568 )
9569 };
9570 batch.push(SeatJob {
9571 spec: specs[i].clone(),
9572 seat: seats[i].clone(),
9573 cwd: src.cwd.clone(),
9574 prompt,
9575 timeout,
9576 allow_write: src.allow_write,
9577 sessions: src.sessions,
9578 artifacts: src.artifacts.clone(),
9579 stem,
9580 handover: None,
9581 });
9582 }
9583
9584 if !renudged.is_empty() {
9585 state.event(
9586 ctx.node,
9587 format!("retry {round}: re-asking {}", renudged.join(", ")),
9588 );
9589 }
9590 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9591 let mut still = Vec::new();
9592 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9593 seats[i] = seat;
9594 if let AgentOutcome::ResourceBlocked(why) = &out {
9597 done[i] = Some(Err(anyhow::anyhow!("{why}")));
9598 continue;
9599 }
9600 let class = FailClass::of(&out);
9601 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9604 && nudges[i] < retries
9605 && !roster.is_empty();
9606 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9607 let next = should_hand_over(prev[i].as_ref(), &cur)
9608 .then(|| next_agent(i, &tried[i], &specs))
9609 .flatten();
9610 if carry {
9611 let h = state
9612 .seat_history
9613 .entry(originals[i].seat.key.clone())
9614 .or_default();
9615 h.failed.insert(specs[i].id.clone());
9616 h.last_fail = Some(cur.clone());
9617 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9618 h.last_ok = None;
9619 }
9620 if let Err(e) = state.save() {
9623 tracing::warn!("could not persist a seat's failure history: {e:#}");
9624 }
9625 }
9626 if let Some(next) = next {
9627 record_handover(
9628 state,
9629 ctx.node,
9630 &originals[i].seat.key,
9631 &specs[i].id,
9632 &next.id,
9633 &cur,
9634 &fail_reason(&out),
9635 );
9636 tried[i].insert(next.id.clone());
9637 prev[i] = Some(cur.clone());
9638 seats[i] =
9639 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9640 specs[i] = next;
9641 fresh[i] = Some(cur.stem_word().to_owned());
9642 nudges[i] = 0;
9643 done[i] = Some(Err(anyhow::anyhow!(
9644 "handed over after: {}",
9645 fail_reason(&out)
9646 )));
9647 still.push(i);
9648 continue;
9649 }
9650 }
9651 if carry
9652 && !nudge_first
9653 && let Some(cur) = class.clone()
9654 {
9655 let h = state
9657 .seat_history
9658 .entry(originals[i].seat.key.clone())
9659 .or_default();
9660 h.failed.insert(specs[i].id.clone());
9661 h.last_fail = Some(cur);
9662 }
9663 let parsed = match out {
9664 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9665 AgentOutcome::Quota(o) => {
9666 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9667 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9668 }
9669 AgentOutcome::Dropped(o) => {
9673 let why = o
9674 .dropped
9675 .as_ref()
9676 .map(|d| d.why.as_str())
9677 .unwrap_or("the CLI ended the stream without delivering its answer");
9678 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9679 }
9680 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9681 Err(anyhow::anyhow!(e))
9682 }
9683 };
9684 let quota = class == Some(FailClass::Quota);
9685 let failed = parsed.is_err();
9686 done[i] = Some(parsed);
9687 if carry && !failed {
9688 let h = state
9689 .seat_history
9690 .entry(originals[i].seat.key.clone())
9691 .or_default();
9692 h.failed.remove(&specs[i].id);
9693 h.last_ok = Some(specs[i].id.clone());
9694 h.last_fail = None;
9695 }
9696 let agent_failure = class.is_some()
9704 && !nudge_first
9705 && !roster.is_empty()
9706 && next_agent(i, &tried[i], &specs).is_some();
9707 if failed && !quota && !agent_failure && nudges[i] < retries {
9708 nudges[i] += 1;
9709 still.push(i);
9710 }
9711 }
9712 pending = still;
9713 }
9714
9715 for (i, q) in last_quota.into_iter().enumerate() {
9718 if let Some(reset) = q {
9719 losses.push(QuotaLoss {
9720 seat: originals[i].seat.key.clone(),
9721 node: ctx.node.to_owned(),
9722 at: Timestamp::now(),
9723 reset,
9724 });
9725 }
9726 }
9727
9728 seats
9729 .into_iter()
9730 .zip(done)
9731 .zip(nudges)
9732 .map(|((seat, res), attempts)| {
9733 (
9734 seat,
9735 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9736 attempts,
9737 )
9738 })
9739 .collect()
9740}
9741
9742fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9744 results
9745 .iter()
9746 .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9747}
9748
9749fn block_wave(
9753 jobs: Vec<SeatJob>,
9754 state: &mut RunState,
9755 node: &str,
9756 attempt: usize,
9757 why: &str,
9758) -> Vec<(usize, SeatState, AgentOutcome)> {
9759 state.resource_wait = None;
9760 state
9761 .active
9762 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9763 if let Err(e) = state.save() {
9764 tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9765 }
9766 jobs.into_iter()
9767 .enumerate()
9768 .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9769 .collect()
9770}
9771
9772async fn acquire_cache_lease(
9785 state: &mut RunState,
9786 cache_dir: &Path,
9787 owner: &crate::cache::Owner,
9788 budget: Duration,
9789 context: &str,
9790 cancel: &(dyn Fn() -> bool + Sync),
9791) -> Result<Option<crate::cache::Guard>> {
9792 let home = crate::run::home();
9793 let started = Instant::now();
9794 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9795 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9796 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9797 Err(e) => {
9798 state.event(
9799 "verify",
9800 format!("{context}: could not check the shared build cache: {e:#}"),
9801 );
9802 if let Err(e2) = state.save() {
9803 tracing::warn!("could not persist a cache-check failure: {e2:#}");
9804 }
9805 return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9806 }
9807 };
9808 state.event(
9809 "verify",
9810 format!(
9811 "{context}: waiting for the shared build cache at {} ({})",
9812 cache_dir.display(),
9813 busy.describe()
9814 ),
9815 );
9816 state.resource_wait = Some(crate::run::ResourceWait {
9819 node: context.to_owned(),
9820 kind: "shared build cache".to_owned(),
9821 holder: busy.describe(),
9822 since: Timestamp::now(),
9823 limit_secs: budget.as_secs(),
9824 });
9825 if let Err(e) = state.save() {
9826 tracing::warn!("could not persist a cache wait: {e:#}");
9827 }
9828 let remaining = budget.saturating_sub(started.elapsed());
9829 let waited = crate::cache::wait_for_unless(
9830 &home,
9831 cache_dir,
9832 owner,
9833 remaining,
9834 Duration::from_secs(5),
9835 cancel,
9836 )
9837 .await;
9838 state.resource_wait = None;
9839 match waited {
9840 Ok(g) => Ok(g),
9841 Err(e) => {
9842 state.event("verify", format!("{context}: {e:#}"));
9843 if let Err(e2) = state.save() {
9844 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9845 }
9846 Err(e)
9847 }
9848 }
9849}
9850
9851#[derive(Debug)]
9856struct CacheCheckError(String);
9857
9858impl std::fmt::Display for CacheCheckError {
9859 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9860 f.write_str(&self.0)
9861 }
9862}
9863
9864impl std::error::Error for CacheCheckError {}
9865
9866#[allow(clippy::too_many_arguments)]
9885async fn with_cache_lease<'s, F, Fut>(
9886 state: &'s mut RunState,
9887 cache_dir: Option<&Path>,
9888 node: &str,
9889 seat: &str,
9890 worktree: &Path,
9891 head: &str,
9892 budget: Duration,
9893 context: &str,
9894 body: F,
9895) -> (Vec<CommandOutcome>, bool)
9896where
9897 F: FnOnce(&'s mut RunState, Duration) -> Fut,
9898 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9899{
9900 let Some(cache_dir) = cache_dir else {
9901 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9902 return (outcomes, retried);
9903 };
9904 let home = crate::run::home();
9905 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9906 let started = Instant::now();
9907 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
9908 let guard =
9909 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, context, &|| false)
9910 .await
9911 .and_then(|g| {
9912 g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9913 }) {
9914 Ok(g) => g,
9915 Err(e) => {
9916 return (
9917 vec![CommandOutcome {
9918 command: "(waiting for the shared build cache)".to_owned(),
9919 code: None,
9920 output_tail: e.to_string(),
9921 duration_ms: started.elapsed().as_millis() as u64,
9922 resource_blocked: true,
9923 }],
9924 false,
9925 );
9926 }
9927 };
9928 let identity = crate::cache::Identity::new(worktree, head);
9929 if let Err(e) = crate::cache::ensure_fresh(
9930 &home,
9931 cache_dir,
9932 &identity,
9933 &state.config.verify.cargo_manifests(),
9934 ) {
9935 state.event(
9943 "verify",
9944 format!(
9945 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9946 worktree.display(),
9947 short(head)
9948 ),
9949 );
9950 guard.release();
9951 return (
9952 vec![CommandOutcome {
9953 command: "(confirming the shared build cache is fresh)".to_owned(),
9954 code: None,
9955 output_tail: e.to_string(),
9956 duration_ms: started.elapsed().as_millis() as u64,
9957 resource_blocked: true,
9958 }],
9959 false,
9960 );
9961 }
9962 let (outcomes, retried, timed_out_pids) = body(state, budget).await;
9963 if !timed_out_pids.is_empty() {
9968 wait_for_timed_out_children_to_die(&timed_out_pids).await;
9969 }
9970 guard.release();
9971 (outcomes, retried)
9972}
9973
9974async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9986 wait_for_pids_with(
9987 pids,
9988 crate::proc::pid_alive,
9989 LEASE_RELEASE_POLL,
9990 LEASE_RELEASE_MAX_WAIT,
9991 )
9992 .await;
9993}
9994
9995async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10001 pids: &[u32],
10002 alive: F,
10003 poll: Duration,
10004 max_wait: Duration,
10005) {
10006 let deadline = Instant::now() + max_wait;
10007 loop {
10008 if pids.iter().all(|&pid| !alive(pid)) {
10009 return;
10010 }
10011 if Instant::now() >= deadline {
10012 return;
10013 }
10014 tokio::time::sleep(poll).await;
10015 }
10016}
10017
10018fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10025 outcomes.iter().any(|o| o.resource_blocked)
10026}
10027
10028enum GateFix {
10030 Retry,
10032 Stop,
10035 Defer,
10038}
10039
10040fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10048 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10049 red.peek().is_some()
10050 && red.all(|o| {
10051 !o.resource_blocked
10052 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10053 && !o.output_tail.trim().is_empty()
10054 })
10055}
10056
10057fn e2e_outcome_label(o: &CommandOutcome) -> String {
10061 if o.ok() {
10062 return "pass".to_owned();
10063 }
10064 let reason = if o.build_failed() {
10065 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10066 } else {
10067 format!("FAIL ({:?})", o.code)
10068 };
10069 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10070}
10071
10072async fn run_e2e_with_retry(
10080 state: &mut RunState,
10081 shell: &[String],
10082 commands: &[String],
10083 worktree: &Path,
10084 timeout: Duration,
10085 context: &str,
10086) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10087 let (mut e2e, mut timed_out_pids) = run_commands(
10088 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10089 )
10090 .await;
10091 for o in &e2e {
10092 state.event(
10093 "verify",
10094 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10095 );
10096 }
10097 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10101 if verify_retried {
10102 state.event(
10103 "verify",
10104 format!(
10105 "{context}: verify could not build/link, not a test result — retrying once \
10106 before concluding"
10107 ),
10108 );
10109 let retried = run_commands(
10110 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10111 )
10112 .await;
10113 e2e = retried.0;
10114 timed_out_pids.extend(retried.1);
10117 for o in &e2e {
10118 state.event(
10119 "verify",
10120 format!(
10121 "{context}: retry `{}` -> {}",
10122 o.command,
10123 e2e_outcome_label(o)
10124 ),
10125 );
10126 }
10127 }
10128 (e2e, verify_retried, timed_out_pids)
10129}
10130
10131#[allow(clippy::too_many_arguments)]
10147async fn run_commands(
10148 state: &mut RunState,
10149 node: &str,
10150 task: &str,
10151 attempt: usize,
10152 shell: &[String],
10153 commands: &[String],
10154 cwd: &Path,
10155 timeout: Duration,
10156) -> (Vec<CommandOutcome>, Vec<u32>) {
10157 if commands.is_empty() {
10158 return (Vec::new(), Vec::new());
10163 }
10164 let mut out = Vec::new();
10165 let mut timed_out_pids = Vec::new();
10166 let total = commands.len();
10167 for (idx, command) in commands.iter().enumerate() {
10168 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10169 if let Err(e) = state.save() {
10170 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10171 }
10172 let started = Instant::now();
10173 let mut cmd = tokio::process::Command::new(&shell[0]);
10174 cmd.quiet();
10175 cmd.args(&shell[1..])
10176 .arg(command)
10177 .current_dir(cwd)
10178 .stdin(std::process::Stdio::null())
10179 .stdout(std::process::Stdio::piped())
10180 .stderr(std::process::Stdio::piped())
10181 .kill_on_drop(true);
10182 let spawned = cmd.spawn();
10183 let (code, body) = match spawned {
10184 Ok(child) => {
10185 let pid = child.id();
10190 match tokio::time::timeout(timeout, child.wait_with_output()).await {
10191 Ok(Ok(o)) => {
10192 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10193 body.push_str(&String::from_utf8_lossy(&o.stderr));
10194 (o.status.code(), body)
10195 }
10196 Ok(Err(e)) => (None, format!("failed to run: {e}")),
10197 Err(_) => {
10198 if let Some(pid) = pid {
10199 timed_out_pids.push(pid);
10200 }
10201 (None, format!("timed out after {}s", timeout.as_secs()))
10202 }
10203 }
10204 }
10205 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10206 };
10207 out.push(CommandOutcome {
10208 command: command.clone(),
10209 code,
10210 output_tail: tail(&body, OUTPUT_TAIL),
10211 duration_ms: started.elapsed().as_millis() as u64,
10212 resource_blocked: false,
10213 });
10214 }
10215 state.task_finished(task);
10216 if let Err(e) = state.save() {
10217 tracing::warn!("could not persist the end of {task}: {e:#}");
10218 }
10219 (out, timed_out_pids)
10220}
10221
10222fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10234 let repo = repo.display();
10235 match style {
10236 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10237 MergeStyle::Squash => {
10238 let subject = message
10241 .lines()
10242 .next()
10243 .unwrap_or(branch)
10244 .replace(['\\', '"', '$', '`'], "");
10245 format!(
10246 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10247 )
10248 }
10249 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10250 }
10251}
10252
10253const PR_TITLE_MAX: usize = 240;
10264
10265fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10276 let line = queue::first_line(text)?;
10277 let chars: Vec<char> = line.chars().collect();
10278 let (kept, mark) = if chars.len() <= max {
10279 (line.to_owned(), "")
10280 } else if let Some(end) = sentence_end(&chars, max) {
10281 (chars[..end].iter().collect::<String>(), "")
10282 } else {
10283 let room = max.saturating_sub(3);
10284 let cut = if chars[room].is_whitespace() {
10287 room
10288 } else {
10289 chars[..room]
10290 .iter()
10291 .rposition(|c| c.is_whitespace())
10292 .unwrap_or(room)
10293 };
10294 let head: String = chars[..cut].iter().collect();
10295 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10296 (head.to_owned(), "...")
10297 };
10298 if kept.is_empty() {
10299 return None;
10300 }
10301 if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10302 return None;
10303 }
10304 if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10305 return None;
10306 }
10307 Some(format!("{kept}{mark}"))
10308}
10309
10310fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10314 const MIN: usize = 20;
10315 for i in MIN..max.min(chars.len()) {
10316 if !matches!(chars[i], '.' | '!' | '?') {
10317 continue;
10318 }
10319 let Some(&next) = chars.get(i + 1) else {
10320 continue;
10321 };
10322 if !next.is_whitespace() {
10323 continue;
10324 }
10325 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10326 if after.is_some_and(|c| c.is_ascii_lowercase()) {
10327 continue;
10328 }
10329 let word: String = chars[..i]
10330 .iter()
10331 .rev()
10332 .take_while(|c| !c.is_whitespace())
10333 .collect::<Vec<_>>()
10334 .into_iter()
10335 .rev()
10336 .collect();
10337 let word = word.to_ascii_lowercase();
10338 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10339 continue;
10340 }
10341 let end = chars[..i]
10342 .iter()
10343 .rposition(|c| !c.is_whitespace())
10344 .map_or(i, |p| p + 1);
10345 return Some(end);
10346 }
10347 None
10348}
10349
10350struct PrMessage {
10355 title: String,
10356 body: String,
10357}
10358
10359impl PrMessage {
10360 fn commit_message(&self) -> String {
10364 format!("{}\n\n{}", self.title, self.body)
10365 }
10366}
10367
10368fn title_marker(line: &str) -> Option<&str> {
10370 let line = line.trim();
10371 let head = line.get(..6)?;
10372 head.eq_ignore_ascii_case("title:")
10373 .then(|| line[6..].trim())
10374}
10375
10376fn summary_title(summary: &str) -> Option<String> {
10381 let first = summary.lines().find(|l| !l.trim().is_empty())?;
10382 let raw = title_marker(first)?;
10383 if raw.is_empty() {
10384 return None;
10385 }
10386 let title = queue::title_from(raw, PR_TITLE_MAX);
10387 let lower = title.to_ascii_lowercase();
10388 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10389 return None;
10390 }
10391 Some(title)
10392}
10393
10394const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10396
10397pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10399
10400fn is_review_run(state: &RunState) -> bool {
10404 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10405}
10406
10407fn review_title(state: &RunState) -> Option<String> {
10412 english_subject(
10413 state.reviewed_commits.as_ref()?.first()?,
10414 state.config.graph.github_english(),
10415 )
10416}
10417
10418fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10421 let raw = raw.trim();
10422 if raw.is_empty() {
10423 return None;
10424 }
10425 if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10426 return None;
10427 }
10428 if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10429 return None;
10430 }
10431 let title = queue::title_from(raw, PR_TITLE_MAX);
10432 let lower = title.to_ascii_lowercase();
10433 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10434 return None;
10435 }
10436 Some(title)
10437}
10438
10439#[derive(Debug, Clone, PartialEq, Eq)]
10442struct BranchFacts {
10443 commits: Vec<(String, String)>,
10445 stat: String,
10447}
10448
10449const STAT_FILE_LINES: usize = 25;
10451const COMMITS_MAX_CHARS: usize = 20_000;
10453
10454async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10457 let commits = git::commit_log(repo, base, branch).await.ok()?;
10458 if commits.is_empty() {
10459 return None;
10460 }
10461 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10462 let lines: Vec<&str> = stat.lines().collect();
10463 let stat = if lines.len() > STAT_FILE_LINES + 1 {
10464 let omitted = lines.len() - 1 - STAT_FILE_LINES;
10465 let more = format!(" ... {omitted} more file(s)");
10466 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10467 kept.push(&more);
10468 kept.push(lines[lines.len() - 1]);
10469 kept.join("\n")
10470 } else {
10471 lines.join("\n")
10472 };
10473 Some(BranchFacts { commits, stat })
10474}
10475
10476fn markdown_safe(text: &str) -> String {
10479 text.replace("</details>", "</details>")
10480 .replace("\x60\x60\x60", "~~~")
10481}
10482
10483fn neutral_title(state: &RunState, winner: char) -> String {
10484 format!(
10485 "chore: land candidate {} of run {}",
10486 winner.to_ascii_uppercase(),
10487 state.id
10488 )
10489}
10490
10491pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10497 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10498 ""
10499 } else {
10500 pr_title
10501 }
10502}
10503
10504pub fn landing_subject_source(state: &RunState) -> String {
10508 if is_review_run(state) {
10509 let winner = state.candidates.first().map_or('A', |c| c.label);
10510 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10511 }
10512 state.instruction.clone()
10513}
10514
10515fn summary_without_title(summary: &str) -> String {
10518 let mut lines = summary.trim().lines().peekable();
10519 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10520 lines.next();
10521 }
10522 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10523}
10524
10525#[cfg(test)]
10537fn pr_message(state: &RunState, winner: char) -> PrMessage {
10538 pr_message_with(state, winner, None)
10539}
10540
10541#[cfg(test)]
10544fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10545 let raw = pr_message_raw(
10546 state,
10547 winner,
10548 facts,
10549 crate::pr_outcome::FinalHead::Unreported,
10550 ChangeNote::NotAsked,
10551 );
10552 let id = crate::scrub::Identity::current();
10553 PrMessage {
10554 title: crate::scrub::scrub(&raw.title, &id),
10555 body: crate::scrub::scrub(&raw.body, &id),
10556 }
10557}
10558
10559#[derive(Debug, Clone, Copy)]
10561enum ChangeNote<'a> {
10562 NotAsked,
10564 Summary(&'a crate::run::ChangeSummary),
10566 Unavailable(&'a str),
10568}
10569
10570fn pr_message_raw(
10571 state: &RunState,
10572 winner: char,
10573 facts: Option<&BranchFacts>,
10574 head: crate::pr_outcome::FinalHead<'_>,
10575 change: ChangeNote<'_>,
10576) -> PrMessage {
10577 if !matches!(change, ChangeNote::NotAsked) {
10578 let (title, mut body) = match change {
10579 ChangeNote::Summary(c) => (
10580 c.title.clone(),
10581 format!("## Summary\n\n{}\n\n", c.body.trim()),
10582 ),
10583 ChangeNote::Unavailable(why) => (
10584 neutral_title(state, winner),
10585 format!(
10586 "## 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"
10587 ),
10588 ),
10589 ChangeNote::NotAsked => unreachable!(),
10590 };
10591 body.push_str(&public_pr_evidence(state, head));
10592 body.push_str(&format!(
10593 "\n---\nmagi:run/{} magi:candidate-{}\n",
10594 state.id,
10595 winner.to_ascii_lowercase()
10596 ));
10597 return PrMessage { title, body };
10598 }
10599 pr_preview(state, winner, facts, head)
10600}
10601
10602fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10605 let mut body = crate::pr_outcome::review_rounds(state);
10606 body.push_str(&crate::pr_outcome::final_verification(state, head));
10607 let open = state.last_round_findings();
10608 if !open.is_empty() {
10609 body.push_str("## Open review findings\n\n");
10610 for f in open {
10611 body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10612 }
10613 body.push('\n');
10614 }
10615 if let Some(c) = state.change_summary.as_ref()
10616 && head == crate::pr_outcome::FinalHead::At(&c.head)
10617 {
10618 body.push_str(&format!(
10619 "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10620 c.base, c.head
10621 ));
10622 }
10623 body
10624}
10625
10626fn pr_preview(
10628 state: &RunState,
10629 winner: char,
10630 facts: Option<&BranchFacts>,
10631 head: crate::pr_outcome::FinalHead<'_>,
10632) -> PrMessage {
10633 let summary = state
10634 .candidates
10635 .iter()
10636 .find(|c| c.label == winner)
10637 .map(|c| c.summary.as_str())
10638 .unwrap_or_default();
10639 let review = is_review_run(state);
10642 let title = if review {
10643 facts
10644 .and_then(|f| {
10645 english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10646 })
10647 .or_else(|| review_title(state))
10648 .or_else(|| {
10649 state
10650 .candidates
10651 .iter()
10652 .find(|c| c.label == winner)
10653 .filter(|c| !c.branch.starts_with("magi/"))
10654 .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10655 })
10656 .unwrap_or_else(|| neutral_title(state, winner))
10657 } else {
10658 summary_title(summary).unwrap_or_else(|| {
10659 english_title(
10660 &state.instruction,
10661 PR_TITLE_MAX,
10662 state.config.graph.github_english(),
10663 )
10664 .unwrap_or_else(|| neutral_title(state, winner))
10665 })
10666 };
10667
10668 let mut body = String::new();
10669 let what = summary_without_title(summary);
10670 if !what.is_empty() {
10671 body.push_str("## Summary\n\n");
10672 body.push_str(&what);
10673 body.push_str("\n\n");
10674 }
10675
10676 body.push_str(&crate::pr_outcome::review_rounds(state));
10678 body.push_str(&crate::pr_outcome::final_verification(state, head));
10679 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10680
10681 let open = state.open_findings();
10682 if !open.is_empty() {
10683 body.push_str("## Open review findings\n\n");
10684 for f in &open {
10685 body.push_str(&format!(
10686 "- `{}` [{:?}] {}\n",
10687 f.id, f.severity, "Context retained in local review records."
10688 ));
10689 }
10690 body.push('\n');
10691 }
10692
10693 if let Some(fix) = fix
10694 && !fix.rejected.is_empty()
10695 {
10696 body.push_str("## Declined by the fixer\n\n");
10697 for r in &fix.rejected {
10698 body.push_str(&format!(
10699 "- `{}`: {}\n",
10700 r.id, "Reason retained in local review records."
10701 ));
10702 }
10703 body.push('\n');
10704 }
10705
10706 if review {
10707 body.push_str("## Commits under review\n\n");
10709 if let Some(facts) = facts {
10710 let mut left = COMMITS_MAX_CHARS;
10711 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10712 let mut entry = format!("- {}\n", markdown_safe(subject));
10713 for l in markdown_safe(text).lines() {
10714 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10715 }
10716 if left == 0 {
10717 body.push_str(&format!(
10718 "- ... {} more commit(s)\n",
10719 facts.commits.len() - i
10720 ));
10721 break;
10722 }
10723 if entry.len() > left {
10724 let mut end = left;
10727 while !entry.is_char_boundary(end) {
10728 end -= 1;
10729 }
10730 entry.truncate(end);
10731 entry.push_str("\n ... (truncated)\n");
10732 left = 0;
10733 } else {
10734 left -= entry.len();
10735 }
10736 body.push_str(&entry);
10737 }
10738 if !facts.stat.trim().is_empty() {
10739 body.push_str(&format!(
10740 "\n## Diff stat\n\n```\n{}\n```\n",
10741 markdown_safe(facts.stat.trim())
10742 ));
10743 }
10744 } else {
10745 match &state.reviewed_commits {
10746 Some(subjects) => {
10747 for s in subjects {
10748 body.push_str(&format!("- {}\n", s.trim()));
10749 }
10750 }
10751 None => {
10752 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10755 body.push_str(rest.trim());
10756 body.push('\n');
10757 }
10758 }
10759 }
10760 } else {
10761 let task = state.instruction.trim();
10762 let task = if task.is_empty() {
10763 "(empty task)"
10764 } else {
10765 task
10766 };
10767 body.push_str(&format!(
10768 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10769 task.replace("</details>", "</details>")
10770 ));
10771 }
10772
10773 body.push_str(&format!(
10774 "\n---\nmagi:run/{} magi:candidate-{}\n",
10775 state.id,
10776 winner.to_ascii_lowercase()
10777 ));
10778
10779 PrMessage { title, body }
10780}
10781
10782fn seeded_instruction(state: &RunState) -> String {
10786 match refs::describe(&state.seeds) {
10787 Some(facts) => format!(
10788 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10789 Candidates start from the unmerged branch named above, when there \
10790 is one, and carry any unmerged commit named by sha as a \
10791 cherry-pick. Check that this is what the task meant before \
10792 building on it.",
10793 state.instruction
10794 ),
10795 None => state.instruction.clone(),
10796 }
10797}
10798
10799async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10803 let base = &state.base_branch;
10804 let mut against = base.clone();
10805 if mode == MergeMode::Pr {
10806 let remote = &state.config.merge.remote;
10810 let tracking = format!("{remote}/{base}");
10811 let fetched = git::fetch(repo, remote, base).await;
10812 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10813 against = tracking;
10814 } else {
10815 return false;
10816 }
10817 }
10818 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10819}
10820
10821fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10824 let mut detail = format!(
10825 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10826 nothing to open a pull request for"
10827 );
10828 match refs::describe(&state.seeds) {
10829 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10830 None => detail.push_str(
10831 "\nThe task names no existing branch or commit; if it means to land work \
10832 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10833 ),
10834 }
10835 detail
10836}
10837
10838#[derive(Debug, PartialEq, Eq)]
10841enum PrPlan {
10842 Create,
10843 Adopt { url: String, title: String },
10844 Stop(String),
10845}
10846
10847fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10850 match found {
10851 Ok(land::OpenPr::None) => PrPlan::Create,
10852 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10853 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10854 "several open pull requests exist for this branch, not picking one: {}",
10855 urls.join(" ")
10856 )),
10857 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10858 }
10859}
10860
10861pub(crate) async fn gh_pr_create(
10863 cwd: &Path,
10864 base: &str,
10865 head: &str,
10866 title: &str,
10867 body: &str,
10868) -> Result<String> {
10869 let out = tokio::process::Command::new("gh")
10870 .args([
10871 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10872 ])
10873 .current_dir(cwd)
10874 .quiet()
10875 .stdin(std::process::Stdio::null())
10876 .output()
10877 .await
10878 .context("spawn gh")?;
10879 if out.status.success() {
10880 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10881 } else {
10882 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10883 }
10884}
10885
10886pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10895 if crate::pr_recovery::sources_retained(home, &state.id) {
10896 return Ok(Vec::new());
10897 }
10898 let repo = state.repo.clone();
10899 let root = state.worktree_root();
10900 let winner = state.tally.as_ref().map(|t| t.winner);
10901 let mut removed = Vec::new();
10902
10903 for i in 0..state.candidates.len() {
10904 let c = state.candidates[i].clone();
10905 let is_winner = Some(c.label) == winner;
10906 if is_winner && !drop_winner {
10907 continue;
10908 }
10909 if c.worktree.exists() {
10910 git::worktree_remove(&repo, &c.worktree).await.ok();
10911 removed.push(c.worktree.to_string_lossy().into_owned());
10912 }
10913 let handed_over = state.released_branches.contains(&c.branch);
10916 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10917 git::branch_delete(&repo, &c.branch).await.ok();
10918 removed.push(c.branch.clone());
10919 }
10920 state.candidates[i].folded = true;
10921 }
10922
10923 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10924 let path = name.path();
10925 let keep = !drop_winner
10926 && winner.is_some_and(|w| {
10927 path.file_name()
10928 .is_some_and(|n| n == format!("cand-{w}").as_str())
10929 });
10930 if keep {
10931 continue;
10932 }
10933 git::worktree_remove(&repo, &path).await.ok();
10934 removed.push(path.to_string_lossy().into_owned());
10935 }
10936
10937 remove_if_empty(&root);
10946
10947 if state.enabled_worktree_config && drop_winner {
10948 git::release_worktree_config(&repo).await.ok();
10952 state.enabled_worktree_config = false;
10953 }
10954 state.save_under(home)?;
10955 Ok(removed)
10956}
10957
10958fn remove_if_empty(dir: &Path) {
10969 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10970 std::fs::remove_dir(dir).ok();
10971 }
10972}
10973
10974pub fn worst_open(state: &RunState) -> Option<Severity> {
10976 state
10977 .reviews
10978 .last()?
10979 .reviews
10980 .iter()
10981 .flat_map(|r| r.findings.iter())
10982 .map(|f| f.severity)
10983 .max()
10984}
10985
10986async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10990 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10991 git::acquire_worktree_config(repo).await?;
10992 state.enabled_worktree_config = true;
10993 }
10994 Ok(())
10995}
10996
10997#[cfg(test)]
10998mod tests {
10999 #[test]
11000 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11001 let leaked = vec!["chore(deps): update a crate".to_owned()];
11002 let own = "fix(daemon): apply a chosen action";
11003 assert!(should_retitle("", own, &leaked));
11004 assert!(should_retitle(
11005 &format!("{REVIEW_PROMPT_OPENING} `x`"),
11006 own,
11007 &leaked
11008 ));
11009 assert!(should_retitle(
11010 "chore: land candidate A of run 1",
11011 own,
11012 &leaked
11013 ));
11014 assert!(should_retitle(
11015 "magi: candidate A (uncommitted work)",
11016 own,
11017 &leaked
11018 ));
11019 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11020 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11021 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11022 }
11023
11024 #[test]
11025 fn pr_merge_plan_creates_adopts_or_stops() {
11026 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11027 assert_eq!(
11028 pr_merge_plan(Ok(land::OpenPr::One {
11029 url: "u".into(),
11030 title: "t".into()
11031 })),
11032 PrPlan::Adopt {
11033 url: "u".into(),
11034 title: "t".into()
11035 }
11036 );
11037 let PrPlan::Stop(many) =
11038 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11039 else {
11040 panic!("many must stop");
11041 };
11042 assert!(many.contains('a') && many.contains('b'));
11043 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11044 panic!("a failed lookup must stop");
11045 };
11046 assert!(err.contains("bad token"));
11047 }
11048
11049 use super::*;
11050 use crate::run::GateStatus;
11051 use std::collections::BTreeMap;
11052 use std::time::Duration;
11053
11054 fn conductor() -> AgentSpec {
11055 AgentSpec {
11056 id: "conductor".to_owned(),
11057 kind: crate::config::AgentKind::Command,
11058 model: None,
11059 command: vec!["true".to_owned()],
11060 extra_args: Vec::new(),
11061 env: BTreeMap::new(),
11062 prompt_delivery: None,
11063 }
11064 }
11065
11066 fn spec(id: &str) -> AgentSpec {
11067 AgentSpec {
11068 id: id.to_owned(),
11069 kind: crate::config::AgentKind::Command,
11070 model: None,
11071 command: vec!["true".to_owned()],
11072 extra_args: Vec::new(),
11073 env: BTreeMap::new(),
11074 prompt_delivery: None,
11075 }
11076 }
11077
11078 fn ids(xs: &[&str]) -> BTreeSet<String> {
11079 xs.iter().map(|s| (*s).to_owned()).collect()
11080 }
11081
11082 #[test]
11083 fn pick_successor_skips_an_agent_another_seat_holds() {
11084 let roster = [spec("a"), spec("b"), spec("c")];
11086 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11087 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11088 }
11089
11090 #[test]
11091 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11092 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11094 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11095 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11096 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11097 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11098 }
11099
11100 #[test]
11101 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11102 let roster = [spec("a"), spec("b")];
11103 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11104 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11105 let carried = ids(&["b"]);
11106 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11107 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11108 }
11109
11110 #[test]
11111 fn pick_successor_returns_none_without_any_untried_successor() {
11112 let roster = [spec("a"), spec("b")];
11113 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11114 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11115 let carried = ids(&["a"]);
11116 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11117 }
11118
11119 #[test]
11120 fn pick_successor_rescue_avoids_another_seats_occupant() {
11121 let roster = [spec("a"), spec("b"), spec("c")];
11123 let carried = ids(&["b", "c"]);
11124 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11125 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11126 }
11127
11128 #[test]
11129 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11130 let roster = [spec("a"), spec("b"), spec("c")];
11131 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11132 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11133 }
11134
11135 #[test]
11136 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11137 let roster = [spec("a"), spec("b"), spec("c")];
11138 let failed = ids(&["a", "b", "c"]);
11139 let mut tried = ids(&["b"]);
11141 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11142 assert_eq!(first.id, "a");
11143 tried.insert(first.id.clone());
11144 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11145 assert_eq!(second.id, "c");
11146 tried.insert(second.id.clone());
11147 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11148 }
11149
11150 #[test]
11151 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11152 let roster = [spec("a"), spec("b")];
11153 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11154 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11155 }
11156
11157 #[test]
11158 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11159 let roster = [spec("a"), spec("b"), spec("c")];
11160 let h = SeatHistory {
11161 failed: ids(&["a"]),
11162 last_ok: Some("b".to_owned()),
11163 last_fail: None,
11164 };
11165 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11166 let gone = SeatHistory {
11168 last_ok: Some("zzz".to_owned()),
11169 ..h.clone()
11170 };
11171 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11172 let failed = SeatHistory {
11173 failed: ids(&["a", "b"]),
11174 last_ok: Some("b".to_owned()),
11175 last_fail: None,
11176 };
11177 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11178 }
11179
11180 #[test]
11181 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11182 let first = SeatState::new("review-1", "alpha", 7);
11183 let next = handover_seat("review-1", "gamma", 7);
11184 assert_ne!(first.claude_session, next.claude_session);
11185 }
11186
11187 #[test]
11188 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11189 let mut state = state_with_summary("x", "y");
11190 let a = handover_seat("review-1", "beta", state.next_seat_seed());
11191 let b = handover_seat("review-1", "beta", state.next_seat_seed());
11192 assert_ne!(a.claude_session, b.claude_session);
11193 }
11194
11195 #[test]
11196 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11197 let roster = [spec("a"), spec("b")];
11198 let h = SeatHistory {
11199 failed: ids(&["a", "b"]),
11200 ..SeatHistory::default()
11201 };
11202 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11203 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11204 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11205 }
11206
11207 #[test]
11214 fn failure_signature_ignores_numbers_and_paths() {
11215 assert_eq!(
11216 failure_signature("exited with Some(2) and no usable output"),
11217 failure_signature("exited with Some(137) and no usable output")
11218 );
11219 assert_eq!(
11220 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11221 failure_signature("cannot open /var/x.txt: denied")
11222 );
11223 assert_ne!(failure_signature("boom"), failure_signature("bang"));
11224 }
11225
11226 #[test]
11227 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11228 let other = FailClass::Other("x".into());
11229 assert!(should_hand_over(None, &FailClass::Quota));
11230 assert!(should_hand_over(Some(&other), &FailClass::Quota));
11231 assert!(should_hand_over(
11232 Some(&FailClass::Timeout),
11233 &FailClass::Timeout
11234 ));
11235 assert!(should_hand_over(None, &other));
11236 assert!(!should_hand_over(Some(&other), &other));
11237 assert!(should_hand_over(
11238 Some(&other),
11239 &FailClass::Other("y".into())
11240 ));
11241 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11243 assert!(should_hand_over(Some(&FailClass::Quota), &other));
11244 }
11245
11246 #[test]
11247 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11248 let a = SeatState::new("judge-1", "alpha", 7);
11249 let b = handover_seat("judge-1", "beta", 7);
11250 assert_ne!(a.claude_session, b.claude_session);
11251 assert_eq!(b.turns, 0);
11252 }
11253
11254 #[test]
11255 fn a_timeout_is_classified_apart_from_other_failures() {
11256 assert_eq!(
11257 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11258 Some(FailClass::Timeout)
11259 );
11260 assert!(matches!(
11261 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11262 Some(FailClass::Other(_))
11263 ));
11264 }
11265
11266 #[test]
11267 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11268 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11269 let tried = BTreeSet::from(["beta".to_owned()]);
11270 let next = next_untried_in_roster(&roster, 1, &tried);
11273 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11274 }
11275
11276 #[test]
11277 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11278 let roster = vec![spec("alpha"), spec("beta")];
11279 let tried = BTreeSet::from(["beta".to_owned()]);
11280 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11284 }
11285
11286 #[test]
11287 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11288 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11289 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11290 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11294 }
11295
11296 #[test]
11297 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11298 let roster = vec![spec("a"), spec("a"), spec("b")];
11299 let tried = BTreeSet::from(["a".to_owned()]);
11300 let next = next_untried_in_roster(&roster, 0, &tried);
11301 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11302 }
11303
11304 #[test]
11305 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11306 let roster = vec![spec("a"), spec("b")];
11307 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11308 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11309 }
11310
11311 #[test]
11312 fn remove_if_empty_only_ever_takes_a_bare_directory() {
11313 let dir = tempfile::tempdir().unwrap();
11314 let bay = dir.path().join("ffff");
11315
11316 remove_if_empty(&bay);
11318 assert!(!bay.exists());
11319
11320 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11323 remove_if_empty(&bay);
11324 assert!(bay.exists(), "non-empty directory must survive");
11325
11326 std::fs::remove_dir(bay.join("cand-A")).unwrap();
11328 remove_if_empty(&bay);
11329 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11330 }
11331
11332 #[test]
11341 fn a_full_panel_that_found_nothing_is_clean() {
11342 assert!(round_is_clean(
11343 0,
11344 true,
11345 2,
11346 2,
11347 0,
11348 IncompleteReviewPolicy::Block
11349 ));
11350 }
11351
11352 #[test]
11353 fn a_missing_seat_is_never_clean_under_the_default_policy() {
11354 assert!(!round_is_clean(
11355 0,
11356 true,
11357 1,
11358 2,
11359 0,
11360 IncompleteReviewPolicy::Block
11361 ));
11362 }
11363
11364 #[test]
11365 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11366 assert!(!round_is_clean(
11367 1,
11368 true,
11369 1,
11370 2,
11371 0,
11372 IncompleteReviewPolicy::Warn
11373 ));
11374 }
11375
11376 #[test]
11377 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11378 assert!(round_is_clean(
11379 0,
11380 true,
11381 1,
11382 2,
11383 0,
11384 IncompleteReviewPolicy::Warn
11385 ));
11386 }
11387
11388 #[test]
11389 fn a_full_panel_with_an_open_finding_is_not_clean() {
11390 assert!(!round_is_clean(
11391 1,
11392 true,
11393 2,
11394 2,
11395 0,
11396 IncompleteReviewPolicy::Block
11397 ));
11398 }
11399
11400 #[test]
11401 fn a_full_panel_with_a_red_e2e_is_not_clean() {
11402 assert!(!round_is_clean(
11403 0,
11404 false,
11405 2,
11406 2,
11407 0,
11408 IncompleteReviewPolicy::Block
11409 ));
11410 }
11411
11412 #[test]
11419 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11420 assert!(round_is_clean(
11423 0,
11424 true,
11425 1,
11426 2,
11427 1,
11428 IncompleteReviewPolicy::Block
11429 ));
11430 }
11431
11432 #[test]
11433 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11434 assert!(!round_is_clean(
11437 0,
11438 true,
11439 1,
11440 2,
11441 0,
11442 IncompleteReviewPolicy::Block
11443 ));
11444 }
11445
11446 #[test]
11447 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11448 assert!(!round_is_clean(
11449 1,
11450 true,
11451 1,
11452 2,
11453 1,
11454 IncompleteReviewPolicy::Block
11455 ));
11456 assert!(!round_is_clean(
11457 0,
11458 false,
11459 1,
11460 2,
11461 1,
11462 IncompleteReviewPolicy::Block
11463 ));
11464 }
11465
11466 #[test]
11467 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11468 assert!(!round_is_clean(
11472 0,
11473 true,
11474 0,
11475 2,
11476 2,
11477 IncompleteReviewPolicy::Block
11478 ));
11479 }
11480
11481 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11482 CommandOutcome {
11483 command: "test".to_owned(),
11484 code,
11485 output_tail: String::new(),
11486 duration_ms: 0,
11487 resource_blocked,
11488 }
11489 }
11490
11491 #[test]
11492 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11493 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11494 assert!(
11495 !verify_inconclusive(&[outcome(Some(1), false)]),
11496 "an ordinary failure is still evidence about the patch"
11497 );
11498 assert!(verify_inconclusive(&[outcome(None, true)]));
11499 assert!(
11500 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11501 "one inconclusive outcome taints the whole batch"
11502 );
11503 assert!(!verify_inconclusive(&[]));
11504 }
11505
11506 #[tokio::test]
11507 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11508 let calls = std::sync::atomic::AtomicUsize::new(0);
11512 let started = Instant::now();
11513 wait_for_pids_with(
11514 &[123],
11515 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11516 Duration::from_millis(5),
11517 Duration::from_secs(5),
11518 )
11519 .await;
11520 assert!(
11521 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11522 "must keep checking rather than deciding on the first answer"
11523 );
11524 assert!(
11525 started.elapsed() < Duration::from_secs(1),
11526 "must return the moment it is confirmed dead, not wait out the ceiling"
11527 );
11528 }
11529
11530 #[tokio::test]
11531 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11532 let started = Instant::now();
11533 wait_for_pids_with(
11534 &[123],
11535 |_| true, Duration::from_millis(5),
11537 Duration::from_millis(30),
11538 )
11539 .await;
11540 let elapsed = started.elapsed();
11541 assert!(
11542 elapsed >= Duration::from_millis(30),
11543 "must not give up before its own ceiling: {elapsed:?}"
11544 );
11545 assert!(
11546 elapsed < Duration::from_secs(1),
11547 "must not wait past its own ceiling either: {elapsed:?}"
11548 );
11549 }
11550
11551 #[tokio::test]
11552 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11553 let started = Instant::now();
11554 wait_for_pids_with(
11555 &[],
11556 |_| true,
11557 Duration::from_secs(5),
11558 Duration::from_secs(5),
11559 )
11560 .await;
11561 assert!(
11562 started.elapsed() < Duration::from_millis(200),
11563 "an empty pid list has nothing to confirm"
11564 );
11565 }
11566
11567 fn review_round(
11573 clean: bool,
11574 blocking: usize,
11575 answered: usize,
11576 expected: usize,
11577 progressed: bool,
11578 e2e_ok: bool,
11579 ) -> ReviewRound {
11580 ReviewRound {
11581 round: 1,
11582 head: "h".to_owned(),
11583 verified_head: None,
11584 verified_at: None,
11585 reviews: Vec::new(),
11586 e2e: vec![CommandOutcome {
11587 command: "test".to_owned(),
11588 code: Some(if e2e_ok { 0 } else { 1 }),
11589 output_tail: String::new(),
11590 duration_ms: 0,
11591 resource_blocked: false,
11592 }],
11593 verify_retried: false,
11594 e2e_deferred: false,
11595 e2e_defer_reason: None,
11596 fix: None,
11597 blocking,
11598 answered,
11599 expected,
11600 clean,
11601 progressed,
11602 vote_split: false,
11603 reconsideration: Vec::new(),
11604 verdict: None,
11605 }
11606 }
11607
11608 #[test]
11609 fn review_conclusion_is_none_when_nothing_has_run() {
11610 assert_eq!(review_conclusion(&[], 3), None);
11611 }
11612
11613 #[test]
11614 fn review_conclusion_is_none_while_rounds_remain() {
11615 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11616 assert_eq!(review_conclusion(&rounds, 3), None);
11617 }
11618
11619 #[test]
11620 fn review_conclusion_is_gating_once_a_round_is_clean() {
11621 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11622 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11623 }
11624
11625 #[test]
11626 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11627 let rounds = vec![
11628 review_round(false, 1, 2, 2, true, true),
11629 review_round(false, 1, 2, 2, true, true),
11630 ];
11631 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11632 }
11633
11634 #[test]
11635 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11636 let rounds = vec![
11637 review_round(false, 1, 2, 2, true, true),
11638 review_round(false, 1, 2, 2, true, false),
11639 ];
11640 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11641 }
11642
11643 #[test]
11644 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11645 let mut blocked = review_round(false, 1, 2, 2, true, false);
11652 blocked.e2e[0].resource_blocked = true;
11653 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11654 assert_eq!(review_conclusion(&rounds, 2), None);
11655 }
11656
11657 #[test]
11658 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11659 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11661 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11662 }
11663
11664 #[test]
11665 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11666 let rounds = vec![
11667 review_round(false, 1, 2, 2, false, true),
11668 review_round(false, 1, 2, 2, false, true),
11669 ];
11670 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11671 }
11672
11673 fn secs(n: u64) -> Duration {
11674 Duration::from_secs(n)
11675 }
11676
11677 #[tokio::test]
11680 async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11681 ask_test_home();
11682 let tmp = tempfile::tempdir().unwrap();
11683 let repo = tmp.path().join("repo");
11684 std::fs::create_dir_all(&repo).unwrap();
11685 init_repo(&repo);
11686 let g = |args: &[&str]| {
11687 let out = std::process::Command::new("git")
11688 .args(args)
11689 .current_dir(&repo)
11690 .quiet()
11691 .output()
11692 .unwrap();
11693 assert!(
11694 out.status.success(),
11695 "{}",
11696 String::from_utf8_lossy(&out.stderr)
11697 );
11698 };
11699 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11700 g(&["checkout", "-b", "historical"]);
11701 std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11702 g(&["add", "."]);
11703 g(&["commit", "-m", "old alternative"]);
11704 let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11705 g(&["checkout", "-b", "magi/task-selected", &base]);
11706 std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11707 g(&["add", "."]);
11708 g(&["commit", "-m", "measurement infrastructure"]);
11709 let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11710 std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11711 g(&["add", "."]);
11712 g(&["commit", "-m", "dependent implementation"]);
11713 let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11714 g(&["checkout", "main"]);
11715 std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11716 g(&["add", "."]);
11717 g(&["commit", "-m", "unrelated base change"]);
11718 let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11719 let origin = tmp.path().join("origin.git");
11720 g(&[
11721 "clone",
11722 "--bare",
11723 repo.to_str().unwrap(),
11724 origin.to_str().unwrap(),
11725 ]);
11726 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11727 g(&["fetch", "origin"]);
11728 let selection = crate::pr_recovery::Selection {
11729 url: "https://example.test/project/pull/1".into(),
11730 branch: "magi/task-selected".into(),
11731 head: head.clone(),
11732 instruction: "preserve measured behavior".into(),
11733 approved_at: jiff::Timestamp::now(),
11734 };
11735 let mut template = runner_at(RunStatus::Reviewing);
11736 template.state.repo = repo.clone();
11737 template.state.base_commit = current.clone();
11738 template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11739 template.state.origin = Some(crate::run::Origin::queue("selected"));
11740 template.state.pr_head = Some(selection.branch.clone());
11741 template.state.pr_recovery = Some(selection.clone());
11742 let mut runner =
11743 Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11744 .await
11745 .unwrap();
11746 let wt = runner.state.winner().unwrap().worktree.clone();
11747 assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11748 assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11749 assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11750 assert!(runner.state.seeds.is_empty());
11751 assert_eq!(runner.state.instruction, "preserve measured behavior");
11752 runner.sync_to_base().await.unwrap();
11753 assert_eq!(
11754 std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11755 "prerequisite"
11756 );
11757 assert_eq!(
11758 std::fs::read_to_string(wt.join("implementation")).unwrap(),
11759 "measured implementation"
11760 );
11761 assert_eq!(
11762 std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11763 "current main change"
11764 );
11765 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11766 assert_eq!(
11767 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11768 head
11769 );
11770
11771 let mut partial = runner_at(RunStatus::Reviewing);
11773 partial.state.repo = repo.clone();
11774 partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11775 partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11776 run: Some("exit 1".into()),
11777 ..Default::default()
11778 }];
11779 let selection = runner.state.pr_recovery.clone().unwrap();
11780 partial.state.pr_recovery = Some(selection.clone());
11781 partial.state.pr_head = Some(selection.branch.clone());
11782 partial.state.origin = Some(crate::run::Origin::queue("selected"));
11783 let id = partial.state.id.clone();
11784 let error = match Runner::open_selected(
11785 &repo,
11786 partial.state,
11787 partial.roles,
11788 1,
11789 runner.state.base_commit.clone(),
11790 selection,
11791 )
11792 .await
11793 {
11794 Ok(_) => panic!("setup must fail"),
11795 Err(e) => e,
11796 };
11797 assert_eq!(
11798 error
11799 .downcast_ref::<crate::pr_recovery::StartupFailed>()
11800 .unwrap()
11801 .run,
11802 id
11803 );
11804 let failed = RunState::load(&id).unwrap();
11805 assert_eq!(failed.status, RunStatus::Blocked);
11806 assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11807 assert!(Runner::resume(&id).is_err());
11808 runner.state.status = RunStatus::Ready;
11809 runner.state.driver_exited = true;
11810 runner.state.save().unwrap();
11811 let mut retry = runner_at(RunStatus::Reviewing);
11812 retry.state.repo = repo.clone();
11813 retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11814 let mut reselected = runner.state.pr_recovery.clone().unwrap();
11815 reselected.approved_at = jiff::Timestamp::now();
11816 retry.state.origin = Some(crate::run::Origin::queue("selected"));
11817 retry.state.pr_head = Some(reselected.branch.clone());
11818 retry.state.pr_recovery = Some(reselected.clone());
11819 crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11820 .await
11821 .unwrap();
11822 let restarted = Runner::open_selected(
11823 &repo,
11824 retry.state,
11825 retry.roles,
11826 1,
11827 runner.state.base_commit.clone(),
11828 reselected,
11829 )
11830 .await
11831 .unwrap();
11832 assert_eq!(
11833 git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
11834 .await
11835 .unwrap(),
11836 head
11837 );
11838 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11839 assert_eq!(
11840 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11841 head
11842 );
11843 }
11844
11845 #[tokio::test]
11846 async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
11847 ask_test_home();
11848 let tmp = tempfile::tempdir().unwrap();
11849 let repo = tmp.path().join("repo");
11850 std::fs::create_dir_all(&repo).unwrap();
11851 init_repo(&repo);
11852 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11853 git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
11854 std::fs::write(repo.join("referenced"), "required commit").unwrap();
11855 git::git(&repo, &["add", "."]).await.unwrap();
11856 git::git(&repo, &["commit", "-m", "required seed"])
11857 .await
11858 .unwrap();
11859 let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
11860 git::git(&repo, &["checkout", "main"]).await.unwrap();
11861 let mut runner = runner_at(RunStatus::Prep);
11862 runner.state.repo = repo.clone();
11863 runner.state.base_commit = base.clone();
11864 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
11865 runner.state.config.graph.advise = false;
11866 runner.state.config.blind.commit_msg_hook = false;
11867 runner.state.seeds = vec![refs::Seed {
11868 token: seed.clone(),
11869 sha: seed.clone(),
11870 kind: refs::SeedKind::Unmerged,
11871 branch: false,
11872 detail: String::new(),
11873 }];
11874 runner.roles.implementers = vec![spec("one"), spec("two")];
11875 runner.roles.judges = vec![spec("judge")];
11876 let label = blind::assign_labels(2, runner.state.seed)[0];
11877 let branch = runner.state.branch_for(label);
11878 let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
11879 git::worktree_add_branch(&repo, &worktree, &branch, &base)
11880 .await
11881 .unwrap();
11882 runner.state.candidates.push(Candidate {
11883 index: 0,
11884 label,
11885 agent: "one".into(),
11886 branch,
11887 worktree,
11888 summary: String::new(),
11889 stat: String::new(),
11890 files: 0,
11891 commits: 0,
11892 empty: false,
11893 failed: None,
11894 verified_noop: None,
11895 duration_ms: 0,
11896 folded: false,
11897 });
11898 runner.state.save().unwrap();
11899 runner.state = RunState::load(&runner.state.id).unwrap();
11901 assert_eq!(runner.state.prep_complete, Some(false));
11902 runner.prep().await.unwrap();
11903 assert_eq!(runner.state.prep_complete, Some(true));
11904 assert_eq!(runner.state.candidates.len(), 2);
11905 for candidate in &runner.state.candidates {
11906 assert_eq!(
11907 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11908 git::tree_of(&repo, &seed).await.unwrap()
11909 );
11910 assert_eq!(
11911 std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
11912 "required commit"
11913 );
11914 }
11915 assert!(runner.state.worktree_root().join("judge-1").exists());
11916 let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
11917 runner.prep().await.unwrap();
11918 assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
11919
11920 runner.state.prep_complete = Some(false);
11921 runner.state.config.worktree.setup = vec![crate::config::SetupStep {
11922 run: Some(format!(
11923 "test -f '{}'",
11924 tmp.path().join("dependency-ready").display()
11925 )),
11926 ..Default::default()
11927 }];
11928 assert!(runner.prep().await.is_err());
11929 assert!(runner.state.prep_failure.is_some());
11930 std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
11931 runner.state = RunState::load(&runner.state.id).unwrap();
11932 assert!(
11933 runner.prep().await.is_err(),
11934 "correcting the cause alone must not auto-retry"
11935 );
11936 let failed_at = runner.state.events.last().unwrap().at;
11937 assert!(
11938 runner.retry_preparation(failed_at).is_err(),
11939 "old approvals cannot unlock the hold"
11940 );
11941 let mut task = crate::queue::Task::new(
11942 "retry".into(),
11943 "retry setup".into(),
11944 repo.clone(),
11945 crate::queue::Source::Human,
11946 );
11947 task.start(runner.state.id.clone());
11948 task.hold_machine(Some("setup failed".into()));
11949 task.release_operator("cli", None);
11950 runner
11951 .retry_preparation(task.resumption.as_ref().unwrap().at)
11952 .unwrap();
11953 runner.prep().await.unwrap();
11954 assert!(runner.state.prep_failure.is_none());
11955 assert_eq!(runner.state.prep_complete, Some(true));
11956 assert_eq!(runner.state.candidates.len(), 2);
11957 assert!(runner.state.worktree_root().join("judge-1").exists());
11958 for candidate in &runner.state.candidates {
11959 assert_eq!(
11960 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11961 git::tree_of(&repo, &seed).await.unwrap()
11962 );
11963 }
11964 }
11965
11966 async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
11967 let mut heads = Vec::new();
11968 for candidate in candidates {
11969 heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
11970 }
11971 heads
11972 }
11973
11974 fn init_repo(dir: &Path) {
11975 let run = |args: &[&str]| {
11976 let out = std::process::Command::new("git")
11977 .args(args)
11978 .current_dir(dir)
11979 .quiet()
11980 .output()
11981 .expect("spawn git");
11982 assert!(
11983 out.status.success(),
11984 "git {args:?} failed: {}",
11985 String::from_utf8_lossy(&out.stderr)
11986 );
11987 };
11988 run(&["init", "-b", "main"]);
11989 run(&["config", "user.name", "magi test"]);
11990 run(&["config", "user.email", "magi@example.com"]);
11991 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11992 run(&["add", "-A"]);
11993 run(&["commit", "-m", "init"]);
11994 }
11995
11996 fn ask_test_home() {
12004 crate::run::pin_test_home();
12005 }
12006
12007 fn runner_at(status: RunStatus) -> Runner {
12010 let mut state = RunState::new(
12011 PathBuf::from("/nonexistent/repo"),
12012 "main".to_owned(),
12013 "deadbeef".to_owned(),
12014 "task".to_owned(),
12015 Config::default(),
12016 );
12017 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12020 state.id = format!(
12021 "20000101-000000-{:04x}",
12022 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
12023 );
12024 state.status = status;
12025 Runner {
12026 state,
12027 roles: ResolvedRoles {
12028 implementers: Vec::new(),
12029 judges: Vec::new(),
12030 reviewers: Vec::new(),
12031 fixer: None,
12032 conductor: conductor(),
12033 implementer_roster: Vec::new(),
12034 judge_roster: Vec::new(),
12035 reviewer_roster: Vec::new(),
12036 },
12037 sem: Arc::new(Semaphore::new(1)),
12038 pause: Pause::new(),
12039 interrupt: Pause::new(),
12040 }
12041 }
12042
12043 fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
12044 crate::run::MergeRepair {
12045 question: "repair-question".into(),
12046 head: "old-head".into(),
12047 pr: "https://github.com/o/r/pull/42".into(),
12048 reviews: vec![review_round(false, 1, 1, 1, false, true)],
12049 gate: vec![outcome(Some(0), false)],
12050 gate_head: Some("old-head".into()),
12051 gate_fixes: Vec::new(),
12052 pre_gate: Vec::new(),
12053 phase,
12054 failure: None,
12055 }
12056 }
12057
12058 #[tokio::test]
12059 async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
12060 crate::run::pin_test_home();
12061 let mut runner = runner_at(RunStatus::Landing);
12062 runner.state.merge_repairs.push(merge_repair_request(
12063 crate::run::MergeRepairPhase::Requested,
12064 ));
12065 runner.state.save().unwrap();
12066 let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
12067 assert!(
12068 runner
12069 .execute()
12070 .await
12071 .unwrap_err()
12072 .to_string()
12073 .contains("already running")
12074 );
12075 assert_eq!(
12076 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12077 crate::run::MergeRepairPhase::Requested
12078 );
12079 drop(claim);
12080 let mut disk = runner.state.clone();
12081 disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12082 disk.save().unwrap();
12083 assert!(
12084 runner
12085 .execute()
12086 .await
12087 .unwrap_err()
12088 .to_string()
12089 .contains("decision changed")
12090 );
12091 assert_eq!(
12092 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12093 crate::run::MergeRepairPhase::Complete
12094 );
12095 }
12096
12097 #[test]
12098 fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12099 crate::run::pin_test_home();
12100 let mut runner = runner_at(RunStatus::Landing);
12101 let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12102 runner.state.reviews = request.reviews.clone();
12103 runner.state.gate = request.gate.clone();
12104 runner.state.gate_ran = true;
12105 runner.state.land_approval = Some(crate::run::LandApproval {
12106 question: "old".into(),
12107 head: "old-head".into(),
12108 });
12109 runner.state.merge_repairs.push(request);
12110 runner.begin_merge_repair_review(0).unwrap();
12111 assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12112 assert!(!runner.state.gate_status().ok());
12113 assert!(runner.state.land_approval.is_none());
12114 let disk = RunState::load(&runner.state.id).unwrap();
12115 assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12116 assert!(disk.merge_repairs[0].gate[0].ok());
12117 assert_eq!(
12118 disk.merge_repairs[0].phase,
12119 crate::run::MergeRepairPhase::Reviewing
12120 );
12121 assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12122 assert!(disk.reviews.is_empty());
12123 }
12124
12125 #[tokio::test]
12126 async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12127 crate::run::pin_test_home();
12128 for phase in [
12129 crate::run::MergeRepairPhase::Fixing,
12130 crate::run::MergeRepairPhase::Requested,
12131 ] {
12132 let mut runner = runner_at(RunStatus::Landing);
12133 runner.state.config.graph.land_rounds = 0;
12134 runner.state.merge_repairs.push(merge_repair_request(phase));
12135 runner.state.save().unwrap();
12136 assert!(!runner.repair_merge_review().await.unwrap());
12137 assert_eq!(runner.state.status, RunStatus::Blocked);
12138 assert_eq!(
12139 runner.state.merge_repairs[0].phase,
12140 crate::run::MergeRepairPhase::Failed
12141 );
12142 let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12143 assert!(
12144 reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12145 "interrupted"
12146 } else {
12147 "budget exhausted"
12148 })
12149 );
12150 assert!(runner.state.merge.is_none());
12151 }
12152 }
12153
12154 #[test]
12158 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12159 crate::run::pin_test_home();
12160 let mut runner = runner_at(RunStatus::Implementing);
12161 let interrupt = Pause::new();
12162 runner.watch_interrupt(interrupt.clone());
12163
12164 interrupt.park_because("task a1b2 asked to run first");
12165
12166 assert!(runner.park_here().expect("park_here"));
12167 assert!(runner.state.parked);
12168 let last = runner.state.events.last().expect("a park event");
12169 assert_eq!(last.node, "park");
12170 assert!(
12171 last.message.contains("task a1b2 asked to run first"),
12172 "expected the interrupt reason in {:?}",
12173 last.message
12174 );
12175 }
12176
12177 #[test]
12180 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12181 crate::run::pin_test_home();
12182 let mut runner = runner_at(RunStatus::Implementing);
12183 runner.state.begin_pass();
12184 let shutdown = Pause::new();
12185 runner.on_pause(shutdown.clone());
12186 let upgrade = ParkCause::Upgrade {
12187 from: "0.121.0".to_owned(),
12188 to: Some("0.121.1".to_owned()),
12189 };
12190 shutdown.park_for(upgrade.clone());
12191 shutdown.park_for(ParkCause::Operator);
12192
12193 assert!(runner.park_here().expect("park_here"));
12194 assert_eq!(
12195 runner.state.passes[0].end,
12196 Some(crate::run::PassEnd::Parked { cause: upgrade })
12197 );
12198 let last = runner.state.events.last().expect("a park event");
12199 assert!(last.message.contains("0.121.0"), "{}", last.message);
12200 }
12201
12202 #[test]
12210 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12211 crate::run::pin_test_home();
12212 let mut runner = runner_at(RunStatus::Implementing);
12213 let shutdown = Pause::new();
12214 runner.on_pause(shutdown.clone());
12215 let interrupt = Pause::new();
12216 runner.watch_interrupt(interrupt.clone());
12217
12218 assert!(!runner.park_here().expect("park_here"));
12220 assert!(!runner.state.parked);
12221
12222 interrupt.park_because("test");
12224 assert!(!shutdown.parked());
12225 assert!(runner.park_here().expect("park_here"));
12226 }
12227
12228 #[tokio::test]
12242 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12243 crate::run::pin_test_home();
12244 let mut runner = runner_at(RunStatus::Implementing);
12245 let interrupt = Pause::new();
12246 runner.watch_interrupt(interrupt.clone());
12247
12248 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12249 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12250
12251 let node = async move {
12255 started_tx.send(()).expect("send started");
12256 finish_rx.await.expect("recv finish");
12257 "node finished"
12258 };
12259
12260 let interrupter = async move {
12261 started_rx.await.expect("recv started");
12262 interrupt.park_because("higher-priority task waiting");
12264 tokio::task::yield_now().await;
12268 finish_tx.send(()).expect("send finish");
12269 };
12270
12271 let (node_result, ()) = tokio::join!(node, interrupter);
12272 assert_eq!(
12273 node_result, "node finished",
12274 "the in-flight call ran to completion"
12275 );
12276
12277 assert!(runner.park_here().expect("park_here"));
12280 assert!(runner.state.parked);
12281 }
12282
12283 #[test]
12289 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12290 crate::run::pin_test_home();
12291 let mut runner = runner_at(RunStatus::Judging);
12292 runner.state.config.agents = vec![conductor()];
12296 runner.state.candidates = vec![Candidate {
12297 index: 0,
12298 label: 'A',
12299 agent: "alpha".to_owned(),
12300 branch: "magi/x/A".to_owned(),
12301 worktree: PathBuf::from("/nonexistent/worktree"),
12302 summary: "did the thing".to_owned(),
12303 stat: "1 file changed".to_owned(),
12304 files: 1,
12305 commits: 1,
12306 empty: false,
12307 failed: None,
12308 verified_noop: None,
12309 duration_ms: 1234,
12310 folded: false,
12311 }];
12312 let run_id = runner.state.id.clone();
12313
12314 let interrupt = Pause::new();
12315 runner.watch_interrupt(interrupt.clone());
12316 interrupt.park_because("task c3d4 asked to run first");
12317 assert!(runner.park_here().expect("park_here"));
12318
12319 let resumed = Runner::resume(&run_id).expect("resume");
12320 assert_eq!(resumed.state.candidates.len(), 1);
12321 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12322 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12323 assert_eq!(resumed.state.status, runner.state.status);
12324 assert!(
12325 resumed.state.parked,
12326 "still parked until `execute` actually walks the graph again"
12327 );
12328 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12329 }
12330
12331 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12333 let mut q = ask::Question::new(
12334 run.to_owned(),
12335 "implement".to_owned(),
12336 "impl-A".to_owned(),
12337 "Which storage backend should the cache use?".to_owned(),
12338 String::new(),
12339 vec!["SQLite".to_owned(), "Redis".to_owned()],
12340 );
12341 store.put(&mut q).unwrap();
12342 q
12343 }
12344
12345 #[test]
12346 fn a_failed_runs_open_question_is_abandoned() {
12347 ask_test_home();
12348 let store = ask::Questions::open();
12349 let mut runner = runner_at(RunStatus::Failed);
12350 let run = runner.state.id.clone();
12351 let q = ask_open_question(&store, &run);
12352
12353 runner.settle_questions();
12354
12355 let back = store.get(&q.id).unwrap();
12356 assert!(
12357 !back.status.open(),
12358 "the seat that asked died with the run; nobody is left to read an answer"
12359 );
12360 assert!(
12361 back.detail.contains(&run) && back.detail.contains("failed"),
12362 "the reason names what the run became, not just that it is gone: {}",
12363 back.detail
12364 );
12365 }
12366
12367 #[test]
12368 fn a_merged_runs_open_question_is_abandoned_too() {
12369 ask_test_home();
12370 let store = ask::Questions::open();
12371 for status in [RunStatus::Merged, RunStatus::Ready] {
12374 let mut runner = runner_at(status);
12375 let run = runner.state.id.clone();
12376 let q = ask_open_question(&store, &run);
12377
12378 runner.settle_questions();
12379
12380 let back = store.get(&q.id).unwrap();
12381 assert!(
12382 !back.status.open(),
12383 "{status:?} run's question must not outlive the run"
12384 );
12385 }
12386 }
12387
12388 #[test]
12389 fn a_still_resumable_runs_open_question_is_left_alone() {
12390 ask_test_home();
12391 let store = ask::Questions::open();
12392 for status in [RunStatus::Blocked, RunStatus::Stalled] {
12398 let mut runner = runner_at(status);
12399 let run = runner.state.id.clone();
12400 let q = ask_open_question(&store, &run);
12401
12402 runner.settle_questions();
12403
12404 let back = store.get(&q.id).unwrap();
12405 assert!(
12406 back.status.open(),
12407 "{status:?} is still alive; the question must still be waiting"
12408 );
12409 }
12410 }
12411
12412 #[test]
12413 fn settle_questions_never_touches_an_already_answered_question() {
12414 ask_test_home();
12415 let store = ask::Questions::open();
12416 let mut runner = runner_at(RunStatus::Failed);
12417 let run = runner.state.id.clone();
12418 let mut q = ask_open_question(&store, &run);
12419 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12420 .unwrap();
12421 store.put(&mut q).unwrap();
12422
12423 runner.settle_questions();
12428 runner.settle_questions();
12429
12430 let back = store.get(&q.id).unwrap();
12431 assert_eq!(
12432 back.status,
12433 ask::QuestionStatus::Answered,
12434 "a real answer is a decision on record, never overwritten by a sweep"
12435 );
12436 }
12437
12438 #[tokio::test]
12449 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12450 crate::run::pin_test_home();
12451 let tmp = tempfile::tempdir().expect("tempdir");
12452 let repo = tmp.path().join("repo");
12453 std::fs::create_dir_all(&repo).unwrap();
12454 init_repo(&repo);
12455
12456 let mut config = Config::default();
12457 config.graph.worktree_root = Some(tmp.path().join("wt"));
12458
12459 let mut state = RunState::new(
12460 repo.clone(),
12461 "main".to_owned(),
12462 "deadbeef".to_owned(),
12463 "task".to_owned(),
12464 config,
12465 );
12466 let root = state.worktree_root();
12467 let wt_a = root.join("cand-A");
12468 let wt_b = root.join("cand-B");
12469 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12470 .await
12471 .expect("worktree A");
12472 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12473 .await
12474 .expect("worktree B");
12475
12476 state.candidates = vec![
12477 Candidate {
12478 index: 0,
12479 label: 'A',
12480 agent: "alpha".to_owned(),
12481 branch: "magi/x/A".to_owned(),
12482 worktree: wt_a.clone(),
12483 summary: String::new(),
12484 stat: String::new(),
12485 files: 0,
12486 commits: 0,
12487 empty: false,
12488 failed: None,
12489 verified_noop: None,
12490 duration_ms: 0,
12491 folded: false,
12492 },
12493 Candidate {
12494 index: 1,
12495 label: 'B',
12496 agent: "beta".to_owned(),
12497 branch: "magi/x/B".to_owned(),
12498 worktree: wt_b.clone(),
12499 summary: String::new(),
12500 stat: String::new(),
12501 files: 0,
12502 commits: 0,
12503 empty: false,
12504 failed: None,
12505 verified_noop: None,
12506 duration_ms: 0,
12507 folded: false,
12508 },
12509 ];
12510 state.tally = Some(Tally {
12511 first_choice: BTreeMap::from([('A', 1)]),
12512 borda: BTreeMap::new(),
12513 winner: 'A',
12514 rankings: 1,
12515 unanimous_initial: true,
12516 deliberated: false,
12517 changed_votes: 0,
12518 unanimous_final: true,
12519 tie_break: None,
12520 judges: 1,
12521 present: 1,
12522 quorum: 1,
12523 met_quorum: true,
12524 uncontested: None,
12525 });
12526 state.status = RunStatus::Ready;
12527
12528 fold_run(&mut state, false, &crate::run::home())
12529 .await
12530 .expect("fold_run");
12531
12532 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12533 assert!(
12534 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12535 "the unmerged winner's branch survives"
12536 );
12537 assert!(
12538 !state.candidates[0].folded,
12539 "the winner is not marked folded"
12540 );
12541
12542 assert!(!wt_b.exists(), "the loser's worktree is removed");
12543 assert!(
12544 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12545 "the loser's branch is removed"
12546 );
12547 assert!(state.candidates[1].folded, "the loser is marked folded");
12548 }
12549
12550 #[tokio::test]
12553 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12554 let tmp = tempfile::tempdir().expect("tempdir");
12555 let repo = tmp.path().join("repo");
12556 std::fs::create_dir_all(&repo).unwrap();
12557 init_repo(&repo);
12558 let home = tmp.path().join("home");
12559
12560 let mut config = Config::default();
12561 config.graph.worktree_root = Some(tmp.path().join("wt"));
12562 let mut state = RunState::new(
12563 repo.clone(),
12564 "main".to_owned(),
12565 "deadbeef".to_owned(),
12566 "task".to_owned(),
12567 config,
12568 );
12569 git::git(&repo, &["branch", "magi/x/A", "main"])
12571 .await
12572 .expect("branch");
12573 state.candidates = vec![Candidate {
12574 index: 0,
12575 label: 'A',
12576 agent: "alpha".to_owned(),
12577 branch: "magi/x/A".to_owned(),
12578 worktree: state.worktree_root().join("cand-A"),
12579 summary: String::new(),
12580 stat: String::new(),
12581 files: 0,
12582 commits: 0,
12583 empty: false,
12584 failed: None,
12585 verified_noop: None,
12586 duration_ms: 0,
12587 folded: true,
12588 }];
12589 state.released_to = Some("20260901-000000-new1".to_owned());
12590 state.released_branches = vec!["magi/x/A".to_owned()];
12591
12592 fold_run(&mut state, true, &home).await.expect("fold_run");
12593
12594 assert!(
12595 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12596 "the handed-over branch survives a fold"
12597 );
12598 }
12599
12600 #[tokio::test]
12604 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12605 let tmp = tempfile::tempdir().expect("tempdir");
12606 let repo = tmp.path().join("repo");
12607 std::fs::create_dir_all(&repo).unwrap();
12608 init_repo(&repo);
12609 let run = |args: &[&str]| {
12610 let out = std::process::Command::new("git")
12611 .quiet()
12612 .args(args)
12613 .current_dir(&repo)
12614 .output()
12615 .expect("spawn git");
12616 assert!(out.status.success(), "git {args:?}");
12617 };
12618 run(&["branch", "magi/x/A"]);
12619 run(&["checkout", "-q", "-b", "magi/x/B"]);
12620 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12621 run(&["add", "-A"]);
12622 run(&["commit", "-q", "-m", "work"]);
12623 run(&["checkout", "-q", "main"]);
12624
12625 let probe = RunState::new(
12629 repo.clone(),
12630 "main".to_owned(),
12631 "deadbeef".to_owned(),
12632 "task".to_owned(),
12633 Config::default(),
12634 );
12635 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12636 let bare = tmp.path().join("origin.git");
12637 let out = std::process::Command::new("git")
12638 .quiet()
12639 .args(["init", "-q", "--bare"])
12640 .arg(&bare)
12641 .output()
12642 .expect("spawn git");
12643 assert!(out.status.success(), "git init --bare");
12644 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12645 run(&["push", "-q", "origin", "main"]);
12646
12647 let mut state = RunState::new(
12648 repo.clone(),
12649 "main".to_owned(),
12650 "deadbeef".to_owned(),
12651 "task".to_owned(),
12652 Config::default(),
12653 );
12654 state.seeds = vec![refs::Seed {
12655 token: "magi/27b2/A".to_owned(),
12656 kind: refs::SeedKind::Unresolved,
12657 sha: String::new(),
12658 branch: true,
12659 detail: "no branch or commit named magi/27b2/A".to_owned(),
12660 }];
12661
12662 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12663 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12664 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12665 let detail = empty_candidate_detail(&state, "main");
12666 assert!(detail.starts_with("empty candidate"), "{detail}");
12667 assert!(detail.contains("magi/27b2/A"), "{detail}");
12668 }
12669
12670 #[tokio::test]
12679 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12680 let tmp = tempfile::tempdir().expect("tempdir");
12681 let repo = tmp.path().join("repo");
12682 std::fs::create_dir_all(&repo).unwrap();
12683 init_repo(&repo);
12684
12685 let mut config = Config::default();
12686 config.merge.mode = MergeMode::Local;
12687
12688 let mut state = RunState::new(
12689 repo.clone(),
12690 "main".to_owned(),
12691 "deadbeef".to_owned(),
12692 "task".to_owned(),
12693 config,
12694 );
12695 state.candidates = vec![Candidate {
12696 index: 0,
12697 label: 'A',
12698 agent: "alpha".to_owned(),
12699 branch: "does-not-exist".to_owned(),
12700 worktree: repo.clone(),
12701 summary: String::new(),
12702 stat: String::new(),
12703 files: 0,
12704 commits: 0,
12705 empty: false,
12706 failed: None,
12707 verified_noop: None,
12708 duration_ms: 0,
12709 folded: false,
12710 }];
12711 state.tally = Some(Tally {
12712 first_choice: BTreeMap::from([('A', 1)]),
12713 borda: BTreeMap::new(),
12714 winner: 'A',
12715 rankings: 1,
12716 unanimous_initial: true,
12717 deliberated: false,
12718 changed_votes: 0,
12719 unanimous_final: true,
12720 tie_break: None,
12721 judges: 0,
12722 present: 0,
12723 quorum: 0,
12724 met_quorum: true,
12725 uncontested: Some("only candidate A produced a change".to_owned()),
12726 });
12727 state.reviews = vec![ReviewRound {
12728 round: 1,
12729 head: "deadbeef".to_owned(),
12730 verified_head: None,
12731 verified_at: None,
12732 reviews: Vec::new(),
12733 e2e: Vec::new(),
12734 fix: None,
12735 blocking: 0,
12736 answered: 0,
12737 expected: 0,
12738 clean: true,
12739 verify_retried: false,
12740 e2e_deferred: false,
12741 e2e_defer_reason: None,
12742 progressed: false,
12743 vote_split: false,
12744 reconsideration: Vec::new(),
12745 verdict: None,
12746 }];
12747 state.gate = vec![CommandOutcome {
12748 command: "test".to_owned(),
12749 code: Some(0),
12750 output_tail: String::new(),
12751 duration_ms: 0,
12752 resource_blocked: false,
12753 }];
12754 state.gate_ran = true;
12755 state.status = RunStatus::Ready;
12760 state.merge = Some(MergeOutcome {
12761 mode: MergeMode::Local,
12762 ok: false,
12763 detail: "already concluded".to_owned(),
12764 empty: false,
12765 });
12766
12767 let mut runner = Runner {
12768 state,
12769 roles: ResolvedRoles {
12770 implementers: Vec::new(),
12771 judges: Vec::new(),
12772 reviewers: Vec::new(),
12773 fixer: None,
12774 conductor: conductor(),
12775 implementer_roster: Vec::new(),
12776 judge_roster: Vec::new(),
12777 reviewer_roster: Vec::new(),
12778 },
12779 sem: Arc::new(Semaphore::new(1)),
12780 pause: Pause::new(),
12781 interrupt: Pause::new(),
12782 };
12783
12784 runner.merge().await.expect("merge");
12785
12786 assert_eq!(
12787 runner.state.status,
12788 RunStatus::Ready,
12789 "a concluded run's status must not change on reentry"
12790 );
12791 assert_eq!(
12792 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12793 Some("already concluded"),
12794 "merge must not run again once the node already recorded an outcome"
12795 );
12796 }
12797
12798 #[tokio::test]
12807 async fn merge_refuses_a_gate_that_has_not_actually_run() {
12808 let tmp = tempfile::tempdir().expect("tempdir");
12809 let repo = tmp.path().join("repo");
12810 std::fs::create_dir_all(&repo).unwrap();
12811 init_repo(&repo);
12812
12813 let mut config = Config::default();
12814 config.merge.mode = MergeMode::Local;
12815
12816 let mut state = RunState::new(
12817 repo.clone(),
12818 "main".to_owned(),
12819 "deadbeef".to_owned(),
12820 "task".to_owned(),
12821 config,
12822 );
12823 state.candidates = vec![Candidate {
12824 index: 0,
12825 label: 'A',
12826 agent: "alpha".to_owned(),
12827 branch: "does-not-exist".to_owned(),
12828 worktree: repo.clone(),
12829 summary: String::new(),
12830 stat: String::new(),
12831 files: 0,
12832 commits: 0,
12833 empty: false,
12834 failed: None,
12835 verified_noop: None,
12836 duration_ms: 0,
12837 folded: false,
12838 }];
12839 state.tally = Some(Tally {
12840 first_choice: BTreeMap::from([('A', 1)]),
12841 borda: BTreeMap::new(),
12842 winner: 'A',
12843 rankings: 1,
12844 unanimous_initial: true,
12845 deliberated: false,
12846 changed_votes: 0,
12847 unanimous_final: true,
12848 tie_break: None,
12849 judges: 0,
12850 present: 0,
12851 quorum: 0,
12852 met_quorum: true,
12853 uncontested: Some("only candidate A produced a change".to_owned()),
12854 });
12855 state.reviews = vec![ReviewRound {
12856 round: 1,
12857 head: "deadbeef".to_owned(),
12858 verified_head: None,
12859 verified_at: None,
12860 reviews: Vec::new(),
12861 e2e: Vec::new(),
12862 fix: None,
12863 blocking: 0,
12864 answered: 0,
12865 expected: 0,
12866 clean: true,
12867 verify_retried: false,
12868 e2e_deferred: false,
12869 e2e_defer_reason: None,
12870 progressed: false,
12871 vote_split: false,
12872 reconsideration: Vec::new(),
12873 verdict: None,
12874 }];
12875 state.gate = Vec::new();
12877 state.gate_ran = false;
12878 state.status = RunStatus::Gating;
12879
12880 let mut runner = Runner {
12881 state,
12882 roles: ResolvedRoles {
12883 implementers: Vec::new(),
12884 judges: Vec::new(),
12885 reviewers: Vec::new(),
12886 fixer: None,
12887 conductor: conductor(),
12888 implementer_roster: Vec::new(),
12889 judge_roster: Vec::new(),
12890 reviewer_roster: Vec::new(),
12891 },
12892 sem: Arc::new(Semaphore::new(1)),
12893 pause: Pause::new(),
12894 interrupt: Pause::new(),
12895 };
12896
12897 runner.merge().await.expect("merge");
12898
12899 assert!(
12900 runner.state.merge.is_none(),
12901 "an empty gate must never be read as a passing one: {:?}",
12902 runner.state.merge
12903 );
12904 }
12905
12906 #[tokio::test]
12913 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12914 let tmp = tempfile::tempdir().expect("tempdir");
12915 let repo = tmp.path().join("repo");
12916 std::fs::create_dir_all(&repo).unwrap();
12917 init_repo(&repo);
12918
12919 let config = Config::default();
12921
12922 let mut state = RunState::new(
12923 repo.clone(),
12924 "main".to_owned(),
12925 "deadbeef".to_owned(),
12926 "task".to_owned(),
12927 config,
12928 );
12929 state.candidates = vec![Candidate {
12930 index: 0,
12931 label: 'A',
12932 agent: "alpha".to_owned(),
12933 branch: "does-not-exist".to_owned(),
12934 worktree: repo.clone(),
12935 summary: String::new(),
12936 stat: String::new(),
12937 files: 0,
12938 commits: 0,
12939 empty: false,
12940 failed: None,
12941 verified_noop: None,
12942 duration_ms: 0,
12943 folded: false,
12944 }];
12945 state.tally = Some(Tally {
12946 first_choice: BTreeMap::from([('A', 1)]),
12947 borda: BTreeMap::new(),
12948 winner: 'A',
12949 rankings: 1,
12950 unanimous_initial: true,
12951 deliberated: false,
12952 changed_votes: 0,
12953 unanimous_final: true,
12954 tie_break: None,
12955 judges: 0,
12956 present: 0,
12957 quorum: 0,
12958 met_quorum: true,
12959 uncontested: Some("only candidate A produced a change".to_owned()),
12960 });
12961 state.reviews = vec![ReviewRound {
12962 round: 1,
12963 head: "deadbeef".to_owned(),
12964 verified_head: None,
12965 verified_at: None,
12966 reviews: Vec::new(),
12967 e2e: Vec::new(),
12968 fix: None,
12969 blocking: 0,
12970 answered: 0,
12971 expected: 0,
12972 clean: true,
12973 verify_retried: false,
12974 e2e_deferred: false,
12975 e2e_defer_reason: None,
12976 progressed: false,
12977 vote_split: false,
12978 reconsideration: Vec::new(),
12979 verdict: None,
12980 }];
12981
12982 let mut runner = Runner {
12983 state,
12984 roles: ResolvedRoles {
12985 implementers: Vec::new(),
12986 judges: Vec::new(),
12987 reviewers: Vec::new(),
12988 fixer: None,
12989 conductor: conductor(),
12990 implementer_roster: Vec::new(),
12991 judge_roster: Vec::new(),
12992 reviewer_roster: Vec::new(),
12993 },
12994 sem: Arc::new(Semaphore::new(1)),
12995 pause: Pause::new(),
12996 interrupt: Pause::new(),
12997 };
12998
12999 runner.gate().await.expect("gate");
13000 assert!(
13001 runner.state.gate_ran,
13002 "zero configured commands is still a real attempt, not an unrun gate"
13003 );
13004 assert!(runner.state.gate.is_empty());
13005 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
13006 assert_ne!(
13007 runner.state.status,
13008 RunStatus::Blocked,
13009 "a gate with nothing to check must not read as failed"
13010 );
13011
13012 runner.merge().await.expect("merge");
13013 assert_eq!(
13014 runner.state.status,
13015 RunStatus::Ready,
13016 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
13017 );
13018 }
13019
13020 #[tokio::test]
13031 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
13032 crate::run::pin_test_home();
13033 let home = crate::run::home();
13034
13035 let tmp = tempfile::tempdir().expect("tempdir");
13036 let repo = tmp.path().join("repo");
13037 std::fs::create_dir_all(&repo).unwrap();
13038 init_repo(&repo);
13039 let cache_dir = tmp.path().join("target");
13042
13043 let mut config = Config::default();
13044 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
13045 config.graph.timeout_verify = Some(2);
13048 config.graph.timeout_resource_wait = 2;
13049
13050 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13051 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13052 .expect("no io error acquiring directly")
13053 {
13054 crate::cache::AcquireOutcome::Acquired(g) => g,
13055 crate::cache::AcquireOutcome::Busy(b) => {
13056 panic!("expected the direct acquire to win the lease first: {b:?}")
13057 }
13058 };
13059
13060 let mut state = RunState::new(
13061 repo.clone(),
13062 "main".to_owned(),
13063 "deadbeef".to_owned(),
13064 "task".to_owned(),
13065 config,
13066 );
13067 state.candidates = vec![Candidate {
13068 index: 0,
13069 label: 'A',
13070 agent: "alpha".to_owned(),
13071 branch: "does-not-exist".to_owned(),
13072 worktree: repo.clone(),
13073 summary: String::new(),
13074 stat: String::new(),
13075 files: 0,
13076 commits: 0,
13077 empty: false,
13078 failed: None,
13079 verified_noop: None,
13080 duration_ms: 0,
13081 folded: false,
13082 }];
13083 state.tally = Some(Tally {
13084 first_choice: BTreeMap::from([('A', 1)]),
13085 borda: BTreeMap::new(),
13086 winner: 'A',
13087 rankings: 1,
13088 unanimous_initial: true,
13089 deliberated: false,
13090 changed_votes: 0,
13091 unanimous_final: true,
13092 tie_break: None,
13093 judges: 0,
13094 present: 0,
13095 quorum: 0,
13096 met_quorum: true,
13097 uncontested: Some("only candidate A produced a change".to_owned()),
13098 });
13099 state.reviews = vec![ReviewRound {
13100 round: 1,
13101 head: "deadbeef".to_owned(),
13102 verified_head: None,
13103 verified_at: None,
13104 reviews: Vec::new(),
13105 e2e: Vec::new(),
13106 fix: None,
13107 blocking: 0,
13108 answered: 0,
13109 expected: 0,
13110 clean: true,
13111 verify_retried: false,
13112 e2e_deferred: false,
13113 e2e_defer_reason: None,
13114 progressed: false,
13115 vote_split: false,
13116 reconsideration: Vec::new(),
13117 verdict: None,
13118 }];
13119
13120 let mut runner = Runner {
13121 state,
13122 roles: ResolvedRoles {
13123 implementers: Vec::new(),
13124 judges: Vec::new(),
13125 reviewers: Vec::new(),
13126 fixer: None,
13127 conductor: conductor(),
13128 implementer_roster: Vec::new(),
13129 judge_roster: Vec::new(),
13130 reviewer_roster: Vec::new(),
13131 },
13132 sem: Arc::new(Semaphore::new(1)),
13133 pause: Pause::new(),
13134 interrupt: Pause::new(),
13135 };
13136
13137 let started = std::time::Instant::now();
13138 runner.gate().await.expect("gate");
13139 assert!(
13140 started.elapsed() < Duration::from_secs(1),
13141 "a gate with nothing to run must never wait on a lease it never needed"
13142 );
13143 assert!(
13144 runner.state.gate_ran,
13145 "zero commands is still a real, immediate attempt"
13146 );
13147 assert!(runner.state.gate.is_empty());
13148 assert_ne!(
13149 runner.state.status,
13150 RunStatus::Blocked,
13151 "must not read as resource-blocked on a lease it never asked for"
13152 );
13153 }
13154
13155 #[tokio::test]
13165 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
13166 crate::run::pin_test_home();
13167
13168 let tmp = tempfile::tempdir().expect("tempdir");
13169 let repo = tmp.path().join("repo");
13170 std::fs::create_dir_all(&repo).unwrap();
13171 init_repo(&repo);
13172
13173 let mut config = Config::default();
13174 config.verify.gate = vec![
13175 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
13176 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
13177 .to_owned(),
13178 ];
13179
13180 let mut state = RunState::new(
13181 repo.clone(),
13182 "main".to_owned(),
13183 "deadbeef".to_owned(),
13184 "task".to_owned(),
13185 config,
13186 );
13187 let run_id = state.id.clone();
13188 state.candidates = vec![Candidate {
13189 index: 0,
13190 label: 'A',
13191 agent: "alpha".to_owned(),
13192 branch: "does-not-exist".to_owned(),
13193 worktree: repo.clone(),
13194 summary: String::new(),
13195 stat: String::new(),
13196 files: 0,
13197 commits: 0,
13198 empty: false,
13199 failed: None,
13200 verified_noop: None,
13201 duration_ms: 0,
13202 folded: false,
13203 }];
13204 state.tally = Some(Tally {
13205 first_choice: BTreeMap::from([('A', 1)]),
13206 borda: BTreeMap::new(),
13207 winner: 'A',
13208 rankings: 1,
13209 unanimous_initial: true,
13210 deliberated: false,
13211 changed_votes: 0,
13212 unanimous_final: true,
13213 tie_break: None,
13214 judges: 0,
13215 present: 0,
13216 quorum: 0,
13217 met_quorum: true,
13218 uncontested: Some("only candidate A produced a change".to_owned()),
13219 });
13220 state.reviews = vec![ReviewRound {
13221 round: 1,
13222 head: "deadbeef".to_owned(),
13223 verified_head: None,
13224 verified_at: None,
13225 reviews: Vec::new(),
13226 e2e: Vec::new(),
13227 fix: None,
13228 blocking: 0,
13229 answered: 0,
13230 expected: 0,
13231 clean: true,
13232 verify_retried: false,
13233 e2e_deferred: false,
13234 e2e_defer_reason: None,
13235 progressed: false,
13236 vote_split: false,
13237 reconsideration: Vec::new(),
13238 verdict: None,
13239 }];
13240
13241 let mut runner = Runner {
13242 state,
13243 roles: ResolvedRoles {
13244 implementers: Vec::new(),
13245 judges: Vec::new(),
13246 reviewers: Vec::new(),
13247 fixer: None,
13248 conductor: conductor(),
13249 implementer_roster: Vec::new(),
13250 judge_roster: Vec::new(),
13251 reviewer_roster: Vec::new(),
13252 },
13253 sem: Arc::new(Semaphore::new(1)),
13254 pause: Pause::new(),
13255 interrupt: Pause::new(),
13256 };
13257
13258 let started_marker = repo.join("started.marker");
13259 let release_marker = repo.join("release.marker");
13260 let poller = tokio::spawn(async move {
13261 for _ in 0..100 {
13266 if started_marker.exists()
13267 && let Ok(s) = crate::run::RunState::load(&run_id)
13268 && let Some(a) = s.active.get("gate")
13269 {
13270 std::fs::write(&release_marker, b"go").expect("release marker");
13271 return Some(a.clone());
13272 }
13273 tokio::time::sleep(Duration::from_millis(50)).await;
13274 }
13275 None
13276 });
13277
13278 runner.gate().await.expect("gate");
13279 let captured = poller.await.expect("poller task");
13280 let captured = captured.expect(
13281 "the poller never saw a `gate` task entry in run.json while the command was \
13282 still blocked on its own release marker",
13283 );
13284
13285 assert_eq!(captured.task.as_deref(), Some("gate"));
13286 assert_eq!(captured.node, "gate");
13287 assert_eq!(captured.index, Some(1));
13288 assert_eq!(captured.total, Some(1));
13289 assert!(
13290 captured
13291 .command
13292 .as_deref()
13293 .is_some_and(|c| c.contains("started.marker")),
13294 "{captured:?}"
13295 );
13296
13297 assert!(
13298 runner.state.active.is_empty(),
13299 "the entry must be cleared once the command actually finished: {:?}",
13300 runner.state.active
13301 );
13302 assert!(runner.state.gate_ran);
13303 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
13304 }
13305
13306 #[tokio::test]
13309 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
13310 use crate::verdict::{Finding, ReviewVote, Severity};
13311 crate::run::pin_test_home();
13312 let tmp = tempfile::tempdir().expect("tempdir");
13313 let repo = tmp.path().join("repo");
13314 std::fs::create_dir_all(&repo).unwrap();
13315 init_repo(&repo);
13316
13317 for (severity, vote, expect) in [
13318 (Severity::Major, ReviewVote::Reject, true),
13319 (Severity::Minor, ReviewVote::Reject, false),
13320 (Severity::Major, ReviewVote::Approve, false),
13321 ] {
13322 let mut round = review_round(false, 1, 1, 1, false, true);
13323 round.reviews = vec![ReviewRecord {
13324 reviewer: 1,
13325 agent: "alpha".to_owned(),
13326 summary: String::new(),
13327 findings: vec![Finding {
13328 id: "R1-1-1".to_owned(),
13329 severity,
13330 file: None,
13331 line: None,
13332 title: "t".to_owned(),
13333 detail: String::new(),
13334 }],
13335 vote: Some(vote),
13336 failed: None,
13337 duration_ms: 0,
13338 attempts: 0,
13339 }];
13340 let mut state = RunState::new(
13341 repo.clone(),
13342 "main".to_owned(),
13343 "deadbeef".to_owned(),
13344 "task".to_owned(),
13345 Config::default(),
13346 );
13347 state.reviews = vec![round];
13348 let mut runner = Runner {
13349 state,
13350 roles: ResolvedRoles {
13351 implementers: Vec::new(),
13352 judges: Vec::new(),
13353 reviewers: Vec::new(),
13354 fixer: None,
13355 conductor: conductor(),
13356 implementer_roster: Vec::new(),
13357 judge_roster: Vec::new(),
13358 reviewer_roster: Vec::new(),
13359 },
13360 sem: Arc::new(Semaphore::new(1)),
13361 pause: Pause::new(),
13362 interrupt: Pause::new(),
13363 };
13364 let shell = runner.state.config.shell();
13365 runner
13366 .stop_reviewing("round budget spent", &shell, &repo)
13367 .await
13368 .expect("stop_reviewing");
13369 assert_eq!(runner.state.status, RunStatus::Gating);
13370 assert_eq!(
13371 runner.state.contested_handoff.is_some(),
13372 expect,
13373 "{severity:?} + {vote:?}"
13374 );
13375 }
13376 }
13377
13378 #[tokio::test]
13391 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
13392 crate::run::pin_test_home();
13393 let home = crate::run::home();
13394
13395 let tmp = tempfile::tempdir().expect("tempdir");
13396 let repo = tmp.path().join("repo");
13397 std::fs::create_dir_all(&repo).unwrap();
13398 init_repo(&repo);
13399 let head = crate::git::rev_parse(&repo, "HEAD")
13400 .await
13401 .expect("rev-parse");
13402 let cache_dir = tmp.path().join("target");
13405
13406 let mut config = Config::default();
13407 config.verify.e2e = vec![format!(
13408 "CARGO_TARGET_DIR='{}' test -f README.md",
13409 cache_dir.display()
13410 )];
13411 config.graph.review_rounds = 1;
13412 config.graph.timeout_verify = Some(2);
13415 config.graph.timeout_resource_wait = 2;
13416
13417 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13418 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13419 .expect("no io error acquiring directly")
13420 {
13421 crate::cache::AcquireOutcome::Acquired(g) => g,
13422 crate::cache::AcquireOutcome::Busy(b) => {
13423 panic!("expected the direct acquire to win the lease first: {b:?}")
13424 }
13425 };
13426
13427 let mut state = RunState::new(
13428 repo.clone(),
13429 "main".to_owned(),
13430 head.clone(),
13431 "task".to_owned(),
13432 config,
13433 );
13434 state.candidates = vec![Candidate {
13435 index: 0,
13436 label: 'A',
13437 agent: "alpha".to_owned(),
13438 branch: "does-not-exist".to_owned(),
13439 worktree: repo.clone(),
13440 summary: String::new(),
13441 stat: String::new(),
13442 files: 0,
13443 commits: 0,
13444 empty: false,
13445 failed: None,
13446 verified_noop: None,
13447 duration_ms: 0,
13448 folded: false,
13449 }];
13450 state.tally = Some(Tally {
13451 first_choice: BTreeMap::from([('A', 1)]),
13452 borda: BTreeMap::new(),
13453 winner: 'A',
13454 rankings: 1,
13455 unanimous_initial: true,
13456 deliberated: false,
13457 changed_votes: 0,
13458 unanimous_final: true,
13459 tie_break: None,
13460 judges: 0,
13461 present: 0,
13462 quorum: 0,
13463 met_quorum: true,
13464 uncontested: Some("only candidate A produced a change".to_owned()),
13465 });
13466 state.reviews = vec![ReviewRound {
13470 round: 1,
13471 head: head.clone(),
13472 verified_head: None,
13473 verified_at: None,
13474 reviews: Vec::new(),
13475 e2e: Vec::new(),
13476 fix: None,
13477 blocking: 1,
13478 answered: 1,
13479 expected: 1,
13480 clean: false,
13481 verify_retried: false,
13482 e2e_deferred: true,
13483 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13484 progressed: false,
13485 vote_split: false,
13486 reconsideration: Vec::new(),
13487 verdict: None,
13488 }];
13489
13490 let mut runner = Runner {
13491 state,
13492 roles: ResolvedRoles {
13493 implementers: Vec::new(),
13494 judges: Vec::new(),
13495 reviewers: Vec::new(),
13496 fixer: None,
13497 conductor: conductor(),
13498 implementer_roster: Vec::new(),
13499 judge_roster: Vec::new(),
13500 reviewer_roster: Vec::new(),
13501 },
13502 sem: Arc::new(Semaphore::new(1)),
13503 pause: Pause::new(),
13504 interrupt: Pause::new(),
13505 };
13506
13507 let shell = runner.state.config.shell();
13508 runner
13509 .stop_reviewing("round budget spent", &shell, &repo)
13510 .await
13511 .expect("stop_reviewing");
13512
13513 let last = runner.state.reviews.last().expect("round record");
13514 assert_eq!(
13515 last.e2e_status(),
13516 E2eStatus::ResourceBlocked,
13517 "the shared cache is still held; the attempt must read as blocked, not deferred or \
13518 failed: {last:?}"
13519 );
13520 assert_eq!(
13521 last.verified_head.as_deref(),
13522 Some(head.as_str()),
13523 "which commit this attempt targeted is known even though nothing finished checking \
13524 it"
13525 );
13526 let first_attempt_at = last
13527 .verified_at
13528 .expect("when this attempt ran is known too");
13529 assert_ne!(
13530 runner.state.status,
13531 RunStatus::Blocked,
13532 "contention is evidence about the machine, not the patch — it must not settle the \
13533 run as blocked: {:?}",
13534 runner.state.status
13535 );
13536 assert!(
13537 !runner
13538 .state
13539 .events
13540 .iter()
13541 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13542 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13543 runner.state.events
13544 );
13545
13546 runner
13551 .stop_reviewing("round budget spent", &shell, &repo)
13552 .await
13553 .expect("stop_reviewing retry");
13554 assert_eq!(
13555 runner.state.reviews.len(),
13556 1,
13557 "no new round was started: {:?}",
13558 runner.state.reviews
13559 );
13560 let last = runner.state.reviews.last().expect("round record");
13561 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13562 assert!(
13563 last.verified_at.expect("still known") > first_attempt_at,
13564 "a second reentry must be a fresh attempt, not a stale copy of the first"
13565 );
13566 assert_ne!(runner.state.status, RunStatus::Blocked);
13567
13568 held.release();
13569 }
13570
13571 #[tokio::test]
13583 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13584 crate::run::pin_test_home();
13585 let home = crate::run::home();
13586
13587 let tmp = tempfile::tempdir().expect("tempdir");
13588 let repo = tmp.path().join("repo");
13589 std::fs::create_dir_all(&repo).unwrap();
13590 init_repo(&repo);
13591 let head = crate::git::rev_parse(&repo, "HEAD")
13592 .await
13593 .expect("rev-parse");
13594 let cache_dir = tmp.path().join("target");
13595
13596 let mut config = Config::default();
13597 config.verify.e2e = vec![format!(
13598 "CARGO_TARGET_DIR='{}' test -f README.md",
13599 cache_dir.display()
13600 )];
13601 config.graph.review_rounds = 1;
13602 config.graph.timeout_verify = Some(2);
13603 config.graph.timeout_resource_wait = 2;
13604
13605 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13606 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13607 .expect("no io error acquiring directly")
13608 {
13609 crate::cache::AcquireOutcome::Acquired(g) => g,
13610 crate::cache::AcquireOutcome::Busy(b) => {
13611 panic!("expected the direct acquire to win the lease first: {b:?}")
13612 }
13613 };
13614
13615 let mut state = RunState::new(
13616 repo.clone(),
13617 "main".to_owned(),
13618 head.clone(),
13619 "task".to_owned(),
13620 config,
13621 );
13622 state.candidates = vec![Candidate {
13623 index: 0,
13624 label: 'A',
13625 agent: "alpha".to_owned(),
13626 branch: "does-not-exist".to_owned(),
13627 worktree: repo.clone(),
13628 summary: String::new(),
13629 stat: String::new(),
13630 files: 0,
13631 commits: 0,
13632 empty: false,
13633 failed: None,
13634 verified_noop: None,
13635 duration_ms: 0,
13636 folded: false,
13637 }];
13638 state.tally = Some(Tally {
13639 first_choice: BTreeMap::from([('A', 1)]),
13640 borda: BTreeMap::new(),
13641 winner: 'A',
13642 rankings: 1,
13643 unanimous_initial: true,
13644 deliberated: false,
13645 changed_votes: 0,
13646 unanimous_final: true,
13647 tie_break: None,
13648 judges: 0,
13649 present: 0,
13650 quorum: 0,
13651 met_quorum: true,
13652 uncontested: Some("only candidate A produced a change".to_owned()),
13653 });
13654 state.reviews = vec![ReviewRound {
13658 round: 1,
13659 head: head.clone(),
13660 verified_head: Some(head.clone()),
13661 verified_at: Some(jiff::Timestamp::now()),
13662 reviews: Vec::new(),
13663 e2e: vec![CommandOutcome {
13664 command: format!(
13665 "CARGO_TARGET_DIR='{}' test -f README.md",
13666 cache_dir.display()
13667 ),
13668 code: None,
13669 output_tail: "waiting for the shared build cache".to_owned(),
13670 duration_ms: 0,
13671 resource_blocked: true,
13672 }],
13673 fix: None,
13674 blocking: 1,
13675 answered: 1,
13676 expected: 1,
13677 clean: false,
13678 verify_retried: false,
13679 e2e_deferred: false,
13680 e2e_defer_reason: None,
13681 progressed: false,
13682 vote_split: false,
13683 reconsideration: Vec::new(),
13684 verdict: None,
13685 }];
13686
13687 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13688 let mut runner = Runner {
13689 state,
13690 roles: ResolvedRoles {
13691 implementers: Vec::new(),
13692 judges: Vec::new(),
13693 reviewers: Vec::new(),
13694 fixer: None,
13695 conductor: conductor(),
13696 implementer_roster: Vec::new(),
13697 judge_roster: Vec::new(),
13698 reviewer_roster: Vec::new(),
13699 },
13700 sem: Arc::new(Semaphore::new(1)),
13701 pause: Pause::new(),
13702 interrupt: Pause::new(),
13703 };
13704
13705 runner.review_loop().await.expect("review_loop");
13710
13711 assert_eq!(
13712 runner.state.reviews.len(),
13713 1,
13714 "no new round was started on top of the unresolved one: {:?}",
13715 runner.state.reviews
13716 );
13717 let last = &runner.state.reviews[0];
13718 assert_eq!(
13719 last.e2e_status(),
13720 E2eStatus::ResourceBlocked,
13721 "still contended: {last:?}"
13722 );
13723 assert!(
13724 last.verified_at.expect("still known") > first_attempt_at,
13725 "review_loop must have actually retried the check, not left it exactly as found"
13726 );
13727 assert_ne!(
13728 runner.state.status,
13729 RunStatus::Blocked,
13730 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13731 runner.state.status
13732 );
13733
13734 held.release();
13735 }
13736
13737 #[tokio::test]
13738 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13739 crate::run::pin_test_home();
13740 let tmp = tempfile::tempdir().expect("tempdir");
13741 let repo = tmp.path().join("repo");
13742 std::fs::create_dir_all(&repo).unwrap();
13743 init_repo(&repo);
13744
13745 let mut config = Config::default();
13746 config.merge.mode = MergeMode::Pr;
13747 config.graph.land = true;
13748 config.graph.land_approval = false;
13749
13750 let mut state = RunState::new(
13751 repo.clone(),
13752 "main".to_owned(),
13753 "deadbeef".to_owned(),
13754 "task".to_owned(),
13755 config,
13756 );
13757 state.candidates = vec![Candidate {
13758 index: 0,
13759 label: 'A',
13760 agent: "alpha".to_owned(),
13761 branch: "does-not-exist".to_owned(),
13762 worktree: repo.clone(),
13763 summary: String::new(),
13764 stat: String::new(),
13765 files: 0,
13766 commits: 0,
13767 empty: false,
13768 failed: None,
13769 verified_noop: None,
13770 duration_ms: 0,
13771 folded: false,
13772 }];
13773 state.tally = Some(Tally {
13774 first_choice: BTreeMap::from([('A', 1)]),
13775 borda: BTreeMap::new(),
13776 winner: 'A',
13777 rankings: 1,
13778 unanimous_initial: true,
13779 deliberated: false,
13780 changed_votes: 0,
13781 unanimous_final: true,
13782 tie_break: None,
13783 judges: 0,
13784 present: 0,
13785 quorum: 0,
13786 met_quorum: true,
13787 uncontested: Some("only candidate A produced a change".to_owned()),
13788 });
13789 state.reviews = vec![ReviewRound {
13790 round: 1,
13791 head: "deadbeef".to_owned(),
13792 verified_head: None,
13793 verified_at: None,
13794 reviews: Vec::new(),
13795 e2e: Vec::new(),
13796 fix: None,
13797 blocking: 0,
13798 answered: 0,
13799 expected: 0,
13800 clean: true,
13801 verify_retried: false,
13802 e2e_deferred: false,
13803 e2e_defer_reason: None,
13804 progressed: false,
13805 vote_split: false,
13806 reconsideration: Vec::new(),
13807 verdict: None,
13808 }];
13809 state.gate = vec![CommandOutcome {
13810 command: "test".to_owned(),
13811 code: Some(0),
13812 output_tail: String::new(),
13813 duration_ms: 0,
13814 resource_blocked: false,
13815 }];
13816 state.gate_ran = true;
13817 state.status = RunStatus::Landing;
13821 state.merge = Some(MergeOutcome {
13822 mode: MergeMode::Pr,
13823 ok: true,
13824 detail: "https://example.invalid/x/y/pull/1".to_owned(),
13825 empty: false,
13826 });
13827
13828 ask_test_home();
13832 state.save().expect("save landing run");
13835 let store = ask::Questions::open();
13836 let q = ask_open_question(&store, &state.id);
13837
13838 let mut runner = Runner {
13839 state,
13840 roles: ResolvedRoles {
13841 implementers: Vec::new(),
13842 judges: Vec::new(),
13843 reviewers: Vec::new(),
13844 fixer: None,
13845 conductor: conductor(),
13846 implementer_roster: Vec::new(),
13847 judge_roster: Vec::new(),
13848 reviewer_roster: Vec::new(),
13849 },
13850 sem: Arc::new(Semaphore::new(1)),
13851 pause: Pause::new(),
13852 interrupt: Pause::new(),
13853 };
13854
13855 runner.execute().await.expect("execute");
13860
13861 assert_eq!(
13862 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13863 Some("https://example.invalid/x/y/pull/1"),
13864 "reentry must not push again or open a second pull request over the \
13865 one `land` is already watching"
13866 );
13867 assert_ne!(
13868 runner.state.status,
13869 RunStatus::Landing,
13870 "land could not actually reach the fake pull request, so it must \
13871 have given up rather than left the run silently parked forever"
13872 );
13873 assert_eq!(runner.state.status, RunStatus::Blocked);
13877 assert!(
13878 store.get(&q.id).unwrap().status.open(),
13879 "Blocked is still alive; settle_questions must have been a no-op here"
13880 );
13881 }
13882
13883 fn state_with_round(round: ReviewRound) -> RunState {
13884 let mut s = RunState::new(
13885 PathBuf::from("/repo"),
13886 "main".to_owned(),
13887 "abc1234".to_owned(),
13888 "add retries".to_owned(),
13889 Config::default(),
13890 );
13891 s.reviews = vec![round];
13892 s
13893 }
13894
13895 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13896 crate::verdict::Finding {
13897 id: id.to_owned(),
13898 severity,
13899 file: None,
13900 line: None,
13901 title: title.to_owned(),
13902 detail: String::new(),
13903 }
13904 }
13905
13906 #[test]
13907 fn pr_body_names_open_findings_and_declined_ones() {
13908 let round = ReviewRound {
13909 round: 2,
13910 head: "deadbee".to_owned(),
13911 verified_head: None,
13912 verified_at: None,
13913 reviews: vec![ReviewRecord {
13914 attempts: 0,
13915 reviewer: 1,
13916 agent: "alpha".to_owned(),
13917 summary: String::new(),
13918 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13919 vote: None,
13920 failed: None,
13921 duration_ms: 0,
13922 }],
13923 e2e: vec![CommandOutcome {
13924 command: "cargo test".to_owned(),
13925 code: Some(0),
13926 output_tail: String::new(),
13927 duration_ms: 0,
13928 resource_blocked: false,
13929 }],
13930 verify_retried: false,
13931 e2e_deferred: false,
13932 e2e_defer_reason: None,
13933 fix: Some(FixRecord {
13934 agent: "alpha".to_owned(),
13935 addressed: Vec::new(),
13936 rejected: vec![crate::verdict::Rejection {
13937 id: "R1-1-1".to_owned(),
13938 why: "not reachable from any caller".to_owned(),
13939 }],
13940 notes: String::new(),
13941 committed: true,
13942 failed: None,
13943 duration_ms: 0,
13944 continuation: None,
13945 }),
13946 blocking: 0,
13947 answered: 1,
13948 expected: 1,
13949 clean: false,
13950 progressed: true,
13951 vote_split: false,
13952 reconsideration: Vec::new(),
13953 verdict: None,
13954 };
13955 let state = state_with_round(round);
13956 let body = pr_message(&state, 'A').body;
13957
13958 assert!(body.contains("add retries"), "the task must still be there");
13959 assert!(body.contains("R2-1-1"), "{body}");
13960 assert!(!body.contains("unused import"), "{body}");
13961 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13962 assert!(
13963 !body.contains("not reachable from any caller"),
13964 "the reason it was declined: {body}"
13965 );
13966 }
13967
13968 #[test]
13969 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13970 let round = ReviewRound {
13971 round: 1,
13972 head: "deadbee".to_owned(),
13973 verified_head: None,
13974 verified_at: None,
13975 reviews: vec![ReviewRecord {
13976 attempts: 0,
13977 reviewer: 1,
13978 agent: "alpha".to_owned(),
13979 summary: String::new(),
13980 findings: Vec::new(),
13981 vote: None,
13982 failed: None,
13983 duration_ms: 0,
13984 }],
13985 e2e: Vec::new(),
13986 verify_retried: false,
13987 e2e_deferred: false,
13988 e2e_defer_reason: None,
13989 fix: None,
13990 blocking: 0,
13991 answered: 1,
13992 expected: 1,
13993 clean: true,
13994 progressed: false,
13995 vote_split: false,
13996 reconsideration: Vec::new(),
13997 verdict: None,
13998 };
13999 let state = state_with_round(round);
14000 let body = pr_message(&state, 'A').body;
14001 assert!(!body.contains("Open review findings"), "{body}");
14002 assert!(!body.contains("Declined"), "{body}");
14003 }
14004
14005 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
14006 let mut state = RunState::new(
14007 PathBuf::from("/repo"),
14008 "main".to_owned(),
14009 "abc1234".to_owned(),
14010 instruction.to_owned(),
14011 Config::default(),
14012 );
14013 state.candidates.push(Candidate {
14014 index: 0,
14015 label: 'A',
14016 agent: "alpha".to_owned(),
14017 branch: "magi/x/A".to_owned(),
14018 worktree: PathBuf::from("/wt"),
14019 summary: summary.to_owned(),
14020 stat: String::new(),
14021 files: 1,
14022 commits: 1,
14023 empty: false,
14024 failed: None,
14025 verified_noop: None,
14026 folded: false,
14027 duration_ms: 0,
14028 });
14029 state
14030 }
14031
14032 fn review_state(subjects: &[&str]) -> RunState {
14033 let mut state = state_with_summary(
14034 "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",
14035 "",
14036 );
14037 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14038 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
14039 state
14040 }
14041
14042 #[tokio::test]
14045 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
14046 ask_test_home();
14047 let tmp = tempfile::tempdir().unwrap();
14048 let repo = tmp.path().join("repo");
14049 std::fs::create_dir_all(&repo).unwrap();
14050 init_repo(&repo);
14051 let origin = tmp.path().join("origin.git");
14052 let g = |dir: &Path, args: &[&str]| {
14053 let out = std::process::Command::new("git")
14054 .args(args)
14055 .current_dir(dir)
14056 .quiet()
14057 .output()
14058 .expect("spawn git");
14059 assert!(
14060 out.status.success(),
14061 "git {args:?}: {}",
14062 String::from_utf8_lossy(&out.stderr)
14063 );
14064 };
14065 g(
14066 tmp.path(),
14067 &[
14068 "clone",
14069 "--bare",
14070 "-q",
14071 repo.to_str().unwrap(),
14072 origin.to_str().unwrap(),
14073 ],
14074 );
14075 g(
14076 &repo,
14077 &["remote", "add", "origin", origin.to_str().unwrap()],
14078 );
14079 let c1 = git::rev_parse(&repo, "main").await.unwrap();
14080
14081 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14083 g(&repo, &["add", "-A"]);
14084 g(
14085 &repo,
14086 &["commit", "-q", "-m", "chore(deps): update a crate"],
14087 );
14088 g(&repo, &["push", "-q", "origin", "main"]);
14089 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14090 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14091 g(&repo, &["add", "-A"]);
14092 g(
14093 &repo,
14094 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14095 );
14096 g(&repo, &["checkout", "-q", "main"]);
14097
14098 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14099 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14100 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14102 assert_eq!(fallback, c1);
14103
14104 let mut state = review_state(&[
14105 "chore(deps): update a crate",
14106 "fix(daemon): apply a chosen action",
14107 ]);
14108 state.repo = repo.clone();
14109 state.base_branch = "main".to_owned();
14110 state.base_commit = c1;
14111 refresh_reviewed_commits(&mut state, "feat/own").await;
14112 assert_eq!(
14113 state.reviewed_commits,
14114 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14115 );
14116 assert_eq!(
14117 review_title(&state).as_deref(),
14118 Some("fix(daemon): apply a chosen action")
14119 );
14120 assert_eq!(
14121 leaked_subjects(&state, "feat/own").await,
14122 Some(vec!["chore(deps): update a crate".to_owned()])
14123 );
14124 state.config.merge.remote = "nowhere".to_owned();
14127 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14128 }
14129
14130 struct SummaryFixture {
14134 _tmp: tempfile::TempDir,
14135 repo: PathBuf,
14136 reply: PathBuf,
14137 runner: Runner,
14138 }
14139
14140 fn summary_fixture() -> SummaryFixture {
14141 ask_test_home();
14142 let tmp = tempfile::tempdir().unwrap();
14143 let repo = tmp.path().join("repo");
14144 std::fs::create_dir_all(&repo).unwrap();
14145 init_repo(&repo);
14146 let origin = tmp.path().join("origin.git");
14147 let g = |args: &[&str]| {
14148 let out = std::process::Command::new("git")
14149 .args(args)
14150 .current_dir(&repo)
14151 .quiet()
14152 .output()
14153 .expect("spawn git");
14154 assert!(
14155 out.status.success(),
14156 "git {args:?}: {}",
14157 String::from_utf8_lossy(&out.stderr)
14158 );
14159 };
14160 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14161 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14162 g(&["push", "-q", "origin", "main"]);
14163 g(&["checkout", "-q", "-b", "feat/own"]);
14164 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14165 g(&["add", "-A"]);
14166 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14167 let reply = tmp.path().join("reply.txt");
14168 std::fs::write(
14169 &reply,
14170 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14171 )
14172 .unwrap();
14173 let mut runner = runner_at(RunStatus::Gating);
14174 let mut state = review_state(&["feat(own): add the own file"]);
14175 state.repo = repo.clone();
14176 state.base_branch = "main".to_owned();
14177 state.candidates[0].branch = "feat/own".to_owned();
14178 state.candidates[0].worktree = repo.clone();
14179 let mut author = spec("alpha");
14180 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14181 author
14182 .env
14183 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14184 state.config.agents = vec![author];
14185 runner.state = state;
14186 SummaryFixture {
14187 _tmp: tmp,
14188 repo,
14189 reply,
14190 runner,
14191 }
14192 }
14193
14194 async fn posted(f: &mut SummaryFixture) -> PrMessage {
14195 let winner = f.runner.state.candidates[0].clone();
14196 f.runner
14197 .guarded_pr_message(&winner, None, true)
14198 .await
14199 .unwrap()
14200 .expect("an English description never parks")
14201 }
14202
14203 fn clean_round_with_minor_findings() -> ReviewRound {
14204 let mut round = passing_round(2, None);
14205 round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14206 round.reviews = vec![ReviewRecord {
14207 attempts: 0,
14208 reviewer: 1,
14209 agent: "alpha".into(),
14210 summary: "内部レビュー".into(),
14211 findings: vec![
14212 finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14213 finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14214 ],
14215 vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14216 failed: None,
14217 duration_ms: 0,
14218 }];
14219 round
14220 }
14221
14222 #[tokio::test]
14223 async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14224 for review_only in [false, true] {
14225 let mut f = summary_fixture();
14226 if !review_only {
14227 f.runner.state.candidates[0].agent = "alpha".into();
14228 }
14229 f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14230 assert!(f.runner.state.reviews[0].clean);
14231 assert!(f.runner.state.reviews[0].fix.is_none());
14232 assert!(f.runner.state.open_findings().is_empty());
14233 assert_eq!(
14234 crate::pr_outcome::required_finding_ids(&f.runner.state),
14235 ["R2-1-1", "R2-1-2"]
14236 );
14237 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14238 let evidence = crate::pr_outcome::summary_evidence(
14239 &f.runner.state,
14240 crate::pr_outcome::FinalHead::At(&head),
14241 );
14242 assert!(
14243 evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14244 );
14245 let omitted = posted(&mut f).await;
14247 assert!(omitted.body.contains("No description"));
14248 assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14249 assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14250 assert!(f.runner.state.change_summary.is_none());
14251 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();
14252 let described = posted(&mut f).await;
14253 assert!(described.body.contains("improve documentation for callers"));
14254 assert!(described.body.contains("remaining edge case"));
14255 assert!(described.body.contains("## Open review findings"));
14256 assert!(!described.body.contains("未修正"));
14257 }
14258 }
14259
14260 async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14261 let mut f = summary_fixture();
14262 if !review_only {
14263 f.runner.state.candidates[0].agent = "alpha".into();
14264 }
14265 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14266 let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14267 fixed
14268 .fix
14269 .as_mut()
14270 .unwrap()
14271 .rejected
14272 .push(crate::verdict::Rejection {
14273 id: "R1-1-1".into(),
14274 why: "呼び出されないコードのため辞退".into(),
14275 });
14276 let mut clean = clean_round_with_minor_findings();
14277 clean.verified_head = Some(head.clone());
14278 f.runner.state.reviews = vec![fixed, clean];
14279 ok_gate(&mut f.runner.state, Some(&head));
14280 std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14281 f.runner.state.config.agents.push(scripted("beta", reply));
14282 f.runner.state.config.roles.language_judge =
14283 Some(crate::config::AgentChoice::One("beta".into()));
14284 f
14285 }
14286
14287 #[tokio::test]
14288 async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14289 for review_only in [false, true] {
14290 let mut f = rewrite_fixture_with_findings(review_only,
14291 "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;
14292 let m = posted(&mut f).await;
14293 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14294 let expected =
14295 public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14296 assert!(m.body.contains(&expected));
14297 assert!(m.body.contains("## Review rounds"));
14298 assert!(
14299 m.body
14300 .contains("Gate (`verify.gate`): passed on the final commit")
14301 );
14302 assert!(
14303 m.body
14304 .contains("Tests (`verify.e2e`): passed on the final commit")
14305 );
14306 let c = f.runner.state.change_summary.as_ref().unwrap();
14307 assert!(
14308 m.body
14309 .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14310 );
14311 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14312 assert!(crate::pr_outcome::covers_required_findings(
14313 &f.runner.state,
14314 &m.body
14315 ));
14316 assert!(m.body.contains("documentation remains incomplete"));
14317 assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14318 assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14319 assert!(f.runner.state.github_text.is_none());
14320 }
14321 }
14322
14323 #[tokio::test]
14324 async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14325 for review_only in [false, true] {
14326 for reply in [
14327 "TITLE: fix: public description\nA short English description.",
14328 "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14329 ] {
14330 let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14331 let winner = f.runner.state.candidates[0].clone();
14332 let posted = f
14333 .runner
14334 .guarded_pr_message(&winner, None, true)
14335 .await
14336 .unwrap();
14337 assert!(posted.is_none());
14338 assert!(f.runner.state.parked);
14339 let withheld = f.runner.state.github_text.as_ref().unwrap();
14340 assert!(
14341 withheld.rewrite.is_none(),
14342 "an incomplete rewrite must not be selectable"
14343 );
14344 let q = ask::Questions::open()
14345 .get(withheld.question.as_ref().unwrap())
14346 .unwrap();
14347 assert!(
14348 !q.choices
14349 .contains(&crate::github_text::USE_REWRITE.to_owned())
14350 );
14351 assert!(
14352 q.detail.contains("R1-1-1")
14353 && q.detail.contains("R2-1-1")
14354 && q.detail.contains("R2-1-2")
14355 );
14356 }
14357 }
14358 }
14359
14360 #[tokio::test]
14361 async fn japanese_reports_stay_local_in_both_public_run_modes() {
14362 for review_only in [false, true] {
14363 let mut f = summary_fixture();
14364 if !review_only {
14365 f.runner.state.candidates[0].agent = "alpha".into();
14366 }
14367 f.runner.state.instruction = "内部タスクの説明".into();
14368 f.runner.state.candidates[0].summary = "内部の候補報告".into();
14369 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14370 f.runner.state.reviews = vec![
14371 round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14372 round_with_notes(2, Some("二回目の内部修正報告です。")),
14373 passing_round(3, Some(&head)),
14374 ];
14375 f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14376 ok_gate(&mut f.runner.state, Some(&head));
14377 let captured = f.repo.join("public-prompt.txt");
14378 let author = &mut f.runner.state.config.agents[0];
14379 author.command = vec![
14380 "sh".into(),
14381 "-c".into(),
14382 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14383 ];
14384 author.env.insert(
14385 "PROMPT_FILE".into(),
14386 captured.to_string_lossy().into_owned(),
14387 );
14388 let m = posted(&mut f).await;
14389 assert!(m.body.contains("Adds `own.txt`"));
14390 assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14391 assert!(m.body.contains("R1-1-1"));
14392 assert!(
14393 m.body
14394 .contains("Gate (`verify.gate`): passed on the final commit")
14395 );
14396 assert!(
14397 m.body
14398 .contains("Tests (`verify.e2e`): passed on the final commit")
14399 );
14400 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14401 assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14402 let prompt = std::fs::read_to_string(captured).unwrap();
14403 assert!(prompt.contains(&format!(
14404 "git diff {} {head}",
14405 f.runner.state.change_summary.as_ref().unwrap().base
14406 )));
14407 assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14408 assert_eq!(
14409 f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14410 "二回目の内部修正報告です。"
14411 );
14412 }
14413 }
14414
14415 #[tokio::test]
14416 async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14417 for review_only in [false, true] {
14418 let mut f = summary_fixture();
14419 if !review_only {
14420 f.runner.state.candidates[0].agent = "alpha".into();
14421 }
14422 f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14423 f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14424 std::fs::write(&f.reply, "no usable description").unwrap();
14425 let failed = posted(&mut f).await;
14426 assert!(
14427 failed
14428 .body
14429 .contains("No description of what this change does could be generated")
14430 );
14431 assert!(failed.body.is_ascii());
14432 assert!(f.runner.state.github_text.is_none());
14433 assert!(f.runner.state.change_summary.is_none());
14434 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14435 let evidence = crate::pr_outcome::summary_evidence(
14436 &f.runner.state,
14437 crate::pr_outcome::FinalHead::At(&head),
14438 );
14439 let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14440 assert!(
14441 crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14442 .exists()
14443 );
14444 f.runner.state =
14445 serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14446 std::fs::write(
14447 &f.reply,
14448 "TITLE: feat(own): add the own file\nAdds the committed own file.",
14449 )
14450 .unwrap();
14451 let retried = posted(&mut f).await;
14452 assert!(retried.body.contains("Adds the committed own file."));
14453 assert_eq!(
14454 f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14455 "日本語の修正報告"
14456 );
14457 }
14458 }
14459
14460 #[tokio::test]
14461 async fn same_head_verification_and_findings_invalidate_public_prose() {
14462 let mut f = summary_fixture();
14463 posted(&mut f).await;
14464 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14465 let original = f
14466 .runner
14467 .state
14468 .change_summary
14469 .as_ref()
14470 .unwrap()
14471 .evidence
14472 .clone();
14473 ok_gate(&mut f.runner.state, Some(&head));
14474 std::fs::write(
14475 &f.reply,
14476 "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14477 )
14478 .unwrap();
14479 let verified = posted(&mut f).await;
14480 assert!(verified.body.contains("now verified"));
14481 assert_ne!(
14482 f.runner.state.change_summary.as_ref().unwrap().evidence,
14483 original
14484 );
14485 let mut round = round_with_notes(1, Some("内部報告"));
14486 round.reviews.push(ReviewRecord {
14487 attempts: 0,
14488 reviewer: 1,
14489 agent: "alpha".into(),
14490 summary: "内部レビュー".into(),
14491 findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14492 vote: None,
14493 failed: None,
14494 duration_ms: 0,
14495 });
14496 round
14497 .fix
14498 .as_mut()
14499 .unwrap()
14500 .rejected
14501 .push(crate::verdict::Rejection {
14502 id: "R1-1-1".into(),
14503 why: "呼び出し元から到達できない".into(),
14504 });
14505 f.runner.state.reviews = vec![round];
14506 let omitted = posted(&mut f).await;
14508 assert!(omitted.body.contains("No description"));
14509 assert!(omitted.body.contains("R1-1-1"));
14510 assert!(!omitted.body.contains("到達"));
14511 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();
14512 let described = posted(&mut f).await;
14513 assert!(described.body.contains("unreachable from callers"));
14514 assert!(described.body.contains("R1-1-1"));
14515 assert!(!described.body.contains("到達"));
14516 let evidence = f
14517 .runner
14518 .state
14519 .change_summary
14520 .as_ref()
14521 .unwrap()
14522 .evidence
14523 .clone();
14524 f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14525 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14526 assert!(posted(&mut f).await.body.contains("rationale updated"));
14527 assert_ne!(
14528 f.runner.state.change_summary.as_ref().unwrap().evidence,
14529 evidence
14530 );
14531 f.runner
14533 .state
14534 .change_summary
14535 .as_mut()
14536 .unwrap()
14537 .evidence
14538 .clear();
14539 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14540 assert!(posted(&mut f).await.body.contains("current specification"));
14541 }
14542
14543 #[tokio::test]
14544 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14545 let mut f = summary_fixture();
14546 let m = posted(&mut f).await;
14547 assert_eq!(m.title, "feat(own): add the own file");
14548 assert!(
14549 m.body
14550 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14551 "{}",
14552 m.body
14553 );
14554 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14555 assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14556 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14557 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14558 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14559 let kept = f.runner.state.change_summary.as_ref().unwrap();
14560 assert_eq!(
14561 (kept.head.as_str(), kept.base.as_str()),
14562 (head.as_str(), base.as_str())
14563 );
14564 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14565 }
14566
14567 #[tokio::test]
14568 async fn review_only_summary_receives_the_configured_github_language() {
14569 let mut f = summary_fixture();
14570 f.runner.state.config.graph.github_language = "ja".into();
14571 let captured = f.repo.join("summary-prompt.txt");
14572 let author = &mut f.runner.state.config.agents[0];
14573 author.command = vec![
14574 "sh".into(),
14575 "-c".into(),
14576 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14577 ];
14578 author.env.insert(
14579 "PROMPT_FILE".into(),
14580 captured.to_string_lossy().into_owned(),
14581 );
14582 std::fs::write(
14583 &f.reply,
14584 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14585 )
14586 .unwrap();
14587 let m = posted(&mut f).await;
14588 let prompt = std::fs::read_to_string(captured).unwrap();
14589 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14590 assert!(!prompt.contains("in English"));
14591 assert_eq!(m.title, "feat(own): ファイルを追加");
14592 assert!(m.body.contains("own.txt を追加します。"));
14593 }
14594
14595 #[tokio::test]
14596 async fn review_fixes_come_after_the_whole_change_summary() {
14597 let mut f = summary_fixture();
14598 f.runner.state.reviews = vec![
14599 round_with_notes(1, Some("handled the PATH case")),
14600 round_with_notes(2, None),
14601 ];
14602 let m = posted(&mut f).await;
14603 let summary = m.body.find("## Summary").expect("summary");
14604 let fixes = m.body.find("## Review rounds").expect("review rounds");
14605 assert!(summary < fixes, "{}", m.body);
14606 assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14607 assert!(m.body.contains("## Final verification"), "{}", m.body);
14608 }
14609
14610 #[tokio::test]
14611 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14612 let mut f = summary_fixture();
14613 let first = posted(&mut f).await;
14614 let first_head = f.runner.state.change_summary.clone().unwrap().head;
14615 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14617 let again = posted(&mut f).await;
14618 assert_eq!((again.title, again.body), (first.title, first.body));
14619
14620 let g = |args: &[&str]| {
14621 let out = std::process::Command::new("git")
14622 .args(args)
14623 .current_dir(&f.repo)
14624 .quiet()
14625 .output()
14626 .expect("spawn git");
14627 assert!(out.status.success(), "git {args:?}");
14628 };
14629 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14630 g(&["add", "-A"]);
14631 g(&["commit", "-q", "-m", "fix(own): handle more"]);
14632 let second = posted(&mut f).await;
14633 let kept = f.runner.state.change_summary.clone().unwrap();
14634 assert_ne!(kept.head, first_head);
14635 assert!(second.body.contains("Not read yet."), "{}", second.body);
14636 assert!(!second.body.contains("so the first summary is read"));
14637 assert_eq!(second.title, "feat(own): other");
14638 }
14639
14640 #[tokio::test]
14641 async fn an_unavailable_summary_is_stated_and_never_invented() {
14642 let mut f = summary_fixture();
14643 std::fs::write(&f.reply, "no title line here").unwrap();
14644 let m = posted(&mut f).await;
14645 assert!(
14646 m.body
14647 .contains("No description of what this change does could be generated"),
14648 "{}",
14649 m.body
14650 );
14651 assert!(m.body.contains("internal reports remain in local records"));
14652 assert!(f.runner.state.change_summary.is_none());
14653 assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14654 let winner = f.runner.state.candidates[0].clone();
14656 let preview = f
14657 .runner
14658 .guarded_pr_message(&winner, None, false)
14659 .await
14660 .unwrap()
14661 .unwrap();
14662 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14663 }
14664
14665 #[test]
14666 fn pr_message_review_single_commit_uses_its_subject() {
14667 let state = review_state(&["feat(nats): per-role user"]);
14668 let m = pr_message(&state, 'A');
14669 assert_eq!(m.title, "feat(nats): per-role user");
14670 assert!(!m.body.contains("Review the work already"), "{}", m.body);
14671 assert!(m.body.contains("## Commits under review"), "{}", m.body);
14672 }
14673
14674 #[test]
14675 fn pr_message_review_multi_commit_takes_the_oldest() {
14676 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14677 let m = pr_message(&state, 'A');
14678 assert_eq!(m.title, "feat: the change");
14679 for s in ["feat: the change", "fix: typo", "fix: again"] {
14680 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14681 }
14682 }
14683
14684 #[test]
14685 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14686 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14687 let mut state = review_state(&[first, "fix: later fixup"]);
14688 state.candidates[0].branch = "機能/ログイン".to_owned();
14689 let m = pr_message(&state, 'A');
14690 assert!(
14691 m.title.starts_with("chore: land candidate A of run"),
14692 "{}",
14693 m.title
14694 );
14695 }
14696 }
14697
14698 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14699 BranchFacts {
14700 commits: commits
14701 .iter()
14702 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14703 .collect(),
14704 stat: stat.to_owned(),
14705 }
14706 }
14707
14708 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14709 let mut r = review_round(true, 0, 1, 1, true, true);
14710 r.round = round;
14711 r.fix = notes.map(|n| FixRecord {
14712 agent: "fixer".to_owned(),
14713 addressed: Vec::new(),
14714 rejected: Vec::new(),
14715 notes: n.to_owned(),
14716 committed: true,
14717 failed: None,
14718 duration_ms: 0,
14719 continuation: None,
14720 });
14721 r
14722 }
14723
14724 #[test]
14725 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14726 let state = review_state(&["ignored"]);
14727 let f = facts(
14728 &[
14729 (
14730 "fix(login): resolve PATH on macOS",
14731 "Login shells skip rc files.",
14732 ),
14733 ("fix: address review", ""),
14734 ],
14735 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14736 );
14737 let m = pr_message_with(&state, 'A', Some(&f));
14738 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14739 assert!(
14740 m.body
14741 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
14742 "{}",
14743 m.body
14744 );
14745 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14746 assert!(m.body.contains("## Diff stat"), "{}", m.body);
14747 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14748 for banned in [
14749 "Review the work already",
14750 "no task statement",
14751 "Original task",
14752 ] {
14753 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14754 }
14755 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14756 }
14757
14758 #[test]
14759 fn pr_message_review_truncates_a_huge_first_commit_body() {
14760 let state = review_state(&["ignored"]);
14761 let f = facts(
14762 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14763 "s",
14764 );
14765 let m = pr_message_with(&state, 'A', Some(&f));
14766 assert!(m.body.len() < 30_000, "{}", m.body.len());
14767 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14768 assert!(
14769 m.body.contains("- ... 1 more commit(s)"),
14770 "{}",
14771 m.body.len()
14772 );
14773 assert!(m.body.ends_with("magi:candidate-a\n"));
14774 }
14775
14776 #[test]
14777 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14778 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14779 assert_eq!(m.title, "feat: x");
14780 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14781 assert!(!m.body.contains("Diff stat"), "{}", m.body);
14782 assert!(!m.body.contains("no task statement"), "{}", m.body);
14783 }
14784
14785 #[test]
14786 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14787 let mut state = review_state(&["日本語の件名"]);
14788 state.candidates[0].branch = "fix/macos-login-path".to_owned();
14789 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14790 state.candidates[0].branch = "機能/ログイン".to_owned();
14791 assert!(
14792 pr_message(&state, 'A')
14793 .title
14794 .starts_with("chore: land candidate A")
14795 );
14796 }
14797
14798 #[test]
14799 fn pr_message_review_fixes_survive_a_clean_final_round() {
14800 let mut state = review_state(&["feat: x"]);
14801 state.reviews = vec![
14802 round_with_notes(1, Some("handled the PATH case")),
14803 round_with_notes(2, None),
14804 ];
14805 let body = pr_message(&state, 'A').body;
14806 assert!(
14807 body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14808 "{body}"
14809 );
14810 assert!(body.contains("### Round 1"), "{body}");
14811
14812 state.reviews = vec![
14813 round_with_notes(1, Some("first fix")),
14814 round_with_notes(2, Some("")),
14815 round_with_notes(3, Some("second fix")),
14816 round_with_notes(4, None),
14817 ];
14818 let body = pr_message(&state, 'A').body;
14819 assert!(
14820 body.contains("### Round 1") && !body.contains("first fix"),
14821 "{body}"
14822 );
14823 assert!(
14824 body.contains("### Round 3") && !body.contains("second fix"),
14825 "{body}"
14826 );
14827 assert!(body.contains("### Round 2"), "{body}");
14828 }
14829
14830 const FINAL: &str = "abcdef1234567890";
14831
14832 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14833 let mut r = round_with_notes(round, None);
14834 r.verified_head = verified.map(str::to_owned);
14835 r
14836 }
14837
14838 fn ok_gate(state: &mut RunState, head: Option<&str>) {
14839 state.gate = vec![CommandOutcome {
14840 command: "gate".to_owned(),
14841 code: Some(0),
14842 output_tail: String::new(),
14843 duration_ms: 0,
14844 resource_blocked: false,
14845 }];
14846 state.gate_ran = true;
14847 state.gate_head = head.map(str::to_owned);
14848 }
14849
14850 fn body_at(state: &RunState) -> String {
14851 pr_message_raw(
14852 state,
14853 'A',
14854 None,
14855 crate::pr_outcome::FinalHead::At(FINAL),
14856 ChangeNote::NotAsked,
14857 )
14858 .body
14859 }
14860
14861 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14862
14863 #[test]
14864 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14865 let mut state = review_state(&["feat: x"]);
14866 state.reviews = vec![
14867 round_with_notes(1, Some(SANDBOXED)),
14868 passing_round(2, Some(FINAL)),
14869 ];
14870 ok_gate(&mut state, Some(FINAL));
14871 let body = body_at(&state);
14872 assert!(!body.contains("Handled the PATH case."), "{body}");
14873 assert!(!body.contains("could not commit"), "{body}");
14874 assert!(!body.contains("could not run"), "{body}");
14875 assert!(
14876 body.contains("The fix is committed on the branch"),
14877 "{body}"
14878 );
14879 assert!(!body.contains("sandbox"), "{body}");
14880 assert!(
14881 body.contains("Tests (`verify.e2e`): passed on the final commit"),
14882 "{body}"
14883 );
14884 assert!(
14885 body.contains("Gate (`verify.gate`): passed on the final commit"),
14886 "{body}"
14887 );
14888 assert!(
14890 state.reviews[0]
14891 .fix
14892 .as_ref()
14893 .unwrap()
14894 .notes
14895 .contains("could not commit")
14896 );
14897 }
14898
14899 #[test]
14900 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14901 let mut state = review_state(&["feat: x"]);
14902 let mut r1 = round_with_notes(1, Some(SANDBOXED));
14903 r1.fix.as_mut().unwrap().committed = false;
14904 let mut r2 = passing_round(2, Some(FINAL));
14905 r2.e2e[0].code = Some(1);
14906 state.reviews = vec![r1, r2];
14907 let body = body_at(&state);
14908 assert!(!body.contains("could not commit"), "{body}");
14909 assert!(!body.contains("could not run"), "{body}");
14910 assert!(body.contains("This round produced no commit"), "{body}");
14911 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14912 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14913 assert!(!body.contains("passed"), "{body}");
14914 }
14915
14916 #[test]
14917 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14918 let mut state = review_state(&["feat: x"]);
14919 state.reviews = vec![
14920 round_with_notes(1, Some(SANDBOXED)),
14921 passing_round(2, Some("0123456789abcdef")),
14922 ];
14923 ok_gate(&mut state, Some("0123456789abcdef"));
14924 let body = body_at(&state);
14925 assert!(!body.contains("HTTP tests could not run"), "{body}");
14926 assert!(!body.contains("could not commit"), "{body}");
14928 assert!(
14929 body.contains("passed on an earlier commit (`0123456`)"),
14930 "{body}"
14931 );
14932 assert!(!body.contains("passed on the final commit"), "{body}");
14933
14934 ok_gate(&mut state, None);
14936 let body = body_at(&state);
14937 assert!(
14938 body.contains("passed on a commit that was not recorded"),
14939 "{body}"
14940 );
14941 }
14942
14943 #[test]
14944 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14945 let state = state_with_summary(
14946 "add retries to the client",
14947 "TITLE: feat: retries\n\nDid it.",
14948 );
14949 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14950 assert_eq!(m.title, "feat: retries");
14951 assert!(
14952 m.body.contains("<summary>Original task</summary>"),
14953 "{}",
14954 m.body
14955 );
14956 assert!(!m.body.contains("Commits under review"), "{}", m.body);
14957 assert!(
14958 m.body
14959 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14960 "{}",
14961 m.body
14962 );
14963 }
14964
14965 #[test]
14966 fn pr_message_review_bounds_a_long_english_subject() {
14967 let long = format!("feat: {}", "word ".repeat(100));
14968 let m = pr_message(&review_state(&[&long]), 'A');
14969 assert!(m.title.starts_with("feat: word"), "{}", m.title);
14970 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14971 }
14972
14973 #[test]
14974 fn pr_message_implementation_run_is_unchanged_by_review_support() {
14975 let state = state_with_summary("add retries\n\ndetails", "- did some things");
14976 let m = pr_message(&state, 'A');
14977 assert_eq!(m.title, "add retries");
14978 assert!(m.body.contains("<summary>Original task</summary>"));
14979 assert!(!m.body.contains("Commits under review"));
14980 assert_eq!(landing_subject_source(&state), state.instruction);
14981 }
14982
14983 #[test]
14984 fn review_run_squash_subject_is_the_change_not_the_prompt() {
14985 let state = review_state(&["feat: the change", "fix: typo"]);
14986 let source = landing_subject_source(&state);
14987 assert_eq!(land::merge_subject("", &source), "feat: the change");
14988 assert_eq!(
14989 land::merge_subject("magi: candidate A (uncommitted work)", &source),
14990 "feat: the change"
14991 );
14992 assert_eq!(
14994 land::merge_subject("feat: renamed by hand", &source),
14995 "feat: renamed by hand"
14996 );
14997 let blank = review_state(&["日本語"]);
14998 assert!(
14999 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
15000 );
15001 }
15002
15003 #[test]
15004 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
15005 let state = review_state(&["feat: the change"]);
15006 let source = landing_subject_source(&state);
15007 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
15008 assert_eq!(
15009 land::merge_subject(landing_title(&state, old), &source),
15010 "feat: the change"
15011 );
15012 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
15013 let task = state_with_summary("add retries", "");
15014 assert_eq!(landing_title(&task, old), old);
15015 }
15016
15017 #[test]
15018 fn pr_message_describes_the_change_not_the_task() {
15019 let state = state_with_summary(
15020 "今回やってほしいこと: results projector を直す",
15021 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
15022 );
15023 let m = pr_message(&state, 'A');
15024 assert_eq!(m.title, "fix(web): batch the runs list reads");
15025 assert!(
15026 m.body.starts_with("## Summary\n\n- reads run.json once"),
15027 "{}",
15028 m.body
15029 );
15030 assert!(!m.body.contains("TITLE:"), "{}", m.body);
15031 let task_at = m.body.find("今回やってほしいこと").unwrap();
15032 let details_at = m.body.find("<details>").unwrap();
15033 assert!(
15034 details_at < task_at,
15035 "the task lives inside <details>: {}",
15036 m.body
15037 );
15038 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
15039 assert!(m.body.contains("magi:candidate-a"));
15040 }
15041
15042 #[test]
15043 fn pr_message_falls_back_to_the_task_without_a_title_line() {
15044 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
15045 let m = pr_message(&state, 'A');
15046 assert_eq!(m.title, "add retries");
15047 assert!(
15048 m.body.contains("## Summary\n\n- did some things"),
15049 "{}",
15050 m.body
15051 );
15052
15053 let none = RunState::new(
15054 PathBuf::from("/repo"),
15055 "main".to_owned(),
15056 "abc1234".to_owned(),
15057 "add retries".to_owned(),
15058 Config::default(),
15059 );
15060 let m = pr_message(&none, 'A');
15061 assert_eq!(m.title, "add retries");
15062 assert!(!m.body.contains("## Summary"), "{}", m.body);
15063 }
15064
15065 #[test]
15066 fn pr_message_refuses_the_candidate_commit_subject() {
15067 for bad in [
15068 "TITLE: magi: candidate A (uncommitted work)",
15069 "TITLE: chore: stuff (uncommitted work)",
15070 "TITLE: ",
15071 ] {
15072 let state = state_with_summary("add retries", bad);
15073 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15074 }
15075 }
15076
15077 #[test]
15078 fn pr_message_bounds_a_very_long_task_and_title() {
15079 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15080 let state = state_with_summary(&long, "- nothing");
15081 let m = pr_message(&state, 'A');
15082 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15083 assert!(!m.title.contains('\n'));
15084
15085 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15086 let m = pr_message(&state, 'A');
15087 assert!(m.title.starts_with("feat: "));
15088 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15089 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15090 }
15091
15092 fn long_title_of(instruction: &str) -> String {
15093 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15094 }
15095
15096 #[test]
15097 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15098 let first = "Make the landing path keep a readable title for long tasks";
15099 let line = format!(
15100 "{first}. {}",
15101 "And then keep going with more words ".repeat(20)
15102 );
15103 let t = long_title_of(&line);
15104 assert_eq!(t, first);
15105 assert!(!t.starts_with("chore: land"));
15106 }
15107
15108 #[test]
15109 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15110 let line = "word ".repeat(200);
15111 let t = long_title_of(&line);
15112 assert!(t.ends_with("word..."), "{t}");
15113 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15114 }
15115
15116 #[test]
15117 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15118 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15119 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15120 }
15121 }
15122
15123 #[test]
15124 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15125 let mut state = state_with_summary("再試行を追加する", "- nothing");
15126 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15127 state.config.graph.github_language = "ja".into();
15128 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15129 }
15130
15131 #[test]
15132 fn pr_message_title_limit_is_exact() {
15133 let at = "a".repeat(PR_TITLE_MAX);
15134 assert_eq!(long_title_of(&at), at);
15135 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15136 assert!(over.ends_with("..."), "{over}");
15137 assert_eq!(over.chars().count(), PR_TITLE_MAX);
15138 }
15139
15140 #[test]
15141 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15142 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15143 let t = long_title_of(&line);
15144 assert!(t.ends_with("..."), "{t}");
15145 assert!(t.is_ascii(), "{t}");
15146 }
15147
15148 #[test]
15149 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15150 let line = format!(
15151 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15152 "plus more filler words ".repeat(20)
15153 );
15154 let t = long_title_of(&line);
15155 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15156 }
15157
15158 #[test]
15159 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15160 let line = format!("\n\n# {}", "title words ".repeat(40));
15161 let state = state_with_summary(&line, "- nothing");
15162 let m = pr_message(&state, 'A');
15163 assert!(m.title.starts_with("title words"), "{}", m.title);
15164 assert_eq!(
15165 land::merge_subject(&m.title, &landing_subject_source(&state)),
15166 m.title
15167 );
15168 assert_eq!(
15170 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15171 "feat: renamed by hand"
15172 );
15173 }
15174
15175 #[tokio::test]
15176 async fn github_text_rewrite_is_bounded_and_falls_back() {
15177 crate::run::pin_test_home();
15178 for (reply, accepted) in [
15179 (
15180 "TITLE: fix: retries\nAdd retries for failed requests.",
15181 true,
15182 ),
15183 (
15184 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15185 false,
15186 ),
15187 (
15188 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15189 true,
15190 ),
15191 (
15192 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15193 true,
15194 ),
15195 (
15196 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15197 true,
15198 ),
15199 (
15200 "TITLE: fix: retries\n日本語の説明です。 token=secret",
15201 false,
15202 ),
15203 ("TITLE: fix: retries\n", false),
15204 ] {
15205 let dir = tempfile::tempdir().unwrap();
15206 let mut runner = runner_at(RunStatus::Gating);
15207 runner.state = state_with_summary(
15208 "add retries",
15209 "TITLE: fix: retries\n日本語の説明を書きます。",
15210 );
15211 runner.state.repo = dir.path().to_owned();
15212 runner.state.candidates[0].worktree = dir.path().to_owned();
15213 let mut author = spec("alpha");
15214 author.command = vec![
15215 "sh".into(),
15216 "-c".into(),
15217 "printf '%s' \"$REWRITE_REPLY\"".into(),
15218 ];
15219 author.env.insert("REWRITE_REPLY".into(), reply.into());
15220 runner.state.config.agents = vec![author];
15221 seed_public_summary(&mut runner);
15222 let winner = runner.state.candidates[0].clone();
15223 let first = runner
15224 .guarded_pr_message(&winner, None, true)
15225 .await
15226 .unwrap();
15227 let message = if accepted {
15228 first.expect("a passing rewrite never asks")
15229 } else {
15230 assert!(first.is_none());
15232 assert!(runner.state.parked);
15233 let store = ask::Questions::open();
15234 let asked: Vec<_> = store
15235 .list()
15236 .into_iter()
15237 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15238 .collect();
15239 assert_eq!(asked.len(), 1);
15240 assert!(!asked[0].detail.contains("secret"));
15241 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15242 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15243 assert_eq!(
15244 asked[0]
15245 .choices
15246 .contains(&crate::github_text::USE_REWRITE.to_owned()),
15247 rewrite.is_some()
15248 );
15249 store
15252 .update(&asked[0].id, |q| {
15253 q.abandon("test");
15254 Ok(())
15255 })
15256 .unwrap();
15257 let again = runner
15258 .guarded_pr_message(&winner, None, true)
15259 .await
15260 .unwrap()
15261 .expect("an abandoned question falls back");
15262 let n = store
15263 .list()
15264 .into_iter()
15265 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15266 .count();
15267 assert_eq!(n, 1, "asked once per rejected text");
15268 again
15269 };
15270 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15271 assert!(!message.body.contains("secret"));
15272 assert!(!message.body.contains("prefix"));
15273 assert!(!message.title.contains("secret"));
15274 assert_eq!(
15275 message.body.contains("Add retries for failed requests."),
15276 accepted
15277 );
15278 assert_eq!(
15279 runner.state.seats["github-text-rewrite"].turns, 1,
15280 "only one rewrite invocation"
15281 );
15282 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15283 if accepted {
15284 assert!(runner.state.github_text.is_none());
15285 assert!(!runner.state.parked);
15286 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15287 assert!(message.body.contains("## Final verification"));
15288 assert!(message.body.contains("Evidence: final diff"));
15289 assert!(
15290 message
15291 .body
15292 .contains(&format!("magi:run/{}", runner.state.id))
15293 );
15294 }
15295 if !accepted {
15296 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15297 assert!(
15298 message
15299 .body
15300 .contains(&format!("magi:run/{}", runner.state.id))
15301 );
15302 }
15303 }
15304 }
15305
15306 fn scripted(id: &str, reply: &str) -> AgentSpec {
15307 let mut a = spec(id);
15308 a.command = vec![
15309 "sh".into(),
15310 "-c".into(),
15311 "printf '%s' \"$REWRITE_REPLY\"".into(),
15312 ];
15313 a.env.insert("REWRITE_REPLY".into(), reply.into());
15314 a
15315 }
15316
15317 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15318 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15319
15320 fn seed_public_summary(runner: &mut Runner) {
15322 let repo = &runner.state.repo;
15323 let g = |args: &[&str]| {
15324 let out = std::process::Command::new("git")
15325 .args(args)
15326 .current_dir(repo)
15327 .quiet()
15328 .output()
15329 .unwrap();
15330 assert!(out.status.success(), "git {args:?}");
15331 String::from_utf8_lossy(&out.stdout).trim().to_owned()
15332 };
15333 g(&["init", "-q", "-b", "main"]);
15334 g(&[
15335 "-c",
15336 "user.name=Test",
15337 "-c",
15338 "user.email=test@example.invalid",
15339 "commit",
15340 "-q",
15341 "--allow-empty",
15342 "-m",
15343 "feat: fixture",
15344 ]);
15345 let head = g(&["rev-parse", "HEAD"]);
15346 g(&["update-ref", "refs/remotes/origin/main", &head]);
15347 let evidence = crate::pr_outcome::summary_evidence(
15348 &runner.state,
15349 crate::pr_outcome::FinalHead::At(&head),
15350 );
15351 let generated = &runner.state.candidates[0].summary;
15352 runner.state.change_summary = Some(crate::run::ChangeSummary {
15353 base: head.clone(),
15354 head,
15355 evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15356 title: summary_title(generated).unwrap(),
15357 body: summary_without_title(generated),
15358 });
15359 }
15360
15361 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15362 let mut runner = runner_at(RunStatus::Gating);
15363 runner.state = state_with_summary("add retries", JA_SUMMARY);
15364 if review_only {
15365 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15366 }
15367 runner.state.repo = dir.to_owned();
15368 runner.state.candidates[0].worktree = dir.to_owned();
15369 runner.state.config.agents = agents;
15370 seed_public_summary(&mut runner);
15371 runner
15372 }
15373
15374 fn gate_questions(runner: &Runner) -> usize {
15375 ask::Questions::open()
15376 .list()
15377 .into_iter()
15378 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15379 .count()
15380 }
15381
15382 #[tokio::test]
15383 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15384 crate::run::pin_test_home();
15385 let dir = tempfile::tempdir().unwrap();
15386 let mut runner = rewrite_runner(
15387 dir.path(),
15388 true,
15389 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15390 );
15391 runner.state.config.roles.language_judge =
15392 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15393 let winner = runner.state.candidates[0].clone();
15394 let msg = runner
15395 .guarded_pr_message(&winner, None, true)
15396 .await
15397 .unwrap()
15398 .expect("an English rewrite posts without asking");
15399 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15400 assert!(msg.body.contains("<details>kept</details>"));
15401 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15402 assert_eq!(gate_questions(&runner), 0);
15403 assert!(runner.state.github_text.is_none());
15404 assert!(!runner.state.seats.contains_key("impl-A"));
15405 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15406 }
15407
15408 #[tokio::test]
15409 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15410 crate::run::pin_test_home();
15411 let dir = tempfile::tempdir().unwrap();
15412 let mut runner = rewrite_runner(
15413 dir.path(),
15414 false,
15415 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15416 );
15417 runner.state.config.roles.language_judge =
15418 Some(crate::config::AgentChoice::One("beta".into()));
15419 runner.state.seats.insert(
15420 "impl-A".into(),
15421 SeatState::new("impl-A", "alpha", runner.state.seed),
15422 );
15423 let winner = runner.state.candidates[0].clone();
15424 let msg = runner
15425 .guarded_pr_message(&winner, None, true)
15426 .await
15427 .unwrap()
15428 .expect("rewritten");
15429 assert!(msg.body.contains("<details>kept</details>"));
15430 assert_eq!(runner.state.seats["impl-A"].turns, 0);
15431 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15432 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15433 }
15434
15435 #[tokio::test]
15436 async fn rewrite_reuses_a_resumable_implementer_session() {
15437 crate::run::pin_test_home();
15438 let dir = tempfile::tempdir().unwrap();
15439 let mut runner = rewrite_runner(
15440 dir.path(),
15441 false,
15442 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15443 );
15444 runner.state.config.roles.language_judge =
15445 Some(crate::config::AgentChoice::One("beta".into()));
15446 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15447 seat.turns = 1;
15448 runner.state.seats.insert("impl-A".into(), seat);
15449 let winner = runner.state.candidates[0].clone();
15450 runner
15451 .guarded_pr_message(&winner, None, true)
15452 .await
15453 .unwrap()
15454 .expect("rewritten");
15455 assert_eq!(runner.state.seats["impl-A"].turns, 2);
15456 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15457 }
15458
15459 #[tokio::test]
15460 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15461 crate::run::pin_test_home();
15462 let dir = tempfile::tempdir().unwrap();
15463 let mut broken = spec("bad");
15464 broken.command = vec!["false".into()];
15465 let mut runner = rewrite_runner(
15466 dir.path(),
15467 true,
15468 vec![
15469 broken,
15470 scripted("noformat", "Add retries."),
15471 scripted("good", EN_REWRITE),
15472 ],
15473 );
15474 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15475 "bad".into(),
15476 "noformat".into(),
15477 "good".into(),
15478 ]));
15479 let winner = runner.state.candidates[0].clone();
15480 let msg = runner
15481 .guarded_pr_message(&winner, None, true)
15482 .await
15483 .unwrap()
15484 .expect("the third agent rewrites");
15485 assert!(msg.body.contains("`src/net.rs`"));
15486 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15487 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15488 }
15489
15490 #[tokio::test]
15491 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15492 crate::run::pin_test_home();
15493 for (agents, role, saved) in [
15495 (
15496 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15497 Some("beta"),
15498 true,
15499 ),
15500 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15501 (Vec::new(), None, false),
15502 ] {
15503 let dir = tempfile::tempdir().unwrap();
15504 let mut runner = rewrite_runner(dir.path(), true, agents);
15505 runner.state.config.roles.language_judge =
15506 role.map(|r| crate::config::AgentChoice::One(r.into()));
15507 let winner = runner.state.candidates[0].clone();
15508 let first = runner
15509 .guarded_pr_message(&winner, None, true)
15510 .await
15511 .unwrap();
15512 assert!(first.is_none(), "withheld text parks the run");
15513 assert!(runner.state.parked);
15514 assert_eq!(gate_questions(&runner), 1);
15515 let g = runner.state.github_text.as_ref().unwrap();
15516 assert_eq!(g.rewrite.is_some(), saved);
15517 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15518 }
15519 }
15520
15521 #[tokio::test]
15522 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15523 crate::run::pin_test_home();
15524 let dir = tempfile::tempdir().unwrap();
15525 let mut runner = runner_at(RunStatus::Gating);
15526 runner.state =
15527 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15528 runner.state.repo = dir.path().to_owned();
15529 runner.state.candidates[0].worktree = dir.path().to_owned();
15530 let mut author = spec("alpha");
15531 author.command = vec![
15532 "sh".into(),
15533 "-c".into(),
15534 "printf '%s' \"$REWRITE_REPLY\"".into(),
15535 ];
15536 author.env.insert(
15537 "REWRITE_REPLY".into(),
15538 format!(
15539 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15540 "English sentence. ".repeat(600)
15541 ),
15542 );
15543 runner.state.config.agents = vec![author];
15544 seed_public_summary(&mut runner);
15545 let winner = runner.state.candidates[0].clone();
15546 assert!(
15547 runner
15548 .guarded_pr_message(&winner, None, true)
15549 .await
15550 .unwrap()
15551 .is_none()
15552 );
15553 let record = runner.state.github_text.clone().unwrap();
15554 let rewrite = record.rewrite.clone().unwrap();
15555 assert_eq!(rewrite.failed, ["body-language"]);
15556 assert!(
15557 rewrite
15558 .body
15559 .ends_with(&format!("magi:run/{}", runner.state.id))
15560 );
15561 let store = ask::Questions::open();
15562 let q = store.get(record.question.as_ref().unwrap()).unwrap();
15563 assert!(
15564 q.choices
15565 .contains(&crate::github_text::USE_REWRITE.to_owned())
15566 );
15567 for label in [
15568 "ORIGINAL",
15569 "FALLBACK",
15570 "REWRITE",
15571 "Rules still failed: body-language",
15572 "truncated",
15573 ] {
15574 assert!(q.detail.contains(label), "{label}");
15575 }
15576 let artifact = std::fs::read_to_string(crate::run::artifact_path(
15577 &runner.state,
15578 &crate::github_text::artifact_name(&record.fingerprint),
15579 ))
15580 .unwrap();
15581 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15582 runner.state =
15584 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15585 store
15586 .update(&q.id, |q| {
15587 q.say("まだ日本語")?;
15588 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15589 })
15590 .unwrap();
15591 assert!(
15592 runner
15593 .guarded_pr_message(&winner, None, true)
15594 .await
15595 .unwrap()
15596 .is_none()
15597 );
15598 let retry = runner.state.github_text.as_ref().unwrap();
15599 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15600 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15601 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15602 assert!(
15603 q.posting_comparison
15604 .as_ref()
15605 .unwrap()
15606 .matches_detail(&q.detail)
15607 );
15608 store
15609 .update(&q.id, |q| {
15610 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15611 })
15612 .unwrap();
15613 let message = runner
15614 .guarded_pr_message(&winner, None, true)
15615 .await
15616 .unwrap()
15617 .unwrap();
15618 assert_eq!(message.title, rewrite.title);
15619 assert_eq!(message.body, rewrite.body);
15620 assert_eq!(
15621 runner.state.github_text.as_ref().unwrap().choice,
15622 Some(GithubTextChoice::Rewrite)
15623 );
15624 let again = runner
15625 .guarded_pr_message(&winner, None, true)
15626 .await
15627 .unwrap()
15628 .unwrap();
15629 assert_eq!(again.title, message.title);
15630 assert_eq!(again.body, message.body);
15631 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15632 assert!(
15633 !runner
15634 .state
15635 .events
15636 .iter()
15637 .any(|e| e.message.contains(&rewrite.title)
15638 || e.message.contains("English sentence."))
15639 );
15640 }
15641
15642 #[tokio::test]
15643 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15644 crate::run::pin_test_home();
15645 let dir = tempfile::tempdir().unwrap();
15646 let mut runner = runner_at(RunStatus::Gating);
15647 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15648 runner.state.repo = dir.path().to_owned();
15649 runner.state.candidates[0].worktree = dir.path().to_owned();
15650 seed_public_summary(&mut runner);
15651 let winner = runner.state.candidates[0].clone();
15652 assert!(
15653 runner
15654 .guarded_pr_message(&winner, None, true)
15655 .await
15656 .unwrap()
15657 .is_none()
15658 );
15659 let record = runner.state.github_text.as_ref().unwrap();
15660 assert!(record.rewrite.is_none());
15661 let mut old = serde_json::to_value(record).unwrap();
15663 old.as_object_mut().unwrap().remove("rewrite");
15664 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15665 assert!(old.rewrite.is_none());
15666 let id = record.question.clone().unwrap();
15667 ask::Questions::open()
15668 .update(&id, |q| {
15669 q.choices.push(crate::github_text::USE_REWRITE.into());
15670 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15671 })
15672 .unwrap();
15673 let message = runner
15674 .guarded_pr_message(&winner, None, true)
15675 .await
15676 .unwrap()
15677 .unwrap();
15678 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15679 assert_eq!(
15680 runner.state.github_text.as_ref().unwrap().choice,
15681 Some(GithubTextChoice::Fallback)
15682 );
15683 }
15684
15685 #[tokio::test]
15686 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15687 crate::run::pin_test_home();
15688 let dir = tempfile::tempdir().unwrap();
15689 let mut runner = runner_at(RunStatus::Gating);
15690 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15691 runner.state.repo = dir.path().to_owned();
15692 runner.state.candidates[0].worktree = dir.path().to_owned();
15693 seed_public_summary(&mut runner);
15694 let winner = runner.state.candidates[0].clone();
15695 assert!(
15696 runner
15697 .guarded_pr_message(&winner, None, true)
15698 .await
15699 .unwrap()
15700 .is_none()
15701 );
15702 let store = ask::Questions::open();
15703 let find = |run: &str| {
15704 store
15705 .list()
15706 .into_iter()
15707 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15708 .unwrap()
15709 };
15710 let run = runner.state.id.clone();
15711 let q = find(&run);
15712 let fp = runner
15713 .state
15714 .github_text
15715 .as_ref()
15716 .unwrap()
15717 .fingerprint
15718 .clone();
15719 let name = crate::github_text::artifact_name(&fp);
15720 let path = crate::run::artifact_path(&runner.state, &name);
15721 assert!(q.detail.contains(&path.display().to_string()));
15722 assert!(q.detail.contains("title-language: title"));
15723 let saved = std::fs::read_to_string(&path).expect("artifact written");
15724 assert!(saved.contains("日本語のタイトル"));
15725 assert!(q.cwd.is_none());
15726 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15727 let say = |id: &str, text: &str, choice: &str| {
15729 store
15730 .update(id, |q| {
15731 q.thread.push(ask::Turn {
15732 who: ask::Who::Operator,
15733 body: text.to_owned(),
15734 at: jiff::Timestamp::now(),
15735 note: None,
15736 });
15737 q.answer(ask::Answer::Choice(choice.to_owned()))
15738 })
15739 .unwrap();
15740 };
15741 say(&q.id, "まだ日本語", "use my text");
15742 assert!(
15743 runner
15744 .guarded_pr_message(&winner, None, true)
15745 .await
15746 .unwrap()
15747 .is_none()
15748 );
15749 let second = find(&run);
15750 assert_ne!(second.id, q.id);
15751 assert!(second.detail.contains("did not pass"));
15752 assert!(second.detail.contains("日本語のタイトル"));
15755 assert!(!second.detail.contains("まだ日本語"));
15756 assert!(second.detail.contains(&path.display().to_string()));
15757 say(&second.id, "fix: retry failed requests", "use my text");
15758 let message = runner
15759 .guarded_pr_message(&winner, None, true)
15760 .await
15761 .unwrap()
15762 .expect("a vetted replacement is posted");
15763 assert_eq!(message.title, "fix: retry failed requests");
15764 assert_eq!(
15765 runner
15766 .state
15767 .github_text
15768 .as_ref()
15769 .unwrap()
15770 .chosen_title
15771 .as_deref(),
15772 Some("fix: retry failed requests")
15773 );
15774 assert!(runner.state.github_text.as_ref().unwrap().resolved);
15775 let again = runner
15777 .guarded_pr_message(&winner, None, true)
15778 .await
15779 .unwrap()
15780 .unwrap();
15781 assert_eq!(again.title, message.title);
15782 assert_eq!(
15783 store
15784 .list()
15785 .into_iter()
15786 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15787 .count(),
15788 2
15789 );
15790 }
15791
15792 #[test]
15793 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15794 let mut state = state_with_summary(
15798 "add retries",
15799 "TITLE: fix(web): batch reads\n- reads run.json once",
15800 );
15801 state.config.graph.language = "ja".to_owned();
15802 let m = pr_message(&state, 'A');
15803 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15804 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15805
15806 let task = "今回やってほしいこと: results projector を直す";
15809 let mut state = state_with_summary(task, "- no title line");
15810 state.config.graph.language = "ja".to_owned();
15811 let m = pr_message(&state, 'A');
15812 assert_eq!(
15813 m.title,
15814 format!("chore: land candidate A of run {}", state.id)
15815 );
15816 assert!(
15817 m.body.contains(&format!(
15818 "<summary>Original task</summary>\n\n{task}\n\n</details>"
15819 )),
15820 "{}",
15821 m.body
15822 );
15823 }
15824
15825 #[test]
15826 fn pr_message_scrubs_home_paths_and_addresses() {
15827 let state = state_with_summary(
15828 "fix it in /Users/someone/src/x",
15829 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15830 );
15831 let m = pr_message(&state, 'A');
15832 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15833 assert!(!m.body.contains(leak), "{}", m.body);
15834 }
15835 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15836 }
15837
15838 #[test]
15839 fn pr_message_survives_a_task_that_closes_details() {
15840 let state = state_with_summary("a </details> b", "TITLE: fix: x");
15841 let m = pr_message(&state, 'A');
15842 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15843 }
15844
15845 #[test]
15846 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15847 let cmd = manual_merge_command(
15848 MergeStyle::Squash,
15849 Path::new("/repo"),
15850 "b",
15851 "fix: \"quoted\" $(x) `y`\n\nbody",
15852 );
15853 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15854 }
15855
15856 #[test]
15857 fn manual_merge_command_matches_the_configured_style() {
15858 let repo = Path::new("/repo");
15859 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15860
15861 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15862 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15863
15864 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15865 assert_eq!(
15866 squash,
15867 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15868 \"Merge magi run 0832 (candidate A)\""
15869 );
15870
15871 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15872 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15873 }
15874
15875 #[test]
15876 fn a_nudge_gets_a_quarter_of_the_budget() {
15877 assert_eq!(retry_budget(secs(1200), true), secs(300));
15879 assert_eq!(retry_budget(secs(3600), true), secs(900));
15880 }
15881
15882 #[test]
15883 fn a_resent_prompt_keeps_the_whole_budget() {
15884 assert_eq!(retry_budget(secs(1200), false), secs(1200));
15887 assert_eq!(retry_budget(secs(60), false), secs(60));
15888 }
15889
15890 #[test]
15891 fn the_floor_never_exceeds_the_original_budget() {
15892 assert_eq!(retry_budget(secs(60), true), secs(60));
15896 assert_eq!(retry_budget(secs(480), true), secs(120));
15897 assert_eq!(retry_budget(secs(0), true), secs(0));
15898 }
15899
15900 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15901 agent::CommandEvidence {
15902 id: "item1".to_owned(),
15903 description: "cargo test".to_owned(),
15904 exit_code,
15905 result_summary: String::new(),
15906 source: "codex".to_owned(),
15907 }
15908 }
15909
15910 #[test]
15911 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15912 assert!(!has_unconfirmed_command(&[]));
15916 }
15917
15918 #[test]
15919 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15920 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15924 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15925 assert!(!has_unconfirmed_command(&[
15926 evidence(Some(0)),
15927 evidence(Some(101))
15928 ]));
15929 }
15930
15931 #[test]
15932 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15933 assert!(has_unconfirmed_command(&[
15934 evidence(Some(0)),
15935 evidence(None)
15936 ]));
15937 }
15938
15939 #[test]
15940 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15941 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15942 assert_eq!(
15943 verified_noop_claim(true, &[], text).as_deref(),
15944 Some("already fixed by b32cfc4, on main.")
15945 );
15946 }
15947
15948 #[test]
15949 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15950 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15953 assert!(verified_noop_claim(false, &[], text).is_none());
15954 }
15955
15956 #[test]
15957 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15958 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15959 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15960 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15962 }
15963
15964 #[test]
15965 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15966 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15967 }
15968
15969 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15972 runner.state.candidates = shape
15973 .iter()
15974 .enumerate()
15975 .map(|(i, &(empty, verified))| Candidate {
15976 index: i,
15977 label: (b'A' + i as u8) as char,
15978 agent: "sonnet".to_owned(),
15979 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15980 worktree: PathBuf::from(format!("/wt/{i}")),
15981 summary: String::new(),
15982 stat: String::new(),
15983 files: 0,
15984 commits: 0,
15985 empty,
15986 failed: None,
15987 verified_noop: verified.map(str::to_owned),
15988 duration_ms: 0,
15989 folded: false,
15990 })
15991 .collect();
15992 }
15993
15994 #[test]
15995 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15996 ask_test_home();
15997 let mut runner = runner_at(RunStatus::Implementing);
15998 set_candidates(
15999 &mut runner,
16000 &[
16001 (true, Some("already on main at b32cfc4")),
16002 (true, Some("same fix, see the existing test")),
16003 ],
16004 );
16005
16006 runner
16007 .after_implement()
16008 .expect("a verified no-op is not an error");
16009
16010 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
16011 }
16012
16013 #[test]
16014 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
16015 ask_test_home();
16016 let mut runner = runner_at(RunStatus::Implementing);
16017 set_candidates(
16021 &mut runner,
16022 &[(true, Some("already on main at b32cfc4")), (true, None)],
16023 );
16024
16025 let err = runner
16026 .after_implement()
16027 .expect_err("an unverified empty candidate must still fail the run");
16028
16029 assert!(
16030 err.to_string().contains("no candidate produced a change"),
16031 "{err}"
16032 );
16033 assert_eq!(runner.state.status, RunStatus::Failed);
16034 }
16035
16036 #[test]
16037 fn after_implement_still_fails_an_ordinary_all_empty_run() {
16038 ask_test_home();
16039 let mut runner = runner_at(RunStatus::Implementing);
16040 set_candidates(&mut runner, &[(true, None), (true, None)]);
16041
16042 let err = runner
16043 .after_implement()
16044 .expect_err("no candidate declared anything; this is an ordinary failure");
16045
16046 assert!(
16047 err.to_string().contains("no candidate produced a change"),
16048 "{err}"
16049 );
16050 assert_eq!(runner.state.status, RunStatus::Failed);
16051 }
16052}
16053
16054#[derive(Debug)]
16057struct GraftRestoreFailed(String);
16058
16059impl std::fmt::Display for GraftRestoreFailed {
16060 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16061 write!(
16062 f,
16063 "could not restore the winner to its pre-graft commit: {}",
16064 self.0
16065 )
16066 }
16067}
16068
16069impl std::error::Error for GraftRestoreFailed {}
16070
16071#[cfg(test)]
16075mod resource_wait_tests {
16076 use super::*;
16077 use std::collections::BTreeMap;
16078
16079 fn sh(id: &str, script: &str) -> AgentSpec {
16080 AgentSpec {
16081 id: id.to_owned(),
16082 kind: crate::config::AgentKind::Command,
16083 model: None,
16084 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16085 extra_args: Vec::new(),
16086 env: BTreeMap::new(),
16087 prompt_delivery: None,
16088 }
16089 }
16090
16091 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16092 SeatJob {
16093 seat: SeatState::new("impl-A", &spec.id, 1),
16094 spec,
16095 cwd: dir.to_path_buf(),
16096 prompt: "do it".to_owned(),
16097 timeout,
16098 allow_write: true,
16099 sessions: false,
16100 artifacts: dir.join("art"),
16101 stem: "impl-A".to_owned(),
16102 handover: None,
16103 }
16104 }
16105
16106 fn state(resource_wait_secs: u64) -> RunState {
16107 let mut config = Config::default();
16108 config.graph.timeout_resource_wait = resource_wait_secs;
16109 RunState::new(
16110 PathBuf::from("/repo"),
16111 "main".to_owned(),
16112 "abc1234".to_owned(),
16113 "task".to_owned(),
16114 config,
16115 )
16116 }
16117
16118 fn hold(cache: &Path) -> crate::cache::Guard {
16119 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16120 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16121 crate::cache::AcquireOutcome::Acquired(g) => g,
16122 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16123 }
16124 }
16125
16126 async fn run(
16127 jobs: Vec<SeatJob>,
16128 sem: Arc<Semaphore>,
16129 cache: Option<&Path>,
16130 st: &mut RunState,
16131 ) -> Vec<(usize, SeatState, AgentOutcome)> {
16132 let prompts = Prompts::default();
16133 let ctx = WaveCtx {
16134 run: "r",
16135 node: "implement",
16136 prompts: &prompts,
16137 cache,
16138 round: None,
16139 carry_seats: false,
16140 pauses: None,
16141 };
16142 wave(jobs, sem, &ctx, st, 0).await
16143 }
16144
16145 #[tokio::test]
16146 async fn verification_cache_wait_preserves_the_command_budget() {
16147 crate::run::pin_test_home();
16148 let tmp = tempfile::tempdir().unwrap();
16149 let cache = tmp.path().join("target");
16150 let guard = hold(&cache);
16151 let release = tokio::spawn(async move {
16152 tokio::time::sleep(Duration::from_millis(100)).await;
16153 guard.release();
16154 });
16155 let mut st = state(10);
16156 let budget = Duration::from_millis(50);
16157 let (outcomes, _) = with_cache_lease(
16158 &mut st,
16159 Some(&cache),
16160 "gate",
16161 "gate",
16162 tmp.path(),
16163 "abc1234",
16164 budget,
16165 "test gate",
16166 |_, available| async move {
16167 assert_eq!(
16168 available, budget,
16169 "contention must not shorten verification"
16170 );
16171 (Vec::new(), false, Vec::new())
16172 },
16173 )
16174 .await;
16175 release.await.unwrap();
16176 assert!(outcomes.is_empty(), "{outcomes:?}");
16177 assert!(
16178 st.events
16179 .iter()
16180 .any(|event| event.message.contains("waiting for"))
16181 );
16182 assert!(st.resource_wait.is_none());
16183 hold(&cache).release();
16185 }
16186
16187 #[tokio::test]
16188 async fn verification_cache_wait_expires_without_starting_commands() {
16189 crate::run::pin_test_home();
16190 let tmp = tempfile::tempdir().unwrap();
16191 let cache = tmp.path().join("target");
16192 let _held = hold(&cache);
16193 let mut st = state(0);
16194 let (outcomes, _) = with_cache_lease(
16195 &mut st,
16196 Some(&cache),
16197 "gate",
16198 "gate",
16199 tmp.path(),
16200 "abc1234",
16201 Duration::from_secs(60),
16202 "test gate",
16203 |_, _| async { panic!("commands must not start without the cache lease") },
16204 )
16205 .await;
16206 assert_eq!(outcomes.len(), 1);
16207 assert!(outcomes[0].resource_blocked);
16208 assert!(st.resource_wait.is_none());
16209 }
16210
16211 #[tokio::test]
16212 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16213 crate::run::pin_test_home();
16214 let tmp = tempfile::tempdir().unwrap();
16215 let cache = tmp.path().join("target-a");
16216 let guard = hold(&cache);
16217 tokio::spawn(async move {
16219 tokio::time::sleep(Duration::from_millis(1500)).await;
16220 guard.release();
16221 });
16222 let mut st = state(60);
16223 let results = run(
16224 vec![job(
16225 sh("alpha", "echo ok"),
16226 tmp.path(),
16227 Duration::from_secs(1),
16228 )],
16229 Arc::new(Semaphore::new(1)),
16230 Some(&cache),
16231 &mut st,
16232 )
16233 .await;
16234 assert!(
16235 matches!(results[0].2, AgentOutcome::Ok(_)),
16236 "the wait must not be charged to the agent"
16237 );
16238 assert!(st.resource_block.is_none());
16239 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16240 assert!(st.resource_wait.is_none() && st.active.is_empty());
16241 }
16242
16243 #[tokio::test]
16244 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16245 crate::run::pin_test_home();
16246 let tmp = tempfile::tempdir().unwrap();
16247 let cache = tmp.path().join("target-b");
16248 let _held = hold(&cache);
16249 let mut st = state(1);
16250 let results = run(
16251 vec![job(
16252 sh("alpha", "echo ok"),
16253 tmp.path(),
16254 Duration::from_secs(1),
16255 )],
16256 Arc::new(Semaphore::new(1)),
16257 Some(&cache),
16258 &mut st,
16259 )
16260 .await;
16261 let (_, seat, out) = &results[0];
16262 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16263 assert!(wave_blocked(&results));
16264 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16265 assert_eq!(seat.turns, 0, "nothing was asked");
16266 assert!(
16267 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16268 "no agent was spawned"
16269 );
16270 assert!(FailClass::of(out).is_none(), "not a failure class");
16271 let block = st.resource_block.as_ref().expect("recorded for the resume");
16272 assert_eq!(block.kind, "shared build cache");
16273 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16274 assert!(st.active.is_empty());
16275 }
16276
16277 #[tokio::test]
16278 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16279 crate::run::pin_test_home();
16280 let tmp = tempfile::tempdir().unwrap();
16281 let mut st = state(1);
16282 let results = run(
16283 vec![job(
16284 sh("alpha", "echo ok"),
16285 tmp.path(),
16286 Duration::from_secs(1),
16287 )],
16288 Arc::new(Semaphore::new(0)),
16289 None,
16290 &mut st,
16291 )
16292 .await;
16293 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16294 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16295 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16296 }
16297
16298 #[tokio::test]
16299 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16300 crate::run::pin_test_home();
16301 let tmp = tempfile::tempdir().unwrap();
16302 let mut st = state(60);
16303 let results = run(
16304 vec![job(
16305 sh("alpha", "sleep 30"),
16306 tmp.path(),
16307 Duration::from_secs(1),
16308 )],
16309 Arc::new(Semaphore::new(1)),
16310 None,
16311 &mut st,
16312 )
16313 .await;
16314 let out = &results[0].2;
16315 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16316 let class = FailClass::of(out).expect("a failure");
16317 assert_eq!(class, FailClass::Timeout);
16318 assert!(should_hand_over(None, &class), "existing handover policy");
16319 assert!(st.resource_block.is_none());
16320 }
16321}