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)]
9887async fn with_cache_lease<'s, F, Fut>(
9888 state: &'s mut RunState,
9889 cache_dir: Option<&Path>,
9890 node: &str,
9891 seat: &str,
9892 worktree: &Path,
9893 head: &str,
9894 budget: Duration,
9895 context: &str,
9896 body: F,
9897) -> (Vec<CommandOutcome>, bool)
9898where
9899 F: FnOnce(&'s mut RunState, Duration) -> Fut,
9900 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9901{
9902 let Some(cache_dir) = cache_dir else {
9903 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9904 return (outcomes, retried);
9905 };
9906 let home = crate::run::home();
9907 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9908 let started = Instant::now();
9909 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, 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 remaining = budget.saturating_sub(started.elapsed());
9963 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9964 if !timed_out_pids.is_empty() {
9969 wait_for_timed_out_children_to_die(&timed_out_pids).await;
9970 }
9971 guard.release();
9972 (outcomes, retried)
9973}
9974
9975async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9987 wait_for_pids_with(
9988 pids,
9989 crate::proc::pid_alive,
9990 LEASE_RELEASE_POLL,
9991 LEASE_RELEASE_MAX_WAIT,
9992 )
9993 .await;
9994}
9995
9996async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10002 pids: &[u32],
10003 alive: F,
10004 poll: Duration,
10005 max_wait: Duration,
10006) {
10007 let deadline = Instant::now() + max_wait;
10008 loop {
10009 if pids.iter().all(|&pid| !alive(pid)) {
10010 return;
10011 }
10012 if Instant::now() >= deadline {
10013 return;
10014 }
10015 tokio::time::sleep(poll).await;
10016 }
10017}
10018
10019fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10026 outcomes.iter().any(|o| o.resource_blocked)
10027}
10028
10029enum GateFix {
10031 Retry,
10033 Stop,
10036 Defer,
10039}
10040
10041fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10049 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10050 red.peek().is_some()
10051 && red.all(|o| {
10052 !o.resource_blocked
10053 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10054 && !o.output_tail.trim().is_empty()
10055 })
10056}
10057
10058fn e2e_outcome_label(o: &CommandOutcome) -> String {
10062 if o.ok() {
10063 return "pass".to_owned();
10064 }
10065 let reason = if o.build_failed() {
10066 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10067 } else {
10068 format!("FAIL ({:?})", o.code)
10069 };
10070 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10071}
10072
10073async fn run_e2e_with_retry(
10081 state: &mut RunState,
10082 shell: &[String],
10083 commands: &[String],
10084 worktree: &Path,
10085 timeout: Duration,
10086 context: &str,
10087) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10088 let (mut e2e, mut timed_out_pids) = run_commands(
10089 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10090 )
10091 .await;
10092 for o in &e2e {
10093 state.event(
10094 "verify",
10095 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10096 );
10097 }
10098 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10102 if verify_retried {
10103 state.event(
10104 "verify",
10105 format!(
10106 "{context}: verify could not build/link, not a test result — retrying once \
10107 before concluding"
10108 ),
10109 );
10110 let retried = run_commands(
10111 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10112 )
10113 .await;
10114 e2e = retried.0;
10115 timed_out_pids.extend(retried.1);
10118 for o in &e2e {
10119 state.event(
10120 "verify",
10121 format!(
10122 "{context}: retry `{}` -> {}",
10123 o.command,
10124 e2e_outcome_label(o)
10125 ),
10126 );
10127 }
10128 }
10129 (e2e, verify_retried, timed_out_pids)
10130}
10131
10132#[allow(clippy::too_many_arguments)]
10148async fn run_commands(
10149 state: &mut RunState,
10150 node: &str,
10151 task: &str,
10152 attempt: usize,
10153 shell: &[String],
10154 commands: &[String],
10155 cwd: &Path,
10156 timeout: Duration,
10157) -> (Vec<CommandOutcome>, Vec<u32>) {
10158 if commands.is_empty() {
10159 return (Vec::new(), Vec::new());
10164 }
10165 let mut out = Vec::new();
10166 let mut timed_out_pids = Vec::new();
10167 let total = commands.len();
10168 for (idx, command) in commands.iter().enumerate() {
10169 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10170 if let Err(e) = state.save() {
10171 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10172 }
10173 let started = Instant::now();
10174 let mut cmd = tokio::process::Command::new(&shell[0]);
10175 cmd.quiet();
10176 cmd.args(&shell[1..])
10177 .arg(command)
10178 .current_dir(cwd)
10179 .stdin(std::process::Stdio::null())
10180 .stdout(std::process::Stdio::piped())
10181 .stderr(std::process::Stdio::piped())
10182 .kill_on_drop(true);
10183 let spawned = cmd.spawn();
10184 let (code, body) = match spawned {
10185 Ok(child) => {
10186 let pid = child.id();
10191 match tokio::time::timeout(timeout, child.wait_with_output()).await {
10192 Ok(Ok(o)) => {
10193 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10194 body.push_str(&String::from_utf8_lossy(&o.stderr));
10195 (o.status.code(), body)
10196 }
10197 Ok(Err(e)) => (None, format!("failed to run: {e}")),
10198 Err(_) => {
10199 if let Some(pid) = pid {
10200 timed_out_pids.push(pid);
10201 }
10202 (None, format!("timed out after {}s", timeout.as_secs()))
10203 }
10204 }
10205 }
10206 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10207 };
10208 out.push(CommandOutcome {
10209 command: command.clone(),
10210 code,
10211 output_tail: tail(&body, OUTPUT_TAIL),
10212 duration_ms: started.elapsed().as_millis() as u64,
10213 resource_blocked: false,
10214 });
10215 }
10216 state.task_finished(task);
10217 if let Err(e) = state.save() {
10218 tracing::warn!("could not persist the end of {task}: {e:#}");
10219 }
10220 (out, timed_out_pids)
10221}
10222
10223fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10235 let repo = repo.display();
10236 match style {
10237 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10238 MergeStyle::Squash => {
10239 let subject = message
10242 .lines()
10243 .next()
10244 .unwrap_or(branch)
10245 .replace(['\\', '"', '$', '`'], "");
10246 format!(
10247 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10248 )
10249 }
10250 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10251 }
10252}
10253
10254const PR_TITLE_MAX: usize = 240;
10265
10266fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10277 let line = queue::first_line(text)?;
10278 let chars: Vec<char> = line.chars().collect();
10279 let (kept, mark) = if chars.len() <= max {
10280 (line.to_owned(), "")
10281 } else if let Some(end) = sentence_end(&chars, max) {
10282 (chars[..end].iter().collect::<String>(), "")
10283 } else {
10284 let room = max.saturating_sub(3);
10285 let cut = if chars[room].is_whitespace() {
10288 room
10289 } else {
10290 chars[..room]
10291 .iter()
10292 .rposition(|c| c.is_whitespace())
10293 .unwrap_or(room)
10294 };
10295 let head: String = chars[..cut].iter().collect();
10296 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10297 (head.to_owned(), "...")
10298 };
10299 if kept.is_empty() {
10300 return None;
10301 }
10302 if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10303 return None;
10304 }
10305 if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10306 return None;
10307 }
10308 Some(format!("{kept}{mark}"))
10309}
10310
10311fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10315 const MIN: usize = 20;
10316 for i in MIN..max.min(chars.len()) {
10317 if !matches!(chars[i], '.' | '!' | '?') {
10318 continue;
10319 }
10320 let Some(&next) = chars.get(i + 1) else {
10321 continue;
10322 };
10323 if !next.is_whitespace() {
10324 continue;
10325 }
10326 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10327 if after.is_some_and(|c| c.is_ascii_lowercase()) {
10328 continue;
10329 }
10330 let word: String = chars[..i]
10331 .iter()
10332 .rev()
10333 .take_while(|c| !c.is_whitespace())
10334 .collect::<Vec<_>>()
10335 .into_iter()
10336 .rev()
10337 .collect();
10338 let word = word.to_ascii_lowercase();
10339 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10340 continue;
10341 }
10342 let end = chars[..i]
10343 .iter()
10344 .rposition(|c| !c.is_whitespace())
10345 .map_or(i, |p| p + 1);
10346 return Some(end);
10347 }
10348 None
10349}
10350
10351struct PrMessage {
10356 title: String,
10357 body: String,
10358}
10359
10360impl PrMessage {
10361 fn commit_message(&self) -> String {
10365 format!("{}\n\n{}", self.title, self.body)
10366 }
10367}
10368
10369fn title_marker(line: &str) -> Option<&str> {
10371 let line = line.trim();
10372 let head = line.get(..6)?;
10373 head.eq_ignore_ascii_case("title:")
10374 .then(|| line[6..].trim())
10375}
10376
10377fn summary_title(summary: &str) -> Option<String> {
10382 let first = summary.lines().find(|l| !l.trim().is_empty())?;
10383 let raw = title_marker(first)?;
10384 if raw.is_empty() {
10385 return None;
10386 }
10387 let title = queue::title_from(raw, PR_TITLE_MAX);
10388 let lower = title.to_ascii_lowercase();
10389 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10390 return None;
10391 }
10392 Some(title)
10393}
10394
10395const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10397
10398pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10400
10401fn is_review_run(state: &RunState) -> bool {
10405 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10406}
10407
10408fn review_title(state: &RunState) -> Option<String> {
10413 english_subject(
10414 state.reviewed_commits.as_ref()?.first()?,
10415 state.config.graph.github_english(),
10416 )
10417}
10418
10419fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10422 let raw = raw.trim();
10423 if raw.is_empty() {
10424 return None;
10425 }
10426 if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10427 return None;
10428 }
10429 if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10430 return None;
10431 }
10432 let title = queue::title_from(raw, PR_TITLE_MAX);
10433 let lower = title.to_ascii_lowercase();
10434 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10435 return None;
10436 }
10437 Some(title)
10438}
10439
10440#[derive(Debug, Clone, PartialEq, Eq)]
10443struct BranchFacts {
10444 commits: Vec<(String, String)>,
10446 stat: String,
10448}
10449
10450const STAT_FILE_LINES: usize = 25;
10452const COMMITS_MAX_CHARS: usize = 20_000;
10454
10455async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10458 let commits = git::commit_log(repo, base, branch).await.ok()?;
10459 if commits.is_empty() {
10460 return None;
10461 }
10462 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10463 let lines: Vec<&str> = stat.lines().collect();
10464 let stat = if lines.len() > STAT_FILE_LINES + 1 {
10465 let omitted = lines.len() - 1 - STAT_FILE_LINES;
10466 let more = format!(" ... {omitted} more file(s)");
10467 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10468 kept.push(&more);
10469 kept.push(lines[lines.len() - 1]);
10470 kept.join("\n")
10471 } else {
10472 lines.join("\n")
10473 };
10474 Some(BranchFacts { commits, stat })
10475}
10476
10477fn markdown_safe(text: &str) -> String {
10480 text.replace("</details>", "</details>")
10481 .replace("\x60\x60\x60", "~~~")
10482}
10483
10484fn neutral_title(state: &RunState, winner: char) -> String {
10485 format!(
10486 "chore: land candidate {} of run {}",
10487 winner.to_ascii_uppercase(),
10488 state.id
10489 )
10490}
10491
10492pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10498 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10499 ""
10500 } else {
10501 pr_title
10502 }
10503}
10504
10505pub fn landing_subject_source(state: &RunState) -> String {
10509 if is_review_run(state) {
10510 let winner = state.candidates.first().map_or('A', |c| c.label);
10511 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10512 }
10513 state.instruction.clone()
10514}
10515
10516fn summary_without_title(summary: &str) -> String {
10519 let mut lines = summary.trim().lines().peekable();
10520 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10521 lines.next();
10522 }
10523 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10524}
10525
10526#[cfg(test)]
10538fn pr_message(state: &RunState, winner: char) -> PrMessage {
10539 pr_message_with(state, winner, None)
10540}
10541
10542#[cfg(test)]
10545fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10546 let raw = pr_message_raw(
10547 state,
10548 winner,
10549 facts,
10550 crate::pr_outcome::FinalHead::Unreported,
10551 ChangeNote::NotAsked,
10552 );
10553 let id = crate::scrub::Identity::current();
10554 PrMessage {
10555 title: crate::scrub::scrub(&raw.title, &id),
10556 body: crate::scrub::scrub(&raw.body, &id),
10557 }
10558}
10559
10560#[derive(Debug, Clone, Copy)]
10562enum ChangeNote<'a> {
10563 NotAsked,
10565 Summary(&'a crate::run::ChangeSummary),
10567 Unavailable(&'a str),
10569}
10570
10571fn pr_message_raw(
10572 state: &RunState,
10573 winner: char,
10574 facts: Option<&BranchFacts>,
10575 head: crate::pr_outcome::FinalHead<'_>,
10576 change: ChangeNote<'_>,
10577) -> PrMessage {
10578 if !matches!(change, ChangeNote::NotAsked) {
10579 let (title, mut body) = match change {
10580 ChangeNote::Summary(c) => (
10581 c.title.clone(),
10582 format!("## Summary\n\n{}\n\n", c.body.trim()),
10583 ),
10584 ChangeNote::Unavailable(why) => (
10585 neutral_title(state, winner),
10586 format!(
10587 "## 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"
10588 ),
10589 ),
10590 ChangeNote::NotAsked => unreachable!(),
10591 };
10592 body.push_str(&public_pr_evidence(state, head));
10593 body.push_str(&format!(
10594 "\n---\nmagi:run/{} magi:candidate-{}\n",
10595 state.id,
10596 winner.to_ascii_lowercase()
10597 ));
10598 return PrMessage { title, body };
10599 }
10600 pr_preview(state, winner, facts, head)
10601}
10602
10603fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10606 let mut body = crate::pr_outcome::review_rounds(state);
10607 body.push_str(&crate::pr_outcome::final_verification(state, head));
10608 let open = state.last_round_findings();
10609 if !open.is_empty() {
10610 body.push_str("## Open review findings\n\n");
10611 for f in open {
10612 body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10613 }
10614 body.push('\n');
10615 }
10616 if let Some(c) = state.change_summary.as_ref()
10617 && head == crate::pr_outcome::FinalHead::At(&c.head)
10618 {
10619 body.push_str(&format!(
10620 "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10621 c.base, c.head
10622 ));
10623 }
10624 body
10625}
10626
10627fn pr_preview(
10629 state: &RunState,
10630 winner: char,
10631 facts: Option<&BranchFacts>,
10632 head: crate::pr_outcome::FinalHead<'_>,
10633) -> PrMessage {
10634 let summary = state
10635 .candidates
10636 .iter()
10637 .find(|c| c.label == winner)
10638 .map(|c| c.summary.as_str())
10639 .unwrap_or_default();
10640 let review = is_review_run(state);
10643 let title = if review {
10644 facts
10645 .and_then(|f| {
10646 english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10647 })
10648 .or_else(|| review_title(state))
10649 .or_else(|| {
10650 state
10651 .candidates
10652 .iter()
10653 .find(|c| c.label == winner)
10654 .filter(|c| !c.branch.starts_with("magi/"))
10655 .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10656 })
10657 .unwrap_or_else(|| neutral_title(state, winner))
10658 } else {
10659 summary_title(summary).unwrap_or_else(|| {
10660 english_title(
10661 &state.instruction,
10662 PR_TITLE_MAX,
10663 state.config.graph.github_english(),
10664 )
10665 .unwrap_or_else(|| neutral_title(state, winner))
10666 })
10667 };
10668
10669 let mut body = String::new();
10670 let what = summary_without_title(summary);
10671 if !what.is_empty() {
10672 body.push_str("## Summary\n\n");
10673 body.push_str(&what);
10674 body.push_str("\n\n");
10675 }
10676
10677 body.push_str(&crate::pr_outcome::review_rounds(state));
10679 body.push_str(&crate::pr_outcome::final_verification(state, head));
10680 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10681
10682 let open = state.open_findings();
10683 if !open.is_empty() {
10684 body.push_str("## Open review findings\n\n");
10685 for f in &open {
10686 body.push_str(&format!(
10687 "- `{}` [{:?}] {}\n",
10688 f.id, f.severity, "Context retained in local review records."
10689 ));
10690 }
10691 body.push('\n');
10692 }
10693
10694 if let Some(fix) = fix
10695 && !fix.rejected.is_empty()
10696 {
10697 body.push_str("## Declined by the fixer\n\n");
10698 for r in &fix.rejected {
10699 body.push_str(&format!(
10700 "- `{}`: {}\n",
10701 r.id, "Reason retained in local review records."
10702 ));
10703 }
10704 body.push('\n');
10705 }
10706
10707 if review {
10708 body.push_str("## Commits under review\n\n");
10710 if let Some(facts) = facts {
10711 let mut left = COMMITS_MAX_CHARS;
10712 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10713 let mut entry = format!("- {}\n", markdown_safe(subject));
10714 for l in markdown_safe(text).lines() {
10715 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10716 }
10717 if left == 0 {
10718 body.push_str(&format!(
10719 "- ... {} more commit(s)\n",
10720 facts.commits.len() - i
10721 ));
10722 break;
10723 }
10724 if entry.len() > left {
10725 let mut end = left;
10728 while !entry.is_char_boundary(end) {
10729 end -= 1;
10730 }
10731 entry.truncate(end);
10732 entry.push_str("\n ... (truncated)\n");
10733 left = 0;
10734 } else {
10735 left -= entry.len();
10736 }
10737 body.push_str(&entry);
10738 }
10739 if !facts.stat.trim().is_empty() {
10740 body.push_str(&format!(
10741 "\n## Diff stat\n\n```\n{}\n```\n",
10742 markdown_safe(facts.stat.trim())
10743 ));
10744 }
10745 } else {
10746 match &state.reviewed_commits {
10747 Some(subjects) => {
10748 for s in subjects {
10749 body.push_str(&format!("- {}\n", s.trim()));
10750 }
10751 }
10752 None => {
10753 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10756 body.push_str(rest.trim());
10757 body.push('\n');
10758 }
10759 }
10760 }
10761 } else {
10762 let task = state.instruction.trim();
10763 let task = if task.is_empty() {
10764 "(empty task)"
10765 } else {
10766 task
10767 };
10768 body.push_str(&format!(
10769 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10770 task.replace("</details>", "</details>")
10771 ));
10772 }
10773
10774 body.push_str(&format!(
10775 "\n---\nmagi:run/{} magi:candidate-{}\n",
10776 state.id,
10777 winner.to_ascii_lowercase()
10778 ));
10779
10780 PrMessage { title, body }
10781}
10782
10783fn seeded_instruction(state: &RunState) -> String {
10787 match refs::describe(&state.seeds) {
10788 Some(facts) => format!(
10789 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10790 Candidates start from the unmerged branch named above, when there \
10791 is one, and carry any unmerged commit named by sha as a \
10792 cherry-pick. Check that this is what the task meant before \
10793 building on it.",
10794 state.instruction
10795 ),
10796 None => state.instruction.clone(),
10797 }
10798}
10799
10800async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10804 let base = &state.base_branch;
10805 let mut against = base.clone();
10806 if mode == MergeMode::Pr {
10807 let remote = &state.config.merge.remote;
10811 let tracking = format!("{remote}/{base}");
10812 let fetched = git::fetch(repo, remote, base).await;
10813 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10814 against = tracking;
10815 } else {
10816 return false;
10817 }
10818 }
10819 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10820}
10821
10822fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10825 let mut detail = format!(
10826 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10827 nothing to open a pull request for"
10828 );
10829 match refs::describe(&state.seeds) {
10830 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10831 None => detail.push_str(
10832 "\nThe task names no existing branch or commit; if it means to land work \
10833 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10834 ),
10835 }
10836 detail
10837}
10838
10839#[derive(Debug, PartialEq, Eq)]
10842enum PrPlan {
10843 Create,
10844 Adopt { url: String, title: String },
10845 Stop(String),
10846}
10847
10848fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10851 match found {
10852 Ok(land::OpenPr::None) => PrPlan::Create,
10853 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10854 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10855 "several open pull requests exist for this branch, not picking one: {}",
10856 urls.join(" ")
10857 )),
10858 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10859 }
10860}
10861
10862pub(crate) async fn gh_pr_create(
10864 cwd: &Path,
10865 base: &str,
10866 head: &str,
10867 title: &str,
10868 body: &str,
10869) -> Result<String> {
10870 let out = tokio::process::Command::new("gh")
10871 .args([
10872 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10873 ])
10874 .current_dir(cwd)
10875 .quiet()
10876 .stdin(std::process::Stdio::null())
10877 .output()
10878 .await
10879 .context("spawn gh")?;
10880 if out.status.success() {
10881 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10882 } else {
10883 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10884 }
10885}
10886
10887pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10896 if crate::pr_recovery::sources_retained(home, &state.id) {
10897 return Ok(Vec::new());
10898 }
10899 let repo = state.repo.clone();
10900 let root = state.worktree_root();
10901 let winner = state.tally.as_ref().map(|t| t.winner);
10902 let mut removed = Vec::new();
10903
10904 for i in 0..state.candidates.len() {
10905 let c = state.candidates[i].clone();
10906 let is_winner = Some(c.label) == winner;
10907 if is_winner && !drop_winner {
10908 continue;
10909 }
10910 if c.worktree.exists() {
10911 git::worktree_remove(&repo, &c.worktree).await.ok();
10912 removed.push(c.worktree.to_string_lossy().into_owned());
10913 }
10914 let handed_over = state.released_branches.contains(&c.branch);
10917 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10918 git::branch_delete(&repo, &c.branch).await.ok();
10919 removed.push(c.branch.clone());
10920 }
10921 state.candidates[i].folded = true;
10922 }
10923
10924 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10925 let path = name.path();
10926 let keep = !drop_winner
10927 && winner.is_some_and(|w| {
10928 path.file_name()
10929 .is_some_and(|n| n == format!("cand-{w}").as_str())
10930 });
10931 if keep {
10932 continue;
10933 }
10934 git::worktree_remove(&repo, &path).await.ok();
10935 removed.push(path.to_string_lossy().into_owned());
10936 }
10937
10938 remove_if_empty(&root);
10947
10948 if state.enabled_worktree_config && drop_winner {
10949 git::release_worktree_config(&repo).await.ok();
10953 state.enabled_worktree_config = false;
10954 }
10955 state.save_under(home)?;
10956 Ok(removed)
10957}
10958
10959fn remove_if_empty(dir: &Path) {
10970 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10971 std::fs::remove_dir(dir).ok();
10972 }
10973}
10974
10975pub fn worst_open(state: &RunState) -> Option<Severity> {
10977 state
10978 .reviews
10979 .last()?
10980 .reviews
10981 .iter()
10982 .flat_map(|r| r.findings.iter())
10983 .map(|f| f.severity)
10984 .max()
10985}
10986
10987async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10991 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10992 git::acquire_worktree_config(repo).await?;
10993 state.enabled_worktree_config = true;
10994 }
10995 Ok(())
10996}
10997
10998#[cfg(test)]
10999mod tests {
11000 #[test]
11001 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11002 let leaked = vec!["chore(deps): update a crate".to_owned()];
11003 let own = "fix(daemon): apply a chosen action";
11004 assert!(should_retitle("", own, &leaked));
11005 assert!(should_retitle(
11006 &format!("{REVIEW_PROMPT_OPENING} `x`"),
11007 own,
11008 &leaked
11009 ));
11010 assert!(should_retitle(
11011 "chore: land candidate A of run 1",
11012 own,
11013 &leaked
11014 ));
11015 assert!(should_retitle(
11016 "magi: candidate A (uncommitted work)",
11017 own,
11018 &leaked
11019 ));
11020 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11021 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11022 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11023 }
11024
11025 #[test]
11026 fn pr_merge_plan_creates_adopts_or_stops() {
11027 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11028 assert_eq!(
11029 pr_merge_plan(Ok(land::OpenPr::One {
11030 url: "u".into(),
11031 title: "t".into()
11032 })),
11033 PrPlan::Adopt {
11034 url: "u".into(),
11035 title: "t".into()
11036 }
11037 );
11038 let PrPlan::Stop(many) =
11039 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11040 else {
11041 panic!("many must stop");
11042 };
11043 assert!(many.contains('a') && many.contains('b'));
11044 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11045 panic!("a failed lookup must stop");
11046 };
11047 assert!(err.contains("bad token"));
11048 }
11049
11050 use super::*;
11051 use crate::run::GateStatus;
11052 use std::collections::BTreeMap;
11053 use std::time::Duration;
11054
11055 fn conductor() -> AgentSpec {
11056 AgentSpec {
11057 id: "conductor".to_owned(),
11058 kind: crate::config::AgentKind::Command,
11059 model: None,
11060 command: vec!["true".to_owned()],
11061 extra_args: Vec::new(),
11062 env: BTreeMap::new(),
11063 prompt_delivery: None,
11064 }
11065 }
11066
11067 fn spec(id: &str) -> AgentSpec {
11068 AgentSpec {
11069 id: id.to_owned(),
11070 kind: crate::config::AgentKind::Command,
11071 model: None,
11072 command: vec!["true".to_owned()],
11073 extra_args: Vec::new(),
11074 env: BTreeMap::new(),
11075 prompt_delivery: None,
11076 }
11077 }
11078
11079 fn ids(xs: &[&str]) -> BTreeSet<String> {
11080 xs.iter().map(|s| (*s).to_owned()).collect()
11081 }
11082
11083 #[test]
11084 fn pick_successor_skips_an_agent_another_seat_holds() {
11085 let roster = [spec("a"), spec("b"), spec("c")];
11087 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11088 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11089 }
11090
11091 #[test]
11092 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11093 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11095 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11096 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11097 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11098 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11099 }
11100
11101 #[test]
11102 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11103 let roster = [spec("a"), spec("b")];
11104 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11105 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11106 let carried = ids(&["b"]);
11107 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11108 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11109 }
11110
11111 #[test]
11112 fn pick_successor_returns_none_without_any_untried_successor() {
11113 let roster = [spec("a"), spec("b")];
11114 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11115 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11116 let carried = ids(&["a"]);
11117 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11118 }
11119
11120 #[test]
11121 fn pick_successor_rescue_avoids_another_seats_occupant() {
11122 let roster = [spec("a"), spec("b"), spec("c")];
11124 let carried = ids(&["b", "c"]);
11125 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11126 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11127 }
11128
11129 #[test]
11130 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11131 let roster = [spec("a"), spec("b"), spec("c")];
11132 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11133 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11134 }
11135
11136 #[test]
11137 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11138 let roster = [spec("a"), spec("b"), spec("c")];
11139 let failed = ids(&["a", "b", "c"]);
11140 let mut tried = ids(&["b"]);
11142 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11143 assert_eq!(first.id, "a");
11144 tried.insert(first.id.clone());
11145 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11146 assert_eq!(second.id, "c");
11147 tried.insert(second.id.clone());
11148 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11149 }
11150
11151 #[test]
11152 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11153 let roster = [spec("a"), spec("b")];
11154 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11155 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11156 }
11157
11158 #[test]
11159 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11160 let roster = [spec("a"), spec("b"), spec("c")];
11161 let h = SeatHistory {
11162 failed: ids(&["a"]),
11163 last_ok: Some("b".to_owned()),
11164 last_fail: None,
11165 };
11166 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11167 let gone = SeatHistory {
11169 last_ok: Some("zzz".to_owned()),
11170 ..h.clone()
11171 };
11172 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11173 let failed = SeatHistory {
11174 failed: ids(&["a", "b"]),
11175 last_ok: Some("b".to_owned()),
11176 last_fail: None,
11177 };
11178 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11179 }
11180
11181 #[test]
11182 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11183 let first = SeatState::new("review-1", "alpha", 7);
11184 let next = handover_seat("review-1", "gamma", 7);
11185 assert_ne!(first.claude_session, next.claude_session);
11186 }
11187
11188 #[test]
11189 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11190 let mut state = state_with_summary("x", "y");
11191 let a = handover_seat("review-1", "beta", state.next_seat_seed());
11192 let b = handover_seat("review-1", "beta", state.next_seat_seed());
11193 assert_ne!(a.claude_session, b.claude_session);
11194 }
11195
11196 #[test]
11197 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11198 let roster = [spec("a"), spec("b")];
11199 let h = SeatHistory {
11200 failed: ids(&["a", "b"]),
11201 ..SeatHistory::default()
11202 };
11203 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11204 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11205 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11206 }
11207
11208 #[test]
11215 fn failure_signature_ignores_numbers_and_paths() {
11216 assert_eq!(
11217 failure_signature("exited with Some(2) and no usable output"),
11218 failure_signature("exited with Some(137) and no usable output")
11219 );
11220 assert_eq!(
11221 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11222 failure_signature("cannot open /var/x.txt: denied")
11223 );
11224 assert_ne!(failure_signature("boom"), failure_signature("bang"));
11225 }
11226
11227 #[test]
11228 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11229 let other = FailClass::Other("x".into());
11230 assert!(should_hand_over(None, &FailClass::Quota));
11231 assert!(should_hand_over(Some(&other), &FailClass::Quota));
11232 assert!(should_hand_over(
11233 Some(&FailClass::Timeout),
11234 &FailClass::Timeout
11235 ));
11236 assert!(should_hand_over(None, &other));
11237 assert!(!should_hand_over(Some(&other), &other));
11238 assert!(should_hand_over(
11239 Some(&other),
11240 &FailClass::Other("y".into())
11241 ));
11242 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11244 assert!(should_hand_over(Some(&FailClass::Quota), &other));
11245 }
11246
11247 #[test]
11248 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11249 let a = SeatState::new("judge-1", "alpha", 7);
11250 let b = handover_seat("judge-1", "beta", 7);
11251 assert_ne!(a.claude_session, b.claude_session);
11252 assert_eq!(b.turns, 0);
11253 }
11254
11255 #[test]
11256 fn a_timeout_is_classified_apart_from_other_failures() {
11257 assert_eq!(
11258 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11259 Some(FailClass::Timeout)
11260 );
11261 assert!(matches!(
11262 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11263 Some(FailClass::Other(_))
11264 ));
11265 }
11266
11267 #[test]
11268 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11269 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11270 let tried = BTreeSet::from(["beta".to_owned()]);
11271 let next = next_untried_in_roster(&roster, 1, &tried);
11274 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11275 }
11276
11277 #[test]
11278 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11279 let roster = vec![spec("alpha"), spec("beta")];
11280 let tried = BTreeSet::from(["beta".to_owned()]);
11281 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11285 }
11286
11287 #[test]
11288 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11289 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11290 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11291 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11295 }
11296
11297 #[test]
11298 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11299 let roster = vec![spec("a"), spec("a"), spec("b")];
11300 let tried = BTreeSet::from(["a".to_owned()]);
11301 let next = next_untried_in_roster(&roster, 0, &tried);
11302 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11303 }
11304
11305 #[test]
11306 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11307 let roster = vec![spec("a"), spec("b")];
11308 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11309 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11310 }
11311
11312 #[test]
11313 fn remove_if_empty_only_ever_takes_a_bare_directory() {
11314 let dir = tempfile::tempdir().unwrap();
11315 let bay = dir.path().join("ffff");
11316
11317 remove_if_empty(&bay);
11319 assert!(!bay.exists());
11320
11321 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11324 remove_if_empty(&bay);
11325 assert!(bay.exists(), "non-empty directory must survive");
11326
11327 std::fs::remove_dir(bay.join("cand-A")).unwrap();
11329 remove_if_empty(&bay);
11330 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11331 }
11332
11333 #[test]
11342 fn a_full_panel_that_found_nothing_is_clean() {
11343 assert!(round_is_clean(
11344 0,
11345 true,
11346 2,
11347 2,
11348 0,
11349 IncompleteReviewPolicy::Block
11350 ));
11351 }
11352
11353 #[test]
11354 fn a_missing_seat_is_never_clean_under_the_default_policy() {
11355 assert!(!round_is_clean(
11356 0,
11357 true,
11358 1,
11359 2,
11360 0,
11361 IncompleteReviewPolicy::Block
11362 ));
11363 }
11364
11365 #[test]
11366 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11367 assert!(!round_is_clean(
11368 1,
11369 true,
11370 1,
11371 2,
11372 0,
11373 IncompleteReviewPolicy::Warn
11374 ));
11375 }
11376
11377 #[test]
11378 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11379 assert!(round_is_clean(
11380 0,
11381 true,
11382 1,
11383 2,
11384 0,
11385 IncompleteReviewPolicy::Warn
11386 ));
11387 }
11388
11389 #[test]
11390 fn a_full_panel_with_an_open_finding_is_not_clean() {
11391 assert!(!round_is_clean(
11392 1,
11393 true,
11394 2,
11395 2,
11396 0,
11397 IncompleteReviewPolicy::Block
11398 ));
11399 }
11400
11401 #[test]
11402 fn a_full_panel_with_a_red_e2e_is_not_clean() {
11403 assert!(!round_is_clean(
11404 0,
11405 false,
11406 2,
11407 2,
11408 0,
11409 IncompleteReviewPolicy::Block
11410 ));
11411 }
11412
11413 #[test]
11420 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11421 assert!(round_is_clean(
11424 0,
11425 true,
11426 1,
11427 2,
11428 1,
11429 IncompleteReviewPolicy::Block
11430 ));
11431 }
11432
11433 #[test]
11434 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11435 assert!(!round_is_clean(
11438 0,
11439 true,
11440 1,
11441 2,
11442 0,
11443 IncompleteReviewPolicy::Block
11444 ));
11445 }
11446
11447 #[test]
11448 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11449 assert!(!round_is_clean(
11450 1,
11451 true,
11452 1,
11453 2,
11454 1,
11455 IncompleteReviewPolicy::Block
11456 ));
11457 assert!(!round_is_clean(
11458 0,
11459 false,
11460 1,
11461 2,
11462 1,
11463 IncompleteReviewPolicy::Block
11464 ));
11465 }
11466
11467 #[test]
11468 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11469 assert!(!round_is_clean(
11473 0,
11474 true,
11475 0,
11476 2,
11477 2,
11478 IncompleteReviewPolicy::Block
11479 ));
11480 }
11481
11482 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11483 CommandOutcome {
11484 command: "test".to_owned(),
11485 code,
11486 output_tail: String::new(),
11487 duration_ms: 0,
11488 resource_blocked,
11489 }
11490 }
11491
11492 #[test]
11493 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11494 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11495 assert!(
11496 !verify_inconclusive(&[outcome(Some(1), false)]),
11497 "an ordinary failure is still evidence about the patch"
11498 );
11499 assert!(verify_inconclusive(&[outcome(None, true)]));
11500 assert!(
11501 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11502 "one inconclusive outcome taints the whole batch"
11503 );
11504 assert!(!verify_inconclusive(&[]));
11505 }
11506
11507 #[tokio::test]
11508 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11509 let calls = std::sync::atomic::AtomicUsize::new(0);
11513 let started = Instant::now();
11514 wait_for_pids_with(
11515 &[123],
11516 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11517 Duration::from_millis(5),
11518 Duration::from_secs(5),
11519 )
11520 .await;
11521 assert!(
11522 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11523 "must keep checking rather than deciding on the first answer"
11524 );
11525 assert!(
11526 started.elapsed() < Duration::from_secs(1),
11527 "must return the moment it is confirmed dead, not wait out the ceiling"
11528 );
11529 }
11530
11531 #[tokio::test]
11532 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11533 let started = Instant::now();
11534 wait_for_pids_with(
11535 &[123],
11536 |_| true, Duration::from_millis(5),
11538 Duration::from_millis(30),
11539 )
11540 .await;
11541 let elapsed = started.elapsed();
11542 assert!(
11543 elapsed >= Duration::from_millis(30),
11544 "must not give up before its own ceiling: {elapsed:?}"
11545 );
11546 assert!(
11547 elapsed < Duration::from_secs(1),
11548 "must not wait past its own ceiling either: {elapsed:?}"
11549 );
11550 }
11551
11552 #[tokio::test]
11553 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11554 let started = Instant::now();
11555 wait_for_pids_with(
11556 &[],
11557 |_| true,
11558 Duration::from_secs(5),
11559 Duration::from_secs(5),
11560 )
11561 .await;
11562 assert!(
11563 started.elapsed() < Duration::from_millis(200),
11564 "an empty pid list has nothing to confirm"
11565 );
11566 }
11567
11568 fn review_round(
11574 clean: bool,
11575 blocking: usize,
11576 answered: usize,
11577 expected: usize,
11578 progressed: bool,
11579 e2e_ok: bool,
11580 ) -> ReviewRound {
11581 ReviewRound {
11582 round: 1,
11583 head: "h".to_owned(),
11584 verified_head: None,
11585 verified_at: None,
11586 reviews: Vec::new(),
11587 e2e: vec![CommandOutcome {
11588 command: "test".to_owned(),
11589 code: Some(if e2e_ok { 0 } else { 1 }),
11590 output_tail: String::new(),
11591 duration_ms: 0,
11592 resource_blocked: false,
11593 }],
11594 verify_retried: false,
11595 e2e_deferred: false,
11596 e2e_defer_reason: None,
11597 fix: None,
11598 blocking,
11599 answered,
11600 expected,
11601 clean,
11602 progressed,
11603 vote_split: false,
11604 reconsideration: Vec::new(),
11605 verdict: None,
11606 }
11607 }
11608
11609 #[test]
11610 fn review_conclusion_is_none_when_nothing_has_run() {
11611 assert_eq!(review_conclusion(&[], 3), None);
11612 }
11613
11614 #[test]
11615 fn review_conclusion_is_none_while_rounds_remain() {
11616 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11617 assert_eq!(review_conclusion(&rounds, 3), None);
11618 }
11619
11620 #[test]
11621 fn review_conclusion_is_gating_once_a_round_is_clean() {
11622 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11623 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11624 }
11625
11626 #[test]
11627 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11628 let rounds = vec![
11629 review_round(false, 1, 2, 2, true, true),
11630 review_round(false, 1, 2, 2, true, true),
11631 ];
11632 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11633 }
11634
11635 #[test]
11636 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11637 let rounds = vec![
11638 review_round(false, 1, 2, 2, true, true),
11639 review_round(false, 1, 2, 2, true, false),
11640 ];
11641 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11642 }
11643
11644 #[test]
11645 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11646 let mut blocked = review_round(false, 1, 2, 2, true, false);
11653 blocked.e2e[0].resource_blocked = true;
11654 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11655 assert_eq!(review_conclusion(&rounds, 2), None);
11656 }
11657
11658 #[test]
11659 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11660 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11662 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11663 }
11664
11665 #[test]
11666 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11667 let rounds = vec![
11668 review_round(false, 1, 2, 2, false, true),
11669 review_round(false, 1, 2, 2, false, true),
11670 ];
11671 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11672 }
11673
11674 fn secs(n: u64) -> Duration {
11675 Duration::from_secs(n)
11676 }
11677
11678 #[tokio::test]
11681 async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11682 ask_test_home();
11683 let tmp = tempfile::tempdir().unwrap();
11684 let repo = tmp.path().join("repo");
11685 std::fs::create_dir_all(&repo).unwrap();
11686 init_repo(&repo);
11687 let g = |args: &[&str]| {
11688 let out = std::process::Command::new("git")
11689 .args(args)
11690 .current_dir(&repo)
11691 .quiet()
11692 .output()
11693 .unwrap();
11694 assert!(
11695 out.status.success(),
11696 "{}",
11697 String::from_utf8_lossy(&out.stderr)
11698 );
11699 };
11700 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11701 g(&["checkout", "-b", "historical"]);
11702 std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11703 g(&["add", "."]);
11704 g(&["commit", "-m", "old alternative"]);
11705 let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11706 g(&["checkout", "-b", "magi/task-selected", &base]);
11707 std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11708 g(&["add", "."]);
11709 g(&["commit", "-m", "measurement infrastructure"]);
11710 let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11711 std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11712 g(&["add", "."]);
11713 g(&["commit", "-m", "dependent implementation"]);
11714 let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11715 g(&["checkout", "main"]);
11716 std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11717 g(&["add", "."]);
11718 g(&["commit", "-m", "unrelated base change"]);
11719 let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11720 let origin = tmp.path().join("origin.git");
11721 g(&[
11722 "clone",
11723 "--bare",
11724 repo.to_str().unwrap(),
11725 origin.to_str().unwrap(),
11726 ]);
11727 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11728 g(&["fetch", "origin"]);
11729 let selection = crate::pr_recovery::Selection {
11730 url: "https://example.test/project/pull/1".into(),
11731 branch: "magi/task-selected".into(),
11732 head: head.clone(),
11733 instruction: "preserve measured behavior".into(),
11734 approved_at: jiff::Timestamp::now(),
11735 };
11736 let mut template = runner_at(RunStatus::Reviewing);
11737 template.state.repo = repo.clone();
11738 template.state.base_commit = current.clone();
11739 template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11740 template.state.origin = Some(crate::run::Origin::queue("selected"));
11741 template.state.pr_head = Some(selection.branch.clone());
11742 template.state.pr_recovery = Some(selection.clone());
11743 let mut runner =
11744 Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11745 .await
11746 .unwrap();
11747 let wt = runner.state.winner().unwrap().worktree.clone();
11748 assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11749 assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11750 assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11751 assert!(runner.state.seeds.is_empty());
11752 assert_eq!(runner.state.instruction, "preserve measured behavior");
11753 runner.sync_to_base().await.unwrap();
11754 assert_eq!(
11755 std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11756 "prerequisite"
11757 );
11758 assert_eq!(
11759 std::fs::read_to_string(wt.join("implementation")).unwrap(),
11760 "measured implementation"
11761 );
11762 assert_eq!(
11763 std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11764 "current main change"
11765 );
11766 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11767 assert_eq!(
11768 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11769 head
11770 );
11771
11772 let mut partial = runner_at(RunStatus::Reviewing);
11774 partial.state.repo = repo.clone();
11775 partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11776 partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11777 run: Some("exit 1".into()),
11778 ..Default::default()
11779 }];
11780 let selection = runner.state.pr_recovery.clone().unwrap();
11781 partial.state.pr_recovery = Some(selection.clone());
11782 partial.state.pr_head = Some(selection.branch.clone());
11783 partial.state.origin = Some(crate::run::Origin::queue("selected"));
11784 let id = partial.state.id.clone();
11785 let error = match Runner::open_selected(
11786 &repo,
11787 partial.state,
11788 partial.roles,
11789 1,
11790 runner.state.base_commit.clone(),
11791 selection,
11792 )
11793 .await
11794 {
11795 Ok(_) => panic!("setup must fail"),
11796 Err(e) => e,
11797 };
11798 assert_eq!(
11799 error
11800 .downcast_ref::<crate::pr_recovery::StartupFailed>()
11801 .unwrap()
11802 .run,
11803 id
11804 );
11805 let failed = RunState::load(&id).unwrap();
11806 assert_eq!(failed.status, RunStatus::Blocked);
11807 assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11808 assert!(Runner::resume(&id).is_err());
11809 runner.state.status = RunStatus::Ready;
11810 runner.state.driver_exited = true;
11811 runner.state.save().unwrap();
11812 let mut retry = runner_at(RunStatus::Reviewing);
11813 retry.state.repo = repo.clone();
11814 retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11815 let mut reselected = runner.state.pr_recovery.clone().unwrap();
11816 reselected.approved_at = jiff::Timestamp::now();
11817 retry.state.origin = Some(crate::run::Origin::queue("selected"));
11818 retry.state.pr_head = Some(reselected.branch.clone());
11819 retry.state.pr_recovery = Some(reselected.clone());
11820 crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11821 .await
11822 .unwrap();
11823 let restarted = Runner::open_selected(
11824 &repo,
11825 retry.state,
11826 retry.roles,
11827 1,
11828 runner.state.base_commit.clone(),
11829 reselected,
11830 )
11831 .await
11832 .unwrap();
11833 assert_eq!(
11834 git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
11835 .await
11836 .unwrap(),
11837 head
11838 );
11839 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11840 assert_eq!(
11841 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11842 head
11843 );
11844 }
11845
11846 #[tokio::test]
11847 async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
11848 ask_test_home();
11849 let tmp = tempfile::tempdir().unwrap();
11850 let repo = tmp.path().join("repo");
11851 std::fs::create_dir_all(&repo).unwrap();
11852 init_repo(&repo);
11853 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11854 git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
11855 std::fs::write(repo.join("referenced"), "required commit").unwrap();
11856 git::git(&repo, &["add", "."]).await.unwrap();
11857 git::git(&repo, &["commit", "-m", "required seed"])
11858 .await
11859 .unwrap();
11860 let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
11861 git::git(&repo, &["checkout", "main"]).await.unwrap();
11862 let mut runner = runner_at(RunStatus::Prep);
11863 runner.state.repo = repo.clone();
11864 runner.state.base_commit = base.clone();
11865 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
11866 runner.state.config.graph.advise = false;
11867 runner.state.config.blind.commit_msg_hook = false;
11868 runner.state.seeds = vec![refs::Seed {
11869 token: seed.clone(),
11870 sha: seed.clone(),
11871 kind: refs::SeedKind::Unmerged,
11872 branch: false,
11873 detail: String::new(),
11874 }];
11875 runner.roles.implementers = vec![spec("one"), spec("two")];
11876 runner.roles.judges = vec![spec("judge")];
11877 let label = blind::assign_labels(2, runner.state.seed)[0];
11878 let branch = runner.state.branch_for(label);
11879 let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
11880 git::worktree_add_branch(&repo, &worktree, &branch, &base)
11881 .await
11882 .unwrap();
11883 runner.state.candidates.push(Candidate {
11884 index: 0,
11885 label,
11886 agent: "one".into(),
11887 branch,
11888 worktree,
11889 summary: String::new(),
11890 stat: String::new(),
11891 files: 0,
11892 commits: 0,
11893 empty: false,
11894 failed: None,
11895 verified_noop: None,
11896 duration_ms: 0,
11897 folded: false,
11898 });
11899 runner.state.save().unwrap();
11900 runner.state = RunState::load(&runner.state.id).unwrap();
11902 assert_eq!(runner.state.prep_complete, Some(false));
11903 runner.prep().await.unwrap();
11904 assert_eq!(runner.state.prep_complete, Some(true));
11905 assert_eq!(runner.state.candidates.len(), 2);
11906 for candidate in &runner.state.candidates {
11907 assert_eq!(
11908 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11909 git::tree_of(&repo, &seed).await.unwrap()
11910 );
11911 assert_eq!(
11912 std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
11913 "required commit"
11914 );
11915 }
11916 assert!(runner.state.worktree_root().join("judge-1").exists());
11917 let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
11918 runner.prep().await.unwrap();
11919 assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
11920
11921 runner.state.prep_complete = Some(false);
11922 runner.state.config.worktree.setup = vec![crate::config::SetupStep {
11923 run: Some(format!(
11924 "test -f '{}'",
11925 tmp.path().join("dependency-ready").display()
11926 )),
11927 ..Default::default()
11928 }];
11929 assert!(runner.prep().await.is_err());
11930 assert!(runner.state.prep_failure.is_some());
11931 std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
11932 runner.state = RunState::load(&runner.state.id).unwrap();
11933 assert!(
11934 runner.prep().await.is_err(),
11935 "correcting the cause alone must not auto-retry"
11936 );
11937 let failed_at = runner.state.events.last().unwrap().at;
11938 assert!(
11939 runner.retry_preparation(failed_at).is_err(),
11940 "old approvals cannot unlock the hold"
11941 );
11942 let mut task = crate::queue::Task::new(
11943 "retry".into(),
11944 "retry setup".into(),
11945 repo.clone(),
11946 crate::queue::Source::Human,
11947 );
11948 task.start(runner.state.id.clone());
11949 task.hold_machine(Some("setup failed".into()));
11950 task.release_operator("cli", None);
11951 runner
11952 .retry_preparation(task.resumption.as_ref().unwrap().at)
11953 .unwrap();
11954 runner.prep().await.unwrap();
11955 assert!(runner.state.prep_failure.is_none());
11956 assert_eq!(runner.state.prep_complete, Some(true));
11957 assert_eq!(runner.state.candidates.len(), 2);
11958 assert!(runner.state.worktree_root().join("judge-1").exists());
11959 for candidate in &runner.state.candidates {
11960 assert_eq!(
11961 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11962 git::tree_of(&repo, &seed).await.unwrap()
11963 );
11964 }
11965 }
11966
11967 async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
11968 let mut heads = Vec::new();
11969 for candidate in candidates {
11970 heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
11971 }
11972 heads
11973 }
11974
11975 fn init_repo(dir: &Path) {
11976 let run = |args: &[&str]| {
11977 let out = std::process::Command::new("git")
11978 .args(args)
11979 .current_dir(dir)
11980 .quiet()
11981 .output()
11982 .expect("spawn git");
11983 assert!(
11984 out.status.success(),
11985 "git {args:?} failed: {}",
11986 String::from_utf8_lossy(&out.stderr)
11987 );
11988 };
11989 run(&["init", "-b", "main"]);
11990 run(&["config", "user.name", "magi test"]);
11991 run(&["config", "user.email", "magi@example.com"]);
11992 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11993 run(&["add", "-A"]);
11994 run(&["commit", "-m", "init"]);
11995 }
11996
11997 fn ask_test_home() {
12005 crate::run::pin_test_home();
12006 }
12007
12008 fn runner_at(status: RunStatus) -> Runner {
12011 let mut state = RunState::new(
12012 PathBuf::from("/nonexistent/repo"),
12013 "main".to_owned(),
12014 "deadbeef".to_owned(),
12015 "task".to_owned(),
12016 Config::default(),
12017 );
12018 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12021 state.id = format!(
12022 "20000101-000000-{:04x}",
12023 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
12024 );
12025 state.status = status;
12026 Runner {
12027 state,
12028 roles: ResolvedRoles {
12029 implementers: Vec::new(),
12030 judges: Vec::new(),
12031 reviewers: Vec::new(),
12032 fixer: None,
12033 conductor: conductor(),
12034 implementer_roster: Vec::new(),
12035 judge_roster: Vec::new(),
12036 reviewer_roster: Vec::new(),
12037 },
12038 sem: Arc::new(Semaphore::new(1)),
12039 pause: Pause::new(),
12040 interrupt: Pause::new(),
12041 }
12042 }
12043
12044 fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
12045 crate::run::MergeRepair {
12046 question: "repair-question".into(),
12047 head: "old-head".into(),
12048 pr: "https://github.com/o/r/pull/42".into(),
12049 reviews: vec![review_round(false, 1, 1, 1, false, true)],
12050 gate: vec![outcome(Some(0), false)],
12051 gate_head: Some("old-head".into()),
12052 gate_fixes: Vec::new(),
12053 pre_gate: Vec::new(),
12054 phase,
12055 failure: None,
12056 }
12057 }
12058
12059 #[tokio::test]
12060 async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
12061 crate::run::pin_test_home();
12062 let mut runner = runner_at(RunStatus::Landing);
12063 runner.state.merge_repairs.push(merge_repair_request(
12064 crate::run::MergeRepairPhase::Requested,
12065 ));
12066 runner.state.save().unwrap();
12067 let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
12068 assert!(
12069 runner
12070 .execute()
12071 .await
12072 .unwrap_err()
12073 .to_string()
12074 .contains("already running")
12075 );
12076 assert_eq!(
12077 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12078 crate::run::MergeRepairPhase::Requested
12079 );
12080 drop(claim);
12081 let mut disk = runner.state.clone();
12082 disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12083 disk.save().unwrap();
12084 assert!(
12085 runner
12086 .execute()
12087 .await
12088 .unwrap_err()
12089 .to_string()
12090 .contains("decision changed")
12091 );
12092 assert_eq!(
12093 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12094 crate::run::MergeRepairPhase::Complete
12095 );
12096 }
12097
12098 #[test]
12099 fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12100 crate::run::pin_test_home();
12101 let mut runner = runner_at(RunStatus::Landing);
12102 let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12103 runner.state.reviews = request.reviews.clone();
12104 runner.state.gate = request.gate.clone();
12105 runner.state.gate_ran = true;
12106 runner.state.land_approval = Some(crate::run::LandApproval {
12107 question: "old".into(),
12108 head: "old-head".into(),
12109 });
12110 runner.state.merge_repairs.push(request);
12111 runner.begin_merge_repair_review(0).unwrap();
12112 assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12113 assert!(!runner.state.gate_status().ok());
12114 assert!(runner.state.land_approval.is_none());
12115 let disk = RunState::load(&runner.state.id).unwrap();
12116 assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12117 assert!(disk.merge_repairs[0].gate[0].ok());
12118 assert_eq!(
12119 disk.merge_repairs[0].phase,
12120 crate::run::MergeRepairPhase::Reviewing
12121 );
12122 assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12123 assert!(disk.reviews.is_empty());
12124 }
12125
12126 #[tokio::test]
12127 async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12128 crate::run::pin_test_home();
12129 for phase in [
12130 crate::run::MergeRepairPhase::Fixing,
12131 crate::run::MergeRepairPhase::Requested,
12132 ] {
12133 let mut runner = runner_at(RunStatus::Landing);
12134 runner.state.config.graph.land_rounds = 0;
12135 runner.state.merge_repairs.push(merge_repair_request(phase));
12136 runner.state.save().unwrap();
12137 assert!(!runner.repair_merge_review().await.unwrap());
12138 assert_eq!(runner.state.status, RunStatus::Blocked);
12139 assert_eq!(
12140 runner.state.merge_repairs[0].phase,
12141 crate::run::MergeRepairPhase::Failed
12142 );
12143 let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12144 assert!(
12145 reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12146 "interrupted"
12147 } else {
12148 "budget exhausted"
12149 })
12150 );
12151 assert!(runner.state.merge.is_none());
12152 }
12153 }
12154
12155 #[test]
12159 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12160 crate::run::pin_test_home();
12161 let mut runner = runner_at(RunStatus::Implementing);
12162 let interrupt = Pause::new();
12163 runner.watch_interrupt(interrupt.clone());
12164
12165 interrupt.park_because("task a1b2 asked to run first");
12166
12167 assert!(runner.park_here().expect("park_here"));
12168 assert!(runner.state.parked);
12169 let last = runner.state.events.last().expect("a park event");
12170 assert_eq!(last.node, "park");
12171 assert!(
12172 last.message.contains("task a1b2 asked to run first"),
12173 "expected the interrupt reason in {:?}",
12174 last.message
12175 );
12176 }
12177
12178 #[test]
12181 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12182 crate::run::pin_test_home();
12183 let mut runner = runner_at(RunStatus::Implementing);
12184 runner.state.begin_pass();
12185 let shutdown = Pause::new();
12186 runner.on_pause(shutdown.clone());
12187 let upgrade = ParkCause::Upgrade {
12188 from: "0.121.0".to_owned(),
12189 to: Some("0.121.1".to_owned()),
12190 };
12191 shutdown.park_for(upgrade.clone());
12192 shutdown.park_for(ParkCause::Operator);
12193
12194 assert!(runner.park_here().expect("park_here"));
12195 assert_eq!(
12196 runner.state.passes[0].end,
12197 Some(crate::run::PassEnd::Parked { cause: upgrade })
12198 );
12199 let last = runner.state.events.last().expect("a park event");
12200 assert!(last.message.contains("0.121.0"), "{}", last.message);
12201 }
12202
12203 #[test]
12211 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12212 crate::run::pin_test_home();
12213 let mut runner = runner_at(RunStatus::Implementing);
12214 let shutdown = Pause::new();
12215 runner.on_pause(shutdown.clone());
12216 let interrupt = Pause::new();
12217 runner.watch_interrupt(interrupt.clone());
12218
12219 assert!(!runner.park_here().expect("park_here"));
12221 assert!(!runner.state.parked);
12222
12223 interrupt.park_because("test");
12225 assert!(!shutdown.parked());
12226 assert!(runner.park_here().expect("park_here"));
12227 }
12228
12229 #[tokio::test]
12243 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12244 crate::run::pin_test_home();
12245 let mut runner = runner_at(RunStatus::Implementing);
12246 let interrupt = Pause::new();
12247 runner.watch_interrupt(interrupt.clone());
12248
12249 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12250 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12251
12252 let node = async move {
12256 started_tx.send(()).expect("send started");
12257 finish_rx.await.expect("recv finish");
12258 "node finished"
12259 };
12260
12261 let interrupter = async move {
12262 started_rx.await.expect("recv started");
12263 interrupt.park_because("higher-priority task waiting");
12265 tokio::task::yield_now().await;
12269 finish_tx.send(()).expect("send finish");
12270 };
12271
12272 let (node_result, ()) = tokio::join!(node, interrupter);
12273 assert_eq!(
12274 node_result, "node finished",
12275 "the in-flight call ran to completion"
12276 );
12277
12278 assert!(runner.park_here().expect("park_here"));
12281 assert!(runner.state.parked);
12282 }
12283
12284 #[test]
12290 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12291 crate::run::pin_test_home();
12292 let mut runner = runner_at(RunStatus::Judging);
12293 runner.state.config.agents = vec![conductor()];
12297 runner.state.candidates = vec![Candidate {
12298 index: 0,
12299 label: 'A',
12300 agent: "alpha".to_owned(),
12301 branch: "magi/x/A".to_owned(),
12302 worktree: PathBuf::from("/nonexistent/worktree"),
12303 summary: "did the thing".to_owned(),
12304 stat: "1 file changed".to_owned(),
12305 files: 1,
12306 commits: 1,
12307 empty: false,
12308 failed: None,
12309 verified_noop: None,
12310 duration_ms: 1234,
12311 folded: false,
12312 }];
12313 let run_id = runner.state.id.clone();
12314
12315 let interrupt = Pause::new();
12316 runner.watch_interrupt(interrupt.clone());
12317 interrupt.park_because("task c3d4 asked to run first");
12318 assert!(runner.park_here().expect("park_here"));
12319
12320 let resumed = Runner::resume(&run_id).expect("resume");
12321 assert_eq!(resumed.state.candidates.len(), 1);
12322 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12323 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12324 assert_eq!(resumed.state.status, runner.state.status);
12325 assert!(
12326 resumed.state.parked,
12327 "still parked until `execute` actually walks the graph again"
12328 );
12329 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12330 }
12331
12332 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12334 let mut q = ask::Question::new(
12335 run.to_owned(),
12336 "implement".to_owned(),
12337 "impl-A".to_owned(),
12338 "Which storage backend should the cache use?".to_owned(),
12339 String::new(),
12340 vec!["SQLite".to_owned(), "Redis".to_owned()],
12341 );
12342 store.put(&mut q).unwrap();
12343 q
12344 }
12345
12346 #[test]
12347 fn a_failed_runs_open_question_is_abandoned() {
12348 ask_test_home();
12349 let store = ask::Questions::open();
12350 let mut runner = runner_at(RunStatus::Failed);
12351 let run = runner.state.id.clone();
12352 let q = ask_open_question(&store, &run);
12353
12354 runner.settle_questions();
12355
12356 let back = store.get(&q.id).unwrap();
12357 assert!(
12358 !back.status.open(),
12359 "the seat that asked died with the run; nobody is left to read an answer"
12360 );
12361 assert!(
12362 back.detail.contains(&run) && back.detail.contains("failed"),
12363 "the reason names what the run became, not just that it is gone: {}",
12364 back.detail
12365 );
12366 }
12367
12368 #[test]
12369 fn a_merged_runs_open_question_is_abandoned_too() {
12370 ask_test_home();
12371 let store = ask::Questions::open();
12372 for status in [RunStatus::Merged, RunStatus::Ready] {
12375 let mut runner = runner_at(status);
12376 let run = runner.state.id.clone();
12377 let q = ask_open_question(&store, &run);
12378
12379 runner.settle_questions();
12380
12381 let back = store.get(&q.id).unwrap();
12382 assert!(
12383 !back.status.open(),
12384 "{status:?} run's question must not outlive the run"
12385 );
12386 }
12387 }
12388
12389 #[test]
12390 fn a_still_resumable_runs_open_question_is_left_alone() {
12391 ask_test_home();
12392 let store = ask::Questions::open();
12393 for status in [RunStatus::Blocked, RunStatus::Stalled] {
12399 let mut runner = runner_at(status);
12400 let run = runner.state.id.clone();
12401 let q = ask_open_question(&store, &run);
12402
12403 runner.settle_questions();
12404
12405 let back = store.get(&q.id).unwrap();
12406 assert!(
12407 back.status.open(),
12408 "{status:?} is still alive; the question must still be waiting"
12409 );
12410 }
12411 }
12412
12413 #[test]
12414 fn settle_questions_never_touches_an_already_answered_question() {
12415 ask_test_home();
12416 let store = ask::Questions::open();
12417 let mut runner = runner_at(RunStatus::Failed);
12418 let run = runner.state.id.clone();
12419 let mut q = ask_open_question(&store, &run);
12420 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12421 .unwrap();
12422 store.put(&mut q).unwrap();
12423
12424 runner.settle_questions();
12429 runner.settle_questions();
12430
12431 let back = store.get(&q.id).unwrap();
12432 assert_eq!(
12433 back.status,
12434 ask::QuestionStatus::Answered,
12435 "a real answer is a decision on record, never overwritten by a sweep"
12436 );
12437 }
12438
12439 #[tokio::test]
12450 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12451 crate::run::pin_test_home();
12452 let tmp = tempfile::tempdir().expect("tempdir");
12453 let repo = tmp.path().join("repo");
12454 std::fs::create_dir_all(&repo).unwrap();
12455 init_repo(&repo);
12456
12457 let mut config = Config::default();
12458 config.graph.worktree_root = Some(tmp.path().join("wt"));
12459
12460 let mut state = RunState::new(
12461 repo.clone(),
12462 "main".to_owned(),
12463 "deadbeef".to_owned(),
12464 "task".to_owned(),
12465 config,
12466 );
12467 let root = state.worktree_root();
12468 let wt_a = root.join("cand-A");
12469 let wt_b = root.join("cand-B");
12470 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12471 .await
12472 .expect("worktree A");
12473 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12474 .await
12475 .expect("worktree B");
12476
12477 state.candidates = vec![
12478 Candidate {
12479 index: 0,
12480 label: 'A',
12481 agent: "alpha".to_owned(),
12482 branch: "magi/x/A".to_owned(),
12483 worktree: wt_a.clone(),
12484 summary: String::new(),
12485 stat: String::new(),
12486 files: 0,
12487 commits: 0,
12488 empty: false,
12489 failed: None,
12490 verified_noop: None,
12491 duration_ms: 0,
12492 folded: false,
12493 },
12494 Candidate {
12495 index: 1,
12496 label: 'B',
12497 agent: "beta".to_owned(),
12498 branch: "magi/x/B".to_owned(),
12499 worktree: wt_b.clone(),
12500 summary: String::new(),
12501 stat: String::new(),
12502 files: 0,
12503 commits: 0,
12504 empty: false,
12505 failed: None,
12506 verified_noop: None,
12507 duration_ms: 0,
12508 folded: false,
12509 },
12510 ];
12511 state.tally = Some(Tally {
12512 first_choice: BTreeMap::from([('A', 1)]),
12513 borda: BTreeMap::new(),
12514 winner: 'A',
12515 rankings: 1,
12516 unanimous_initial: true,
12517 deliberated: false,
12518 changed_votes: 0,
12519 unanimous_final: true,
12520 tie_break: None,
12521 judges: 1,
12522 present: 1,
12523 quorum: 1,
12524 met_quorum: true,
12525 uncontested: None,
12526 });
12527 state.status = RunStatus::Ready;
12528
12529 fold_run(&mut state, false, &crate::run::home())
12530 .await
12531 .expect("fold_run");
12532
12533 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12534 assert!(
12535 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12536 "the unmerged winner's branch survives"
12537 );
12538 assert!(
12539 !state.candidates[0].folded,
12540 "the winner is not marked folded"
12541 );
12542
12543 assert!(!wt_b.exists(), "the loser's worktree is removed");
12544 assert!(
12545 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12546 "the loser's branch is removed"
12547 );
12548 assert!(state.candidates[1].folded, "the loser is marked folded");
12549 }
12550
12551 #[tokio::test]
12554 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12555 let tmp = tempfile::tempdir().expect("tempdir");
12556 let repo = tmp.path().join("repo");
12557 std::fs::create_dir_all(&repo).unwrap();
12558 init_repo(&repo);
12559 let home = tmp.path().join("home");
12560
12561 let mut config = Config::default();
12562 config.graph.worktree_root = Some(tmp.path().join("wt"));
12563 let mut state = RunState::new(
12564 repo.clone(),
12565 "main".to_owned(),
12566 "deadbeef".to_owned(),
12567 "task".to_owned(),
12568 config,
12569 );
12570 git::git(&repo, &["branch", "magi/x/A", "main"])
12572 .await
12573 .expect("branch");
12574 state.candidates = vec![Candidate {
12575 index: 0,
12576 label: 'A',
12577 agent: "alpha".to_owned(),
12578 branch: "magi/x/A".to_owned(),
12579 worktree: state.worktree_root().join("cand-A"),
12580 summary: String::new(),
12581 stat: String::new(),
12582 files: 0,
12583 commits: 0,
12584 empty: false,
12585 failed: None,
12586 verified_noop: None,
12587 duration_ms: 0,
12588 folded: true,
12589 }];
12590 state.released_to = Some("20260901-000000-new1".to_owned());
12591 state.released_branches = vec!["magi/x/A".to_owned()];
12592
12593 fold_run(&mut state, true, &home).await.expect("fold_run");
12594
12595 assert!(
12596 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12597 "the handed-over branch survives a fold"
12598 );
12599 }
12600
12601 #[tokio::test]
12605 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12606 let tmp = tempfile::tempdir().expect("tempdir");
12607 let repo = tmp.path().join("repo");
12608 std::fs::create_dir_all(&repo).unwrap();
12609 init_repo(&repo);
12610 let run = |args: &[&str]| {
12611 let out = std::process::Command::new("git")
12612 .quiet()
12613 .args(args)
12614 .current_dir(&repo)
12615 .output()
12616 .expect("spawn git");
12617 assert!(out.status.success(), "git {args:?}");
12618 };
12619 run(&["branch", "magi/x/A"]);
12620 run(&["checkout", "-q", "-b", "magi/x/B"]);
12621 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12622 run(&["add", "-A"]);
12623 run(&["commit", "-q", "-m", "work"]);
12624 run(&["checkout", "-q", "main"]);
12625
12626 let probe = RunState::new(
12630 repo.clone(),
12631 "main".to_owned(),
12632 "deadbeef".to_owned(),
12633 "task".to_owned(),
12634 Config::default(),
12635 );
12636 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12637 let bare = tmp.path().join("origin.git");
12638 let out = std::process::Command::new("git")
12639 .quiet()
12640 .args(["init", "-q", "--bare"])
12641 .arg(&bare)
12642 .output()
12643 .expect("spawn git");
12644 assert!(out.status.success(), "git init --bare");
12645 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12646 run(&["push", "-q", "origin", "main"]);
12647
12648 let mut state = RunState::new(
12649 repo.clone(),
12650 "main".to_owned(),
12651 "deadbeef".to_owned(),
12652 "task".to_owned(),
12653 Config::default(),
12654 );
12655 state.seeds = vec![refs::Seed {
12656 token: "magi/27b2/A".to_owned(),
12657 kind: refs::SeedKind::Unresolved,
12658 sha: String::new(),
12659 branch: true,
12660 detail: "no branch or commit named magi/27b2/A".to_owned(),
12661 }];
12662
12663 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12664 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12665 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12666 let detail = empty_candidate_detail(&state, "main");
12667 assert!(detail.starts_with("empty candidate"), "{detail}");
12668 assert!(detail.contains("magi/27b2/A"), "{detail}");
12669 }
12670
12671 #[tokio::test]
12680 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12681 let tmp = tempfile::tempdir().expect("tempdir");
12682 let repo = tmp.path().join("repo");
12683 std::fs::create_dir_all(&repo).unwrap();
12684 init_repo(&repo);
12685
12686 let mut config = Config::default();
12687 config.merge.mode = MergeMode::Local;
12688
12689 let mut state = RunState::new(
12690 repo.clone(),
12691 "main".to_owned(),
12692 "deadbeef".to_owned(),
12693 "task".to_owned(),
12694 config,
12695 );
12696 state.candidates = vec![Candidate {
12697 index: 0,
12698 label: 'A',
12699 agent: "alpha".to_owned(),
12700 branch: "does-not-exist".to_owned(),
12701 worktree: repo.clone(),
12702 summary: String::new(),
12703 stat: String::new(),
12704 files: 0,
12705 commits: 0,
12706 empty: false,
12707 failed: None,
12708 verified_noop: None,
12709 duration_ms: 0,
12710 folded: false,
12711 }];
12712 state.tally = Some(Tally {
12713 first_choice: BTreeMap::from([('A', 1)]),
12714 borda: BTreeMap::new(),
12715 winner: 'A',
12716 rankings: 1,
12717 unanimous_initial: true,
12718 deliberated: false,
12719 changed_votes: 0,
12720 unanimous_final: true,
12721 tie_break: None,
12722 judges: 0,
12723 present: 0,
12724 quorum: 0,
12725 met_quorum: true,
12726 uncontested: Some("only candidate A produced a change".to_owned()),
12727 });
12728 state.reviews = vec![ReviewRound {
12729 round: 1,
12730 head: "deadbeef".to_owned(),
12731 verified_head: None,
12732 verified_at: None,
12733 reviews: Vec::new(),
12734 e2e: Vec::new(),
12735 fix: None,
12736 blocking: 0,
12737 answered: 0,
12738 expected: 0,
12739 clean: true,
12740 verify_retried: false,
12741 e2e_deferred: false,
12742 e2e_defer_reason: None,
12743 progressed: false,
12744 vote_split: false,
12745 reconsideration: Vec::new(),
12746 verdict: None,
12747 }];
12748 state.gate = vec![CommandOutcome {
12749 command: "test".to_owned(),
12750 code: Some(0),
12751 output_tail: String::new(),
12752 duration_ms: 0,
12753 resource_blocked: false,
12754 }];
12755 state.gate_ran = true;
12756 state.status = RunStatus::Ready;
12761 state.merge = Some(MergeOutcome {
12762 mode: MergeMode::Local,
12763 ok: false,
12764 detail: "already concluded".to_owned(),
12765 empty: false,
12766 });
12767
12768 let mut runner = Runner {
12769 state,
12770 roles: ResolvedRoles {
12771 implementers: Vec::new(),
12772 judges: Vec::new(),
12773 reviewers: Vec::new(),
12774 fixer: None,
12775 conductor: conductor(),
12776 implementer_roster: Vec::new(),
12777 judge_roster: Vec::new(),
12778 reviewer_roster: Vec::new(),
12779 },
12780 sem: Arc::new(Semaphore::new(1)),
12781 pause: Pause::new(),
12782 interrupt: Pause::new(),
12783 };
12784
12785 runner.merge().await.expect("merge");
12786
12787 assert_eq!(
12788 runner.state.status,
12789 RunStatus::Ready,
12790 "a concluded run's status must not change on reentry"
12791 );
12792 assert_eq!(
12793 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12794 Some("already concluded"),
12795 "merge must not run again once the node already recorded an outcome"
12796 );
12797 }
12798
12799 #[tokio::test]
12808 async fn merge_refuses_a_gate_that_has_not_actually_run() {
12809 let tmp = tempfile::tempdir().expect("tempdir");
12810 let repo = tmp.path().join("repo");
12811 std::fs::create_dir_all(&repo).unwrap();
12812 init_repo(&repo);
12813
12814 let mut config = Config::default();
12815 config.merge.mode = MergeMode::Local;
12816
12817 let mut state = RunState::new(
12818 repo.clone(),
12819 "main".to_owned(),
12820 "deadbeef".to_owned(),
12821 "task".to_owned(),
12822 config,
12823 );
12824 state.candidates = vec![Candidate {
12825 index: 0,
12826 label: 'A',
12827 agent: "alpha".to_owned(),
12828 branch: "does-not-exist".to_owned(),
12829 worktree: repo.clone(),
12830 summary: String::new(),
12831 stat: String::new(),
12832 files: 0,
12833 commits: 0,
12834 empty: false,
12835 failed: None,
12836 verified_noop: None,
12837 duration_ms: 0,
12838 folded: false,
12839 }];
12840 state.tally = Some(Tally {
12841 first_choice: BTreeMap::from([('A', 1)]),
12842 borda: BTreeMap::new(),
12843 winner: 'A',
12844 rankings: 1,
12845 unanimous_initial: true,
12846 deliberated: false,
12847 changed_votes: 0,
12848 unanimous_final: true,
12849 tie_break: None,
12850 judges: 0,
12851 present: 0,
12852 quorum: 0,
12853 met_quorum: true,
12854 uncontested: Some("only candidate A produced a change".to_owned()),
12855 });
12856 state.reviews = vec![ReviewRound {
12857 round: 1,
12858 head: "deadbeef".to_owned(),
12859 verified_head: None,
12860 verified_at: None,
12861 reviews: Vec::new(),
12862 e2e: Vec::new(),
12863 fix: None,
12864 blocking: 0,
12865 answered: 0,
12866 expected: 0,
12867 clean: true,
12868 verify_retried: false,
12869 e2e_deferred: false,
12870 e2e_defer_reason: None,
12871 progressed: false,
12872 vote_split: false,
12873 reconsideration: Vec::new(),
12874 verdict: None,
12875 }];
12876 state.gate = Vec::new();
12878 state.gate_ran = false;
12879 state.status = RunStatus::Gating;
12880
12881 let mut runner = Runner {
12882 state,
12883 roles: ResolvedRoles {
12884 implementers: Vec::new(),
12885 judges: Vec::new(),
12886 reviewers: Vec::new(),
12887 fixer: None,
12888 conductor: conductor(),
12889 implementer_roster: Vec::new(),
12890 judge_roster: Vec::new(),
12891 reviewer_roster: Vec::new(),
12892 },
12893 sem: Arc::new(Semaphore::new(1)),
12894 pause: Pause::new(),
12895 interrupt: Pause::new(),
12896 };
12897
12898 runner.merge().await.expect("merge");
12899
12900 assert!(
12901 runner.state.merge.is_none(),
12902 "an empty gate must never be read as a passing one: {:?}",
12903 runner.state.merge
12904 );
12905 }
12906
12907 #[tokio::test]
12914 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12915 let tmp = tempfile::tempdir().expect("tempdir");
12916 let repo = tmp.path().join("repo");
12917 std::fs::create_dir_all(&repo).unwrap();
12918 init_repo(&repo);
12919
12920 let config = Config::default();
12922
12923 let mut state = RunState::new(
12924 repo.clone(),
12925 "main".to_owned(),
12926 "deadbeef".to_owned(),
12927 "task".to_owned(),
12928 config,
12929 );
12930 state.candidates = vec![Candidate {
12931 index: 0,
12932 label: 'A',
12933 agent: "alpha".to_owned(),
12934 branch: "does-not-exist".to_owned(),
12935 worktree: repo.clone(),
12936 summary: String::new(),
12937 stat: String::new(),
12938 files: 0,
12939 commits: 0,
12940 empty: false,
12941 failed: None,
12942 verified_noop: None,
12943 duration_ms: 0,
12944 folded: false,
12945 }];
12946 state.tally = Some(Tally {
12947 first_choice: BTreeMap::from([('A', 1)]),
12948 borda: BTreeMap::new(),
12949 winner: 'A',
12950 rankings: 1,
12951 unanimous_initial: true,
12952 deliberated: false,
12953 changed_votes: 0,
12954 unanimous_final: true,
12955 tie_break: None,
12956 judges: 0,
12957 present: 0,
12958 quorum: 0,
12959 met_quorum: true,
12960 uncontested: Some("only candidate A produced a change".to_owned()),
12961 });
12962 state.reviews = vec![ReviewRound {
12963 round: 1,
12964 head: "deadbeef".to_owned(),
12965 verified_head: None,
12966 verified_at: None,
12967 reviews: Vec::new(),
12968 e2e: Vec::new(),
12969 fix: None,
12970 blocking: 0,
12971 answered: 0,
12972 expected: 0,
12973 clean: true,
12974 verify_retried: false,
12975 e2e_deferred: false,
12976 e2e_defer_reason: None,
12977 progressed: false,
12978 vote_split: false,
12979 reconsideration: Vec::new(),
12980 verdict: None,
12981 }];
12982
12983 let mut runner = Runner {
12984 state,
12985 roles: ResolvedRoles {
12986 implementers: Vec::new(),
12987 judges: Vec::new(),
12988 reviewers: Vec::new(),
12989 fixer: None,
12990 conductor: conductor(),
12991 implementer_roster: Vec::new(),
12992 judge_roster: Vec::new(),
12993 reviewer_roster: Vec::new(),
12994 },
12995 sem: Arc::new(Semaphore::new(1)),
12996 pause: Pause::new(),
12997 interrupt: Pause::new(),
12998 };
12999
13000 runner.gate().await.expect("gate");
13001 assert!(
13002 runner.state.gate_ran,
13003 "zero configured commands is still a real attempt, not an unrun gate"
13004 );
13005 assert!(runner.state.gate.is_empty());
13006 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
13007 assert_ne!(
13008 runner.state.status,
13009 RunStatus::Blocked,
13010 "a gate with nothing to check must not read as failed"
13011 );
13012
13013 runner.merge().await.expect("merge");
13014 assert_eq!(
13015 runner.state.status,
13016 RunStatus::Ready,
13017 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
13018 );
13019 }
13020
13021 #[tokio::test]
13032 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
13033 crate::run::pin_test_home();
13034 let home = crate::run::home();
13035
13036 let tmp = tempfile::tempdir().expect("tempdir");
13037 let repo = tmp.path().join("repo");
13038 std::fs::create_dir_all(&repo).unwrap();
13039 init_repo(&repo);
13040 let cache_dir = tmp.path().join("target");
13043
13044 let mut config = Config::default();
13045 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
13046 config.graph.timeout_verify = Some(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
13416 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13417 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13418 .expect("no io error acquiring directly")
13419 {
13420 crate::cache::AcquireOutcome::Acquired(g) => g,
13421 crate::cache::AcquireOutcome::Busy(b) => {
13422 panic!("expected the direct acquire to win the lease first: {b:?}")
13423 }
13424 };
13425
13426 let mut state = RunState::new(
13427 repo.clone(),
13428 "main".to_owned(),
13429 head.clone(),
13430 "task".to_owned(),
13431 config,
13432 );
13433 state.candidates = vec![Candidate {
13434 index: 0,
13435 label: 'A',
13436 agent: "alpha".to_owned(),
13437 branch: "does-not-exist".to_owned(),
13438 worktree: repo.clone(),
13439 summary: String::new(),
13440 stat: String::new(),
13441 files: 0,
13442 commits: 0,
13443 empty: false,
13444 failed: None,
13445 verified_noop: None,
13446 duration_ms: 0,
13447 folded: false,
13448 }];
13449 state.tally = Some(Tally {
13450 first_choice: BTreeMap::from([('A', 1)]),
13451 borda: BTreeMap::new(),
13452 winner: 'A',
13453 rankings: 1,
13454 unanimous_initial: true,
13455 deliberated: false,
13456 changed_votes: 0,
13457 unanimous_final: true,
13458 tie_break: None,
13459 judges: 0,
13460 present: 0,
13461 quorum: 0,
13462 met_quorum: true,
13463 uncontested: Some("only candidate A produced a change".to_owned()),
13464 });
13465 state.reviews = vec![ReviewRound {
13469 round: 1,
13470 head: head.clone(),
13471 verified_head: None,
13472 verified_at: None,
13473 reviews: Vec::new(),
13474 e2e: Vec::new(),
13475 fix: None,
13476 blocking: 1,
13477 answered: 1,
13478 expected: 1,
13479 clean: false,
13480 verify_retried: false,
13481 e2e_deferred: true,
13482 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13483 progressed: false,
13484 vote_split: false,
13485 reconsideration: Vec::new(),
13486 verdict: None,
13487 }];
13488
13489 let mut runner = Runner {
13490 state,
13491 roles: ResolvedRoles {
13492 implementers: Vec::new(),
13493 judges: Vec::new(),
13494 reviewers: Vec::new(),
13495 fixer: None,
13496 conductor: conductor(),
13497 implementer_roster: Vec::new(),
13498 judge_roster: Vec::new(),
13499 reviewer_roster: Vec::new(),
13500 },
13501 sem: Arc::new(Semaphore::new(1)),
13502 pause: Pause::new(),
13503 interrupt: Pause::new(),
13504 };
13505
13506 let shell = runner.state.config.shell();
13507 runner
13508 .stop_reviewing("round budget spent", &shell, &repo)
13509 .await
13510 .expect("stop_reviewing");
13511
13512 let last = runner.state.reviews.last().expect("round record");
13513 assert_eq!(
13514 last.e2e_status(),
13515 E2eStatus::ResourceBlocked,
13516 "the shared cache is still held; the attempt must read as blocked, not deferred or \
13517 failed: {last:?}"
13518 );
13519 assert_eq!(
13520 last.verified_head.as_deref(),
13521 Some(head.as_str()),
13522 "which commit this attempt targeted is known even though nothing finished checking \
13523 it"
13524 );
13525 let first_attempt_at = last
13526 .verified_at
13527 .expect("when this attempt ran is known too");
13528 assert_ne!(
13529 runner.state.status,
13530 RunStatus::Blocked,
13531 "contention is evidence about the machine, not the patch — it must not settle the \
13532 run as blocked: {:?}",
13533 runner.state.status
13534 );
13535 assert!(
13536 !runner
13537 .state
13538 .events
13539 .iter()
13540 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13541 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13542 runner.state.events
13543 );
13544
13545 runner
13550 .stop_reviewing("round budget spent", &shell, &repo)
13551 .await
13552 .expect("stop_reviewing retry");
13553 assert_eq!(
13554 runner.state.reviews.len(),
13555 1,
13556 "no new round was started: {:?}",
13557 runner.state.reviews
13558 );
13559 let last = runner.state.reviews.last().expect("round record");
13560 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13561 assert!(
13562 last.verified_at.expect("still known") > first_attempt_at,
13563 "a second reentry must be a fresh attempt, not a stale copy of the first"
13564 );
13565 assert_ne!(runner.state.status, RunStatus::Blocked);
13566
13567 held.release();
13568 }
13569
13570 #[tokio::test]
13582 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13583 crate::run::pin_test_home();
13584 let home = crate::run::home();
13585
13586 let tmp = tempfile::tempdir().expect("tempdir");
13587 let repo = tmp.path().join("repo");
13588 std::fs::create_dir_all(&repo).unwrap();
13589 init_repo(&repo);
13590 let head = crate::git::rev_parse(&repo, "HEAD")
13591 .await
13592 .expect("rev-parse");
13593 let cache_dir = tmp.path().join("target");
13594
13595 let mut config = Config::default();
13596 config.verify.e2e = vec![format!(
13597 "CARGO_TARGET_DIR='{}' test -f README.md",
13598 cache_dir.display()
13599 )];
13600 config.graph.review_rounds = 1;
13601 config.graph.timeout_verify = Some(2);
13602
13603 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13604 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13605 .expect("no io error acquiring directly")
13606 {
13607 crate::cache::AcquireOutcome::Acquired(g) => g,
13608 crate::cache::AcquireOutcome::Busy(b) => {
13609 panic!("expected the direct acquire to win the lease first: {b:?}")
13610 }
13611 };
13612
13613 let mut state = RunState::new(
13614 repo.clone(),
13615 "main".to_owned(),
13616 head.clone(),
13617 "task".to_owned(),
13618 config,
13619 );
13620 state.candidates = vec![Candidate {
13621 index: 0,
13622 label: 'A',
13623 agent: "alpha".to_owned(),
13624 branch: "does-not-exist".to_owned(),
13625 worktree: repo.clone(),
13626 summary: String::new(),
13627 stat: String::new(),
13628 files: 0,
13629 commits: 0,
13630 empty: false,
13631 failed: None,
13632 verified_noop: None,
13633 duration_ms: 0,
13634 folded: false,
13635 }];
13636 state.tally = Some(Tally {
13637 first_choice: BTreeMap::from([('A', 1)]),
13638 borda: BTreeMap::new(),
13639 winner: 'A',
13640 rankings: 1,
13641 unanimous_initial: true,
13642 deliberated: false,
13643 changed_votes: 0,
13644 unanimous_final: true,
13645 tie_break: None,
13646 judges: 0,
13647 present: 0,
13648 quorum: 0,
13649 met_quorum: true,
13650 uncontested: Some("only candidate A produced a change".to_owned()),
13651 });
13652 state.reviews = vec![ReviewRound {
13656 round: 1,
13657 head: head.clone(),
13658 verified_head: Some(head.clone()),
13659 verified_at: Some(jiff::Timestamp::now()),
13660 reviews: Vec::new(),
13661 e2e: vec![CommandOutcome {
13662 command: format!(
13663 "CARGO_TARGET_DIR='{}' test -f README.md",
13664 cache_dir.display()
13665 ),
13666 code: None,
13667 output_tail: "waiting for the shared build cache".to_owned(),
13668 duration_ms: 0,
13669 resource_blocked: true,
13670 }],
13671 fix: None,
13672 blocking: 1,
13673 answered: 1,
13674 expected: 1,
13675 clean: false,
13676 verify_retried: false,
13677 e2e_deferred: false,
13678 e2e_defer_reason: None,
13679 progressed: false,
13680 vote_split: false,
13681 reconsideration: Vec::new(),
13682 verdict: None,
13683 }];
13684
13685 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13686 let mut runner = Runner {
13687 state,
13688 roles: ResolvedRoles {
13689 implementers: Vec::new(),
13690 judges: Vec::new(),
13691 reviewers: Vec::new(),
13692 fixer: None,
13693 conductor: conductor(),
13694 implementer_roster: Vec::new(),
13695 judge_roster: Vec::new(),
13696 reviewer_roster: Vec::new(),
13697 },
13698 sem: Arc::new(Semaphore::new(1)),
13699 pause: Pause::new(),
13700 interrupt: Pause::new(),
13701 };
13702
13703 runner.review_loop().await.expect("review_loop");
13708
13709 assert_eq!(
13710 runner.state.reviews.len(),
13711 1,
13712 "no new round was started on top of the unresolved one: {:?}",
13713 runner.state.reviews
13714 );
13715 let last = &runner.state.reviews[0];
13716 assert_eq!(
13717 last.e2e_status(),
13718 E2eStatus::ResourceBlocked,
13719 "still contended: {last:?}"
13720 );
13721 assert!(
13722 last.verified_at.expect("still known") > first_attempt_at,
13723 "review_loop must have actually retried the check, not left it exactly as found"
13724 );
13725 assert_ne!(
13726 runner.state.status,
13727 RunStatus::Blocked,
13728 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13729 runner.state.status
13730 );
13731
13732 held.release();
13733 }
13734
13735 #[tokio::test]
13736 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13737 crate::run::pin_test_home();
13738 let tmp = tempfile::tempdir().expect("tempdir");
13739 let repo = tmp.path().join("repo");
13740 std::fs::create_dir_all(&repo).unwrap();
13741 init_repo(&repo);
13742
13743 let mut config = Config::default();
13744 config.merge.mode = MergeMode::Pr;
13745 config.graph.land = true;
13746 config.graph.land_approval = false;
13747
13748 let mut state = RunState::new(
13749 repo.clone(),
13750 "main".to_owned(),
13751 "deadbeef".to_owned(),
13752 "task".to_owned(),
13753 config,
13754 );
13755 state.candidates = vec![Candidate {
13756 index: 0,
13757 label: 'A',
13758 agent: "alpha".to_owned(),
13759 branch: "does-not-exist".to_owned(),
13760 worktree: repo.clone(),
13761 summary: String::new(),
13762 stat: String::new(),
13763 files: 0,
13764 commits: 0,
13765 empty: false,
13766 failed: None,
13767 verified_noop: None,
13768 duration_ms: 0,
13769 folded: false,
13770 }];
13771 state.tally = Some(Tally {
13772 first_choice: BTreeMap::from([('A', 1)]),
13773 borda: BTreeMap::new(),
13774 winner: 'A',
13775 rankings: 1,
13776 unanimous_initial: true,
13777 deliberated: false,
13778 changed_votes: 0,
13779 unanimous_final: true,
13780 tie_break: None,
13781 judges: 0,
13782 present: 0,
13783 quorum: 0,
13784 met_quorum: true,
13785 uncontested: Some("only candidate A produced a change".to_owned()),
13786 });
13787 state.reviews = vec![ReviewRound {
13788 round: 1,
13789 head: "deadbeef".to_owned(),
13790 verified_head: None,
13791 verified_at: None,
13792 reviews: Vec::new(),
13793 e2e: Vec::new(),
13794 fix: None,
13795 blocking: 0,
13796 answered: 0,
13797 expected: 0,
13798 clean: true,
13799 verify_retried: false,
13800 e2e_deferred: false,
13801 e2e_defer_reason: None,
13802 progressed: false,
13803 vote_split: false,
13804 reconsideration: Vec::new(),
13805 verdict: None,
13806 }];
13807 state.gate = vec![CommandOutcome {
13808 command: "test".to_owned(),
13809 code: Some(0),
13810 output_tail: String::new(),
13811 duration_ms: 0,
13812 resource_blocked: false,
13813 }];
13814 state.gate_ran = true;
13815 state.status = RunStatus::Landing;
13819 state.merge = Some(MergeOutcome {
13820 mode: MergeMode::Pr,
13821 ok: true,
13822 detail: "https://example.invalid/x/y/pull/1".to_owned(),
13823 empty: false,
13824 });
13825
13826 ask_test_home();
13830 state.save().expect("save landing run");
13833 let store = ask::Questions::open();
13834 let q = ask_open_question(&store, &state.id);
13835
13836 let mut runner = Runner {
13837 state,
13838 roles: ResolvedRoles {
13839 implementers: Vec::new(),
13840 judges: Vec::new(),
13841 reviewers: Vec::new(),
13842 fixer: None,
13843 conductor: conductor(),
13844 implementer_roster: Vec::new(),
13845 judge_roster: Vec::new(),
13846 reviewer_roster: Vec::new(),
13847 },
13848 sem: Arc::new(Semaphore::new(1)),
13849 pause: Pause::new(),
13850 interrupt: Pause::new(),
13851 };
13852
13853 runner.execute().await.expect("execute");
13858
13859 assert_eq!(
13860 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13861 Some("https://example.invalid/x/y/pull/1"),
13862 "reentry must not push again or open a second pull request over the \
13863 one `land` is already watching"
13864 );
13865 assert_ne!(
13866 runner.state.status,
13867 RunStatus::Landing,
13868 "land could not actually reach the fake pull request, so it must \
13869 have given up rather than left the run silently parked forever"
13870 );
13871 assert_eq!(runner.state.status, RunStatus::Blocked);
13875 assert!(
13876 store.get(&q.id).unwrap().status.open(),
13877 "Blocked is still alive; settle_questions must have been a no-op here"
13878 );
13879 }
13880
13881 fn state_with_round(round: ReviewRound) -> RunState {
13882 let mut s = RunState::new(
13883 PathBuf::from("/repo"),
13884 "main".to_owned(),
13885 "abc1234".to_owned(),
13886 "add retries".to_owned(),
13887 Config::default(),
13888 );
13889 s.reviews = vec![round];
13890 s
13891 }
13892
13893 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13894 crate::verdict::Finding {
13895 id: id.to_owned(),
13896 severity,
13897 file: None,
13898 line: None,
13899 title: title.to_owned(),
13900 detail: String::new(),
13901 }
13902 }
13903
13904 #[test]
13905 fn pr_body_names_open_findings_and_declined_ones() {
13906 let round = ReviewRound {
13907 round: 2,
13908 head: "deadbee".to_owned(),
13909 verified_head: None,
13910 verified_at: None,
13911 reviews: vec![ReviewRecord {
13912 attempts: 0,
13913 reviewer: 1,
13914 agent: "alpha".to_owned(),
13915 summary: String::new(),
13916 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13917 vote: None,
13918 failed: None,
13919 duration_ms: 0,
13920 }],
13921 e2e: vec![CommandOutcome {
13922 command: "cargo test".to_owned(),
13923 code: Some(0),
13924 output_tail: String::new(),
13925 duration_ms: 0,
13926 resource_blocked: false,
13927 }],
13928 verify_retried: false,
13929 e2e_deferred: false,
13930 e2e_defer_reason: None,
13931 fix: Some(FixRecord {
13932 agent: "alpha".to_owned(),
13933 addressed: Vec::new(),
13934 rejected: vec![crate::verdict::Rejection {
13935 id: "R1-1-1".to_owned(),
13936 why: "not reachable from any caller".to_owned(),
13937 }],
13938 notes: String::new(),
13939 committed: true,
13940 failed: None,
13941 duration_ms: 0,
13942 continuation: None,
13943 }),
13944 blocking: 0,
13945 answered: 1,
13946 expected: 1,
13947 clean: false,
13948 progressed: true,
13949 vote_split: false,
13950 reconsideration: Vec::new(),
13951 verdict: None,
13952 };
13953 let state = state_with_round(round);
13954 let body = pr_message(&state, 'A').body;
13955
13956 assert!(body.contains("add retries"), "the task must still be there");
13957 assert!(body.contains("R2-1-1"), "{body}");
13958 assert!(!body.contains("unused import"), "{body}");
13959 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13960 assert!(
13961 !body.contains("not reachable from any caller"),
13962 "the reason it was declined: {body}"
13963 );
13964 }
13965
13966 #[test]
13967 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13968 let round = ReviewRound {
13969 round: 1,
13970 head: "deadbee".to_owned(),
13971 verified_head: None,
13972 verified_at: None,
13973 reviews: vec![ReviewRecord {
13974 attempts: 0,
13975 reviewer: 1,
13976 agent: "alpha".to_owned(),
13977 summary: String::new(),
13978 findings: Vec::new(),
13979 vote: None,
13980 failed: None,
13981 duration_ms: 0,
13982 }],
13983 e2e: Vec::new(),
13984 verify_retried: false,
13985 e2e_deferred: false,
13986 e2e_defer_reason: None,
13987 fix: None,
13988 blocking: 0,
13989 answered: 1,
13990 expected: 1,
13991 clean: true,
13992 progressed: false,
13993 vote_split: false,
13994 reconsideration: Vec::new(),
13995 verdict: None,
13996 };
13997 let state = state_with_round(round);
13998 let body = pr_message(&state, 'A').body;
13999 assert!(!body.contains("Open review findings"), "{body}");
14000 assert!(!body.contains("Declined"), "{body}");
14001 }
14002
14003 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
14004 let mut state = RunState::new(
14005 PathBuf::from("/repo"),
14006 "main".to_owned(),
14007 "abc1234".to_owned(),
14008 instruction.to_owned(),
14009 Config::default(),
14010 );
14011 state.candidates.push(Candidate {
14012 index: 0,
14013 label: 'A',
14014 agent: "alpha".to_owned(),
14015 branch: "magi/x/A".to_owned(),
14016 worktree: PathBuf::from("/wt"),
14017 summary: summary.to_owned(),
14018 stat: String::new(),
14019 files: 1,
14020 commits: 1,
14021 empty: false,
14022 failed: None,
14023 verified_noop: None,
14024 folded: false,
14025 duration_ms: 0,
14026 });
14027 state
14028 }
14029
14030 fn review_state(subjects: &[&str]) -> RunState {
14031 let mut state = state_with_summary(
14032 "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",
14033 "",
14034 );
14035 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14036 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
14037 state
14038 }
14039
14040 #[tokio::test]
14043 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
14044 ask_test_home();
14045 let tmp = tempfile::tempdir().unwrap();
14046 let repo = tmp.path().join("repo");
14047 std::fs::create_dir_all(&repo).unwrap();
14048 init_repo(&repo);
14049 let origin = tmp.path().join("origin.git");
14050 let g = |dir: &Path, args: &[&str]| {
14051 let out = std::process::Command::new("git")
14052 .args(args)
14053 .current_dir(dir)
14054 .quiet()
14055 .output()
14056 .expect("spawn git");
14057 assert!(
14058 out.status.success(),
14059 "git {args:?}: {}",
14060 String::from_utf8_lossy(&out.stderr)
14061 );
14062 };
14063 g(
14064 tmp.path(),
14065 &[
14066 "clone",
14067 "--bare",
14068 "-q",
14069 repo.to_str().unwrap(),
14070 origin.to_str().unwrap(),
14071 ],
14072 );
14073 g(
14074 &repo,
14075 &["remote", "add", "origin", origin.to_str().unwrap()],
14076 );
14077 let c1 = git::rev_parse(&repo, "main").await.unwrap();
14078
14079 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14081 g(&repo, &["add", "-A"]);
14082 g(
14083 &repo,
14084 &["commit", "-q", "-m", "chore(deps): update a crate"],
14085 );
14086 g(&repo, &["push", "-q", "origin", "main"]);
14087 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14088 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14089 g(&repo, &["add", "-A"]);
14090 g(
14091 &repo,
14092 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14093 );
14094 g(&repo, &["checkout", "-q", "main"]);
14095
14096 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14097 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14098 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14100 assert_eq!(fallback, c1);
14101
14102 let mut state = review_state(&[
14103 "chore(deps): update a crate",
14104 "fix(daemon): apply a chosen action",
14105 ]);
14106 state.repo = repo.clone();
14107 state.base_branch = "main".to_owned();
14108 state.base_commit = c1;
14109 refresh_reviewed_commits(&mut state, "feat/own").await;
14110 assert_eq!(
14111 state.reviewed_commits,
14112 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14113 );
14114 assert_eq!(
14115 review_title(&state).as_deref(),
14116 Some("fix(daemon): apply a chosen action")
14117 );
14118 assert_eq!(
14119 leaked_subjects(&state, "feat/own").await,
14120 Some(vec!["chore(deps): update a crate".to_owned()])
14121 );
14122 state.config.merge.remote = "nowhere".to_owned();
14125 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14126 }
14127
14128 struct SummaryFixture {
14132 _tmp: tempfile::TempDir,
14133 repo: PathBuf,
14134 reply: PathBuf,
14135 runner: Runner,
14136 }
14137
14138 fn summary_fixture() -> SummaryFixture {
14139 ask_test_home();
14140 let tmp = tempfile::tempdir().unwrap();
14141 let repo = tmp.path().join("repo");
14142 std::fs::create_dir_all(&repo).unwrap();
14143 init_repo(&repo);
14144 let origin = tmp.path().join("origin.git");
14145 let g = |args: &[&str]| {
14146 let out = std::process::Command::new("git")
14147 .args(args)
14148 .current_dir(&repo)
14149 .quiet()
14150 .output()
14151 .expect("spawn git");
14152 assert!(
14153 out.status.success(),
14154 "git {args:?}: {}",
14155 String::from_utf8_lossy(&out.stderr)
14156 );
14157 };
14158 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14159 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14160 g(&["push", "-q", "origin", "main"]);
14161 g(&["checkout", "-q", "-b", "feat/own"]);
14162 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14163 g(&["add", "-A"]);
14164 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14165 let reply = tmp.path().join("reply.txt");
14166 std::fs::write(
14167 &reply,
14168 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14169 )
14170 .unwrap();
14171 let mut runner = runner_at(RunStatus::Gating);
14172 let mut state = review_state(&["feat(own): add the own file"]);
14173 state.repo = repo.clone();
14174 state.base_branch = "main".to_owned();
14175 state.candidates[0].branch = "feat/own".to_owned();
14176 state.candidates[0].worktree = repo.clone();
14177 let mut author = spec("alpha");
14178 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14179 author
14180 .env
14181 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14182 state.config.agents = vec![author];
14183 runner.state = state;
14184 SummaryFixture {
14185 _tmp: tmp,
14186 repo,
14187 reply,
14188 runner,
14189 }
14190 }
14191
14192 async fn posted(f: &mut SummaryFixture) -> PrMessage {
14193 let winner = f.runner.state.candidates[0].clone();
14194 f.runner
14195 .guarded_pr_message(&winner, None, true)
14196 .await
14197 .unwrap()
14198 .expect("an English description never parks")
14199 }
14200
14201 fn clean_round_with_minor_findings() -> ReviewRound {
14202 let mut round = passing_round(2, None);
14203 round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14204 round.reviews = vec![ReviewRecord {
14205 attempts: 0,
14206 reviewer: 1,
14207 agent: "alpha".into(),
14208 summary: "内部レビュー".into(),
14209 findings: vec![
14210 finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14211 finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14212 ],
14213 vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14214 failed: None,
14215 duration_ms: 0,
14216 }];
14217 round
14218 }
14219
14220 #[tokio::test]
14221 async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14222 for review_only in [false, true] {
14223 let mut f = summary_fixture();
14224 if !review_only {
14225 f.runner.state.candidates[0].agent = "alpha".into();
14226 }
14227 f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14228 assert!(f.runner.state.reviews[0].clean);
14229 assert!(f.runner.state.reviews[0].fix.is_none());
14230 assert!(f.runner.state.open_findings().is_empty());
14231 assert_eq!(
14232 crate::pr_outcome::required_finding_ids(&f.runner.state),
14233 ["R2-1-1", "R2-1-2"]
14234 );
14235 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14236 let evidence = crate::pr_outcome::summary_evidence(
14237 &f.runner.state,
14238 crate::pr_outcome::FinalHead::At(&head),
14239 );
14240 assert!(
14241 evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14242 );
14243 let omitted = posted(&mut f).await;
14245 assert!(omitted.body.contains("No description"));
14246 assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14247 assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14248 assert!(f.runner.state.change_summary.is_none());
14249 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();
14250 let described = posted(&mut f).await;
14251 assert!(described.body.contains("improve documentation for callers"));
14252 assert!(described.body.contains("remaining edge case"));
14253 assert!(described.body.contains("## Open review findings"));
14254 assert!(!described.body.contains("未修正"));
14255 }
14256 }
14257
14258 async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14259 let mut f = summary_fixture();
14260 if !review_only {
14261 f.runner.state.candidates[0].agent = "alpha".into();
14262 }
14263 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14264 let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14265 fixed
14266 .fix
14267 .as_mut()
14268 .unwrap()
14269 .rejected
14270 .push(crate::verdict::Rejection {
14271 id: "R1-1-1".into(),
14272 why: "呼び出されないコードのため辞退".into(),
14273 });
14274 let mut clean = clean_round_with_minor_findings();
14275 clean.verified_head = Some(head.clone());
14276 f.runner.state.reviews = vec![fixed, clean];
14277 ok_gate(&mut f.runner.state, Some(&head));
14278 std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14279 f.runner.state.config.agents.push(scripted("beta", reply));
14280 f.runner.state.config.roles.language_judge =
14281 Some(crate::config::AgentChoice::One("beta".into()));
14282 f
14283 }
14284
14285 #[tokio::test]
14286 async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14287 for review_only in [false, true] {
14288 let mut f = rewrite_fixture_with_findings(review_only,
14289 "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;
14290 let m = posted(&mut f).await;
14291 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14292 let expected =
14293 public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14294 assert!(m.body.contains(&expected));
14295 assert!(m.body.contains("## Review rounds"));
14296 assert!(
14297 m.body
14298 .contains("Gate (`verify.gate`): passed on the final commit")
14299 );
14300 assert!(
14301 m.body
14302 .contains("Tests (`verify.e2e`): passed on the final commit")
14303 );
14304 let c = f.runner.state.change_summary.as_ref().unwrap();
14305 assert!(
14306 m.body
14307 .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14308 );
14309 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14310 assert!(crate::pr_outcome::covers_required_findings(
14311 &f.runner.state,
14312 &m.body
14313 ));
14314 assert!(m.body.contains("documentation remains incomplete"));
14315 assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14316 assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14317 assert!(f.runner.state.github_text.is_none());
14318 }
14319 }
14320
14321 #[tokio::test]
14322 async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14323 for review_only in [false, true] {
14324 for reply in [
14325 "TITLE: fix: public description\nA short English description.",
14326 "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14327 ] {
14328 let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14329 let winner = f.runner.state.candidates[0].clone();
14330 let posted = f
14331 .runner
14332 .guarded_pr_message(&winner, None, true)
14333 .await
14334 .unwrap();
14335 assert!(posted.is_none());
14336 assert!(f.runner.state.parked);
14337 let withheld = f.runner.state.github_text.as_ref().unwrap();
14338 assert!(
14339 withheld.rewrite.is_none(),
14340 "an incomplete rewrite must not be selectable"
14341 );
14342 let q = ask::Questions::open()
14343 .get(withheld.question.as_ref().unwrap())
14344 .unwrap();
14345 assert!(
14346 !q.choices
14347 .contains(&crate::github_text::USE_REWRITE.to_owned())
14348 );
14349 assert!(
14350 q.detail.contains("R1-1-1")
14351 && q.detail.contains("R2-1-1")
14352 && q.detail.contains("R2-1-2")
14353 );
14354 }
14355 }
14356 }
14357
14358 #[tokio::test]
14359 async fn japanese_reports_stay_local_in_both_public_run_modes() {
14360 for review_only in [false, true] {
14361 let mut f = summary_fixture();
14362 if !review_only {
14363 f.runner.state.candidates[0].agent = "alpha".into();
14364 }
14365 f.runner.state.instruction = "内部タスクの説明".into();
14366 f.runner.state.candidates[0].summary = "内部の候補報告".into();
14367 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14368 f.runner.state.reviews = vec![
14369 round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14370 round_with_notes(2, Some("二回目の内部修正報告です。")),
14371 passing_round(3, Some(&head)),
14372 ];
14373 f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14374 ok_gate(&mut f.runner.state, Some(&head));
14375 let captured = f.repo.join("public-prompt.txt");
14376 let author = &mut f.runner.state.config.agents[0];
14377 author.command = vec![
14378 "sh".into(),
14379 "-c".into(),
14380 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14381 ];
14382 author.env.insert(
14383 "PROMPT_FILE".into(),
14384 captured.to_string_lossy().into_owned(),
14385 );
14386 let m = posted(&mut f).await;
14387 assert!(m.body.contains("Adds `own.txt`"));
14388 assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14389 assert!(m.body.contains("R1-1-1"));
14390 assert!(
14391 m.body
14392 .contains("Gate (`verify.gate`): passed on the final commit")
14393 );
14394 assert!(
14395 m.body
14396 .contains("Tests (`verify.e2e`): passed on the final commit")
14397 );
14398 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14399 assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14400 let prompt = std::fs::read_to_string(captured).unwrap();
14401 assert!(prompt.contains(&format!(
14402 "git diff {} {head}",
14403 f.runner.state.change_summary.as_ref().unwrap().base
14404 )));
14405 assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14406 assert_eq!(
14407 f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14408 "二回目の内部修正報告です。"
14409 );
14410 }
14411 }
14412
14413 #[tokio::test]
14414 async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14415 for review_only in [false, true] {
14416 let mut f = summary_fixture();
14417 if !review_only {
14418 f.runner.state.candidates[0].agent = "alpha".into();
14419 }
14420 f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14421 f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14422 std::fs::write(&f.reply, "no usable description").unwrap();
14423 let failed = posted(&mut f).await;
14424 assert!(
14425 failed
14426 .body
14427 .contains("No description of what this change does could be generated")
14428 );
14429 assert!(failed.body.is_ascii());
14430 assert!(f.runner.state.github_text.is_none());
14431 assert!(f.runner.state.change_summary.is_none());
14432 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14433 let evidence = crate::pr_outcome::summary_evidence(
14434 &f.runner.state,
14435 crate::pr_outcome::FinalHead::At(&head),
14436 );
14437 let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14438 assert!(
14439 crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14440 .exists()
14441 );
14442 f.runner.state =
14443 serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14444 std::fs::write(
14445 &f.reply,
14446 "TITLE: feat(own): add the own file\nAdds the committed own file.",
14447 )
14448 .unwrap();
14449 let retried = posted(&mut f).await;
14450 assert!(retried.body.contains("Adds the committed own file."));
14451 assert_eq!(
14452 f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14453 "日本語の修正報告"
14454 );
14455 }
14456 }
14457
14458 #[tokio::test]
14459 async fn same_head_verification_and_findings_invalidate_public_prose() {
14460 let mut f = summary_fixture();
14461 posted(&mut f).await;
14462 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14463 let original = f
14464 .runner
14465 .state
14466 .change_summary
14467 .as_ref()
14468 .unwrap()
14469 .evidence
14470 .clone();
14471 ok_gate(&mut f.runner.state, Some(&head));
14472 std::fs::write(
14473 &f.reply,
14474 "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14475 )
14476 .unwrap();
14477 let verified = posted(&mut f).await;
14478 assert!(verified.body.contains("now verified"));
14479 assert_ne!(
14480 f.runner.state.change_summary.as_ref().unwrap().evidence,
14481 original
14482 );
14483 let mut round = round_with_notes(1, Some("内部報告"));
14484 round.reviews.push(ReviewRecord {
14485 attempts: 0,
14486 reviewer: 1,
14487 agent: "alpha".into(),
14488 summary: "内部レビュー".into(),
14489 findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14490 vote: None,
14491 failed: None,
14492 duration_ms: 0,
14493 });
14494 round
14495 .fix
14496 .as_mut()
14497 .unwrap()
14498 .rejected
14499 .push(crate::verdict::Rejection {
14500 id: "R1-1-1".into(),
14501 why: "呼び出し元から到達できない".into(),
14502 });
14503 f.runner.state.reviews = vec![round];
14504 let omitted = posted(&mut f).await;
14506 assert!(omitted.body.contains("No description"));
14507 assert!(omitted.body.contains("R1-1-1"));
14508 assert!(!omitted.body.contains("到達"));
14509 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();
14510 let described = posted(&mut f).await;
14511 assert!(described.body.contains("unreachable from callers"));
14512 assert!(described.body.contains("R1-1-1"));
14513 assert!(!described.body.contains("到達"));
14514 let evidence = f
14515 .runner
14516 .state
14517 .change_summary
14518 .as_ref()
14519 .unwrap()
14520 .evidence
14521 .clone();
14522 f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14523 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14524 assert!(posted(&mut f).await.body.contains("rationale updated"));
14525 assert_ne!(
14526 f.runner.state.change_summary.as_ref().unwrap().evidence,
14527 evidence
14528 );
14529 f.runner
14531 .state
14532 .change_summary
14533 .as_mut()
14534 .unwrap()
14535 .evidence
14536 .clear();
14537 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14538 assert!(posted(&mut f).await.body.contains("current specification"));
14539 }
14540
14541 #[tokio::test]
14542 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14543 let mut f = summary_fixture();
14544 let m = posted(&mut f).await;
14545 assert_eq!(m.title, "feat(own): add the own file");
14546 assert!(
14547 m.body
14548 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14549 "{}",
14550 m.body
14551 );
14552 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14553 assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14554 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14555 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14556 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14557 let kept = f.runner.state.change_summary.as_ref().unwrap();
14558 assert_eq!(
14559 (kept.head.as_str(), kept.base.as_str()),
14560 (head.as_str(), base.as_str())
14561 );
14562 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14563 }
14564
14565 #[tokio::test]
14566 async fn review_only_summary_receives_the_configured_github_language() {
14567 let mut f = summary_fixture();
14568 f.runner.state.config.graph.github_language = "ja".into();
14569 let captured = f.repo.join("summary-prompt.txt");
14570 let author = &mut f.runner.state.config.agents[0];
14571 author.command = vec![
14572 "sh".into(),
14573 "-c".into(),
14574 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14575 ];
14576 author.env.insert(
14577 "PROMPT_FILE".into(),
14578 captured.to_string_lossy().into_owned(),
14579 );
14580 std::fs::write(
14581 &f.reply,
14582 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14583 )
14584 .unwrap();
14585 let m = posted(&mut f).await;
14586 let prompt = std::fs::read_to_string(captured).unwrap();
14587 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14588 assert!(!prompt.contains("in English"));
14589 assert_eq!(m.title, "feat(own): ファイルを追加");
14590 assert!(m.body.contains("own.txt を追加します。"));
14591 }
14592
14593 #[tokio::test]
14594 async fn review_fixes_come_after_the_whole_change_summary() {
14595 let mut f = summary_fixture();
14596 f.runner.state.reviews = vec![
14597 round_with_notes(1, Some("handled the PATH case")),
14598 round_with_notes(2, None),
14599 ];
14600 let m = posted(&mut f).await;
14601 let summary = m.body.find("## Summary").expect("summary");
14602 let fixes = m.body.find("## Review rounds").expect("review rounds");
14603 assert!(summary < fixes, "{}", m.body);
14604 assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14605 assert!(m.body.contains("## Final verification"), "{}", m.body);
14606 }
14607
14608 #[tokio::test]
14609 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14610 let mut f = summary_fixture();
14611 let first = posted(&mut f).await;
14612 let first_head = f.runner.state.change_summary.clone().unwrap().head;
14613 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14615 let again = posted(&mut f).await;
14616 assert_eq!((again.title, again.body), (first.title, first.body));
14617
14618 let g = |args: &[&str]| {
14619 let out = std::process::Command::new("git")
14620 .args(args)
14621 .current_dir(&f.repo)
14622 .quiet()
14623 .output()
14624 .expect("spawn git");
14625 assert!(out.status.success(), "git {args:?}");
14626 };
14627 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14628 g(&["add", "-A"]);
14629 g(&["commit", "-q", "-m", "fix(own): handle more"]);
14630 let second = posted(&mut f).await;
14631 let kept = f.runner.state.change_summary.clone().unwrap();
14632 assert_ne!(kept.head, first_head);
14633 assert!(second.body.contains("Not read yet."), "{}", second.body);
14634 assert!(!second.body.contains("so the first summary is read"));
14635 assert_eq!(second.title, "feat(own): other");
14636 }
14637
14638 #[tokio::test]
14639 async fn an_unavailable_summary_is_stated_and_never_invented() {
14640 let mut f = summary_fixture();
14641 std::fs::write(&f.reply, "no title line here").unwrap();
14642 let m = posted(&mut f).await;
14643 assert!(
14644 m.body
14645 .contains("No description of what this change does could be generated"),
14646 "{}",
14647 m.body
14648 );
14649 assert!(m.body.contains("internal reports remain in local records"));
14650 assert!(f.runner.state.change_summary.is_none());
14651 assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14652 let winner = f.runner.state.candidates[0].clone();
14654 let preview = f
14655 .runner
14656 .guarded_pr_message(&winner, None, false)
14657 .await
14658 .unwrap()
14659 .unwrap();
14660 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14661 }
14662
14663 #[test]
14664 fn pr_message_review_single_commit_uses_its_subject() {
14665 let state = review_state(&["feat(nats): per-role user"]);
14666 let m = pr_message(&state, 'A');
14667 assert_eq!(m.title, "feat(nats): per-role user");
14668 assert!(!m.body.contains("Review the work already"), "{}", m.body);
14669 assert!(m.body.contains("## Commits under review"), "{}", m.body);
14670 }
14671
14672 #[test]
14673 fn pr_message_review_multi_commit_takes_the_oldest() {
14674 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14675 let m = pr_message(&state, 'A');
14676 assert_eq!(m.title, "feat: the change");
14677 for s in ["feat: the change", "fix: typo", "fix: again"] {
14678 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14679 }
14680 }
14681
14682 #[test]
14683 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14684 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14685 let mut state = review_state(&[first, "fix: later fixup"]);
14686 state.candidates[0].branch = "機能/ログイン".to_owned();
14687 let m = pr_message(&state, 'A');
14688 assert!(
14689 m.title.starts_with("chore: land candidate A of run"),
14690 "{}",
14691 m.title
14692 );
14693 }
14694 }
14695
14696 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14697 BranchFacts {
14698 commits: commits
14699 .iter()
14700 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14701 .collect(),
14702 stat: stat.to_owned(),
14703 }
14704 }
14705
14706 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14707 let mut r = review_round(true, 0, 1, 1, true, true);
14708 r.round = round;
14709 r.fix = notes.map(|n| FixRecord {
14710 agent: "fixer".to_owned(),
14711 addressed: Vec::new(),
14712 rejected: Vec::new(),
14713 notes: n.to_owned(),
14714 committed: true,
14715 failed: None,
14716 duration_ms: 0,
14717 continuation: None,
14718 });
14719 r
14720 }
14721
14722 #[test]
14723 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14724 let state = review_state(&["ignored"]);
14725 let f = facts(
14726 &[
14727 (
14728 "fix(login): resolve PATH on macOS",
14729 "Login shells skip rc files.",
14730 ),
14731 ("fix: address review", ""),
14732 ],
14733 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14734 );
14735 let m = pr_message_with(&state, 'A', Some(&f));
14736 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14737 assert!(
14738 m.body
14739 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
14740 "{}",
14741 m.body
14742 );
14743 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14744 assert!(m.body.contains("## Diff stat"), "{}", m.body);
14745 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14746 for banned in [
14747 "Review the work already",
14748 "no task statement",
14749 "Original task",
14750 ] {
14751 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14752 }
14753 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14754 }
14755
14756 #[test]
14757 fn pr_message_review_truncates_a_huge_first_commit_body() {
14758 let state = review_state(&["ignored"]);
14759 let f = facts(
14760 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14761 "s",
14762 );
14763 let m = pr_message_with(&state, 'A', Some(&f));
14764 assert!(m.body.len() < 30_000, "{}", m.body.len());
14765 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14766 assert!(
14767 m.body.contains("- ... 1 more commit(s)"),
14768 "{}",
14769 m.body.len()
14770 );
14771 assert!(m.body.ends_with("magi:candidate-a\n"));
14772 }
14773
14774 #[test]
14775 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14776 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14777 assert_eq!(m.title, "feat: x");
14778 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14779 assert!(!m.body.contains("Diff stat"), "{}", m.body);
14780 assert!(!m.body.contains("no task statement"), "{}", m.body);
14781 }
14782
14783 #[test]
14784 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14785 let mut state = review_state(&["日本語の件名"]);
14786 state.candidates[0].branch = "fix/macos-login-path".to_owned();
14787 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14788 state.candidates[0].branch = "機能/ログイン".to_owned();
14789 assert!(
14790 pr_message(&state, 'A')
14791 .title
14792 .starts_with("chore: land candidate A")
14793 );
14794 }
14795
14796 #[test]
14797 fn pr_message_review_fixes_survive_a_clean_final_round() {
14798 let mut state = review_state(&["feat: x"]);
14799 state.reviews = vec![
14800 round_with_notes(1, Some("handled the PATH case")),
14801 round_with_notes(2, None),
14802 ];
14803 let body = pr_message(&state, 'A').body;
14804 assert!(
14805 body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14806 "{body}"
14807 );
14808 assert!(body.contains("### Round 1"), "{body}");
14809
14810 state.reviews = vec![
14811 round_with_notes(1, Some("first fix")),
14812 round_with_notes(2, Some("")),
14813 round_with_notes(3, Some("second fix")),
14814 round_with_notes(4, None),
14815 ];
14816 let body = pr_message(&state, 'A').body;
14817 assert!(
14818 body.contains("### Round 1") && !body.contains("first fix"),
14819 "{body}"
14820 );
14821 assert!(
14822 body.contains("### Round 3") && !body.contains("second fix"),
14823 "{body}"
14824 );
14825 assert!(body.contains("### Round 2"), "{body}");
14826 }
14827
14828 const FINAL: &str = "abcdef1234567890";
14829
14830 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14831 let mut r = round_with_notes(round, None);
14832 r.verified_head = verified.map(str::to_owned);
14833 r
14834 }
14835
14836 fn ok_gate(state: &mut RunState, head: Option<&str>) {
14837 state.gate = vec![CommandOutcome {
14838 command: "gate".to_owned(),
14839 code: Some(0),
14840 output_tail: String::new(),
14841 duration_ms: 0,
14842 resource_blocked: false,
14843 }];
14844 state.gate_ran = true;
14845 state.gate_head = head.map(str::to_owned);
14846 }
14847
14848 fn body_at(state: &RunState) -> String {
14849 pr_message_raw(
14850 state,
14851 'A',
14852 None,
14853 crate::pr_outcome::FinalHead::At(FINAL),
14854 ChangeNote::NotAsked,
14855 )
14856 .body
14857 }
14858
14859 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14860
14861 #[test]
14862 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14863 let mut state = review_state(&["feat: x"]);
14864 state.reviews = vec![
14865 round_with_notes(1, Some(SANDBOXED)),
14866 passing_round(2, Some(FINAL)),
14867 ];
14868 ok_gate(&mut state, Some(FINAL));
14869 let body = body_at(&state);
14870 assert!(!body.contains("Handled the PATH case."), "{body}");
14871 assert!(!body.contains("could not commit"), "{body}");
14872 assert!(!body.contains("could not run"), "{body}");
14873 assert!(
14874 body.contains("The fix is committed on the branch"),
14875 "{body}"
14876 );
14877 assert!(!body.contains("sandbox"), "{body}");
14878 assert!(
14879 body.contains("Tests (`verify.e2e`): passed on the final commit"),
14880 "{body}"
14881 );
14882 assert!(
14883 body.contains("Gate (`verify.gate`): passed on the final commit"),
14884 "{body}"
14885 );
14886 assert!(
14888 state.reviews[0]
14889 .fix
14890 .as_ref()
14891 .unwrap()
14892 .notes
14893 .contains("could not commit")
14894 );
14895 }
14896
14897 #[test]
14898 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14899 let mut state = review_state(&["feat: x"]);
14900 let mut r1 = round_with_notes(1, Some(SANDBOXED));
14901 r1.fix.as_mut().unwrap().committed = false;
14902 let mut r2 = passing_round(2, Some(FINAL));
14903 r2.e2e[0].code = Some(1);
14904 state.reviews = vec![r1, r2];
14905 let body = body_at(&state);
14906 assert!(!body.contains("could not commit"), "{body}");
14907 assert!(!body.contains("could not run"), "{body}");
14908 assert!(body.contains("This round produced no commit"), "{body}");
14909 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14910 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14911 assert!(!body.contains("passed"), "{body}");
14912 }
14913
14914 #[test]
14915 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14916 let mut state = review_state(&["feat: x"]);
14917 state.reviews = vec![
14918 round_with_notes(1, Some(SANDBOXED)),
14919 passing_round(2, Some("0123456789abcdef")),
14920 ];
14921 ok_gate(&mut state, Some("0123456789abcdef"));
14922 let body = body_at(&state);
14923 assert!(!body.contains("HTTP tests could not run"), "{body}");
14924 assert!(!body.contains("could not commit"), "{body}");
14926 assert!(
14927 body.contains("passed on an earlier commit (`0123456`)"),
14928 "{body}"
14929 );
14930 assert!(!body.contains("passed on the final commit"), "{body}");
14931
14932 ok_gate(&mut state, None);
14934 let body = body_at(&state);
14935 assert!(
14936 body.contains("passed on a commit that was not recorded"),
14937 "{body}"
14938 );
14939 }
14940
14941 #[test]
14942 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14943 let state = state_with_summary(
14944 "add retries to the client",
14945 "TITLE: feat: retries\n\nDid it.",
14946 );
14947 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14948 assert_eq!(m.title, "feat: retries");
14949 assert!(
14950 m.body.contains("<summary>Original task</summary>"),
14951 "{}",
14952 m.body
14953 );
14954 assert!(!m.body.contains("Commits under review"), "{}", m.body);
14955 assert!(
14956 m.body
14957 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14958 "{}",
14959 m.body
14960 );
14961 }
14962
14963 #[test]
14964 fn pr_message_review_bounds_a_long_english_subject() {
14965 let long = format!("feat: {}", "word ".repeat(100));
14966 let m = pr_message(&review_state(&[&long]), 'A');
14967 assert!(m.title.starts_with("feat: word"), "{}", m.title);
14968 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14969 }
14970
14971 #[test]
14972 fn pr_message_implementation_run_is_unchanged_by_review_support() {
14973 let state = state_with_summary("add retries\n\ndetails", "- did some things");
14974 let m = pr_message(&state, 'A');
14975 assert_eq!(m.title, "add retries");
14976 assert!(m.body.contains("<summary>Original task</summary>"));
14977 assert!(!m.body.contains("Commits under review"));
14978 assert_eq!(landing_subject_source(&state), state.instruction);
14979 }
14980
14981 #[test]
14982 fn review_run_squash_subject_is_the_change_not_the_prompt() {
14983 let state = review_state(&["feat: the change", "fix: typo"]);
14984 let source = landing_subject_source(&state);
14985 assert_eq!(land::merge_subject("", &source), "feat: the change");
14986 assert_eq!(
14987 land::merge_subject("magi: candidate A (uncommitted work)", &source),
14988 "feat: the change"
14989 );
14990 assert_eq!(
14992 land::merge_subject("feat: renamed by hand", &source),
14993 "feat: renamed by hand"
14994 );
14995 let blank = review_state(&["日本語"]);
14996 assert!(
14997 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14998 );
14999 }
15000
15001 #[test]
15002 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
15003 let state = review_state(&["feat: the change"]);
15004 let source = landing_subject_source(&state);
15005 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
15006 assert_eq!(
15007 land::merge_subject(landing_title(&state, old), &source),
15008 "feat: the change"
15009 );
15010 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
15011 let task = state_with_summary("add retries", "");
15012 assert_eq!(landing_title(&task, old), old);
15013 }
15014
15015 #[test]
15016 fn pr_message_describes_the_change_not_the_task() {
15017 let state = state_with_summary(
15018 "今回やってほしいこと: results projector を直す",
15019 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
15020 );
15021 let m = pr_message(&state, 'A');
15022 assert_eq!(m.title, "fix(web): batch the runs list reads");
15023 assert!(
15024 m.body.starts_with("## Summary\n\n- reads run.json once"),
15025 "{}",
15026 m.body
15027 );
15028 assert!(!m.body.contains("TITLE:"), "{}", m.body);
15029 let task_at = m.body.find("今回やってほしいこと").unwrap();
15030 let details_at = m.body.find("<details>").unwrap();
15031 assert!(
15032 details_at < task_at,
15033 "the task lives inside <details>: {}",
15034 m.body
15035 );
15036 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
15037 assert!(m.body.contains("magi:candidate-a"));
15038 }
15039
15040 #[test]
15041 fn pr_message_falls_back_to_the_task_without_a_title_line() {
15042 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
15043 let m = pr_message(&state, 'A');
15044 assert_eq!(m.title, "add retries");
15045 assert!(
15046 m.body.contains("## Summary\n\n- did some things"),
15047 "{}",
15048 m.body
15049 );
15050
15051 let none = RunState::new(
15052 PathBuf::from("/repo"),
15053 "main".to_owned(),
15054 "abc1234".to_owned(),
15055 "add retries".to_owned(),
15056 Config::default(),
15057 );
15058 let m = pr_message(&none, 'A');
15059 assert_eq!(m.title, "add retries");
15060 assert!(!m.body.contains("## Summary"), "{}", m.body);
15061 }
15062
15063 #[test]
15064 fn pr_message_refuses_the_candidate_commit_subject() {
15065 for bad in [
15066 "TITLE: magi: candidate A (uncommitted work)",
15067 "TITLE: chore: stuff (uncommitted work)",
15068 "TITLE: ",
15069 ] {
15070 let state = state_with_summary("add retries", bad);
15071 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15072 }
15073 }
15074
15075 #[test]
15076 fn pr_message_bounds_a_very_long_task_and_title() {
15077 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15078 let state = state_with_summary(&long, "- nothing");
15079 let m = pr_message(&state, 'A');
15080 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15081 assert!(!m.title.contains('\n'));
15082
15083 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15084 let m = pr_message(&state, 'A');
15085 assert!(m.title.starts_with("feat: "));
15086 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15087 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15088 }
15089
15090 fn long_title_of(instruction: &str) -> String {
15091 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15092 }
15093
15094 #[test]
15095 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15096 let first = "Make the landing path keep a readable title for long tasks";
15097 let line = format!(
15098 "{first}. {}",
15099 "And then keep going with more words ".repeat(20)
15100 );
15101 let t = long_title_of(&line);
15102 assert_eq!(t, first);
15103 assert!(!t.starts_with("chore: land"));
15104 }
15105
15106 #[test]
15107 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15108 let line = "word ".repeat(200);
15109 let t = long_title_of(&line);
15110 assert!(t.ends_with("word..."), "{t}");
15111 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15112 }
15113
15114 #[test]
15115 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15116 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15117 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15118 }
15119 }
15120
15121 #[test]
15122 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15123 let mut state = state_with_summary("再試行を追加する", "- nothing");
15124 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15125 state.config.graph.github_language = "ja".into();
15126 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15127 }
15128
15129 #[test]
15130 fn pr_message_title_limit_is_exact() {
15131 let at = "a".repeat(PR_TITLE_MAX);
15132 assert_eq!(long_title_of(&at), at);
15133 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15134 assert!(over.ends_with("..."), "{over}");
15135 assert_eq!(over.chars().count(), PR_TITLE_MAX);
15136 }
15137
15138 #[test]
15139 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15140 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15141 let t = long_title_of(&line);
15142 assert!(t.ends_with("..."), "{t}");
15143 assert!(t.is_ascii(), "{t}");
15144 }
15145
15146 #[test]
15147 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15148 let line = format!(
15149 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15150 "plus more filler words ".repeat(20)
15151 );
15152 let t = long_title_of(&line);
15153 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15154 }
15155
15156 #[test]
15157 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15158 let line = format!("\n\n# {}", "title words ".repeat(40));
15159 let state = state_with_summary(&line, "- nothing");
15160 let m = pr_message(&state, 'A');
15161 assert!(m.title.starts_with("title words"), "{}", m.title);
15162 assert_eq!(
15163 land::merge_subject(&m.title, &landing_subject_source(&state)),
15164 m.title
15165 );
15166 assert_eq!(
15168 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15169 "feat: renamed by hand"
15170 );
15171 }
15172
15173 #[tokio::test]
15174 async fn github_text_rewrite_is_bounded_and_falls_back() {
15175 crate::run::pin_test_home();
15176 for (reply, accepted) in [
15177 (
15178 "TITLE: fix: retries\nAdd retries for failed requests.",
15179 true,
15180 ),
15181 (
15182 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15183 false,
15184 ),
15185 (
15186 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15187 true,
15188 ),
15189 (
15190 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15191 true,
15192 ),
15193 (
15194 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15195 true,
15196 ),
15197 (
15198 "TITLE: fix: retries\n日本語の説明です。 token=secret",
15199 false,
15200 ),
15201 ("TITLE: fix: retries\n", false),
15202 ] {
15203 let dir = tempfile::tempdir().unwrap();
15204 let mut runner = runner_at(RunStatus::Gating);
15205 runner.state = state_with_summary(
15206 "add retries",
15207 "TITLE: fix: retries\n日本語の説明を書きます。",
15208 );
15209 runner.state.repo = dir.path().to_owned();
15210 runner.state.candidates[0].worktree = dir.path().to_owned();
15211 let mut author = spec("alpha");
15212 author.command = vec![
15213 "sh".into(),
15214 "-c".into(),
15215 "printf '%s' \"$REWRITE_REPLY\"".into(),
15216 ];
15217 author.env.insert("REWRITE_REPLY".into(), reply.into());
15218 runner.state.config.agents = vec![author];
15219 seed_public_summary(&mut runner);
15220 let winner = runner.state.candidates[0].clone();
15221 let first = runner
15222 .guarded_pr_message(&winner, None, true)
15223 .await
15224 .unwrap();
15225 let message = if accepted {
15226 first.expect("a passing rewrite never asks")
15227 } else {
15228 assert!(first.is_none());
15230 assert!(runner.state.parked);
15231 let store = ask::Questions::open();
15232 let asked: Vec<_> = store
15233 .list()
15234 .into_iter()
15235 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15236 .collect();
15237 assert_eq!(asked.len(), 1);
15238 assert!(!asked[0].detail.contains("secret"));
15239 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15240 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15241 assert_eq!(
15242 asked[0]
15243 .choices
15244 .contains(&crate::github_text::USE_REWRITE.to_owned()),
15245 rewrite.is_some()
15246 );
15247 store
15250 .update(&asked[0].id, |q| {
15251 q.abandon("test");
15252 Ok(())
15253 })
15254 .unwrap();
15255 let again = runner
15256 .guarded_pr_message(&winner, None, true)
15257 .await
15258 .unwrap()
15259 .expect("an abandoned question falls back");
15260 let n = store
15261 .list()
15262 .into_iter()
15263 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15264 .count();
15265 assert_eq!(n, 1, "asked once per rejected text");
15266 again
15267 };
15268 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15269 assert!(!message.body.contains("secret"));
15270 assert!(!message.body.contains("prefix"));
15271 assert!(!message.title.contains("secret"));
15272 assert_eq!(
15273 message.body.contains("Add retries for failed requests."),
15274 accepted
15275 );
15276 assert_eq!(
15277 runner.state.seats["github-text-rewrite"].turns, 1,
15278 "only one rewrite invocation"
15279 );
15280 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15281 if accepted {
15282 assert!(runner.state.github_text.is_none());
15283 assert!(!runner.state.parked);
15284 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15285 assert!(message.body.contains("## Final verification"));
15286 assert!(message.body.contains("Evidence: final diff"));
15287 assert!(
15288 message
15289 .body
15290 .contains(&format!("magi:run/{}", runner.state.id))
15291 );
15292 }
15293 if !accepted {
15294 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15295 assert!(
15296 message
15297 .body
15298 .contains(&format!("magi:run/{}", runner.state.id))
15299 );
15300 }
15301 }
15302 }
15303
15304 fn scripted(id: &str, reply: &str) -> AgentSpec {
15305 let mut a = spec(id);
15306 a.command = vec![
15307 "sh".into(),
15308 "-c".into(),
15309 "printf '%s' \"$REWRITE_REPLY\"".into(),
15310 ];
15311 a.env.insert("REWRITE_REPLY".into(), reply.into());
15312 a
15313 }
15314
15315 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15316 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15317
15318 fn seed_public_summary(runner: &mut Runner) {
15320 let repo = &runner.state.repo;
15321 let g = |args: &[&str]| {
15322 let out = std::process::Command::new("git")
15323 .args(args)
15324 .current_dir(repo)
15325 .quiet()
15326 .output()
15327 .unwrap();
15328 assert!(out.status.success(), "git {args:?}");
15329 String::from_utf8_lossy(&out.stdout).trim().to_owned()
15330 };
15331 g(&["init", "-q", "-b", "main"]);
15332 g(&[
15333 "-c",
15334 "user.name=Test",
15335 "-c",
15336 "user.email=test@example.invalid",
15337 "commit",
15338 "-q",
15339 "--allow-empty",
15340 "-m",
15341 "feat: fixture",
15342 ]);
15343 let head = g(&["rev-parse", "HEAD"]);
15344 g(&["update-ref", "refs/remotes/origin/main", &head]);
15345 let evidence = crate::pr_outcome::summary_evidence(
15346 &runner.state,
15347 crate::pr_outcome::FinalHead::At(&head),
15348 );
15349 let generated = &runner.state.candidates[0].summary;
15350 runner.state.change_summary = Some(crate::run::ChangeSummary {
15351 base: head.clone(),
15352 head,
15353 evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15354 title: summary_title(generated).unwrap(),
15355 body: summary_without_title(generated),
15356 });
15357 }
15358
15359 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15360 let mut runner = runner_at(RunStatus::Gating);
15361 runner.state = state_with_summary("add retries", JA_SUMMARY);
15362 if review_only {
15363 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15364 }
15365 runner.state.repo = dir.to_owned();
15366 runner.state.candidates[0].worktree = dir.to_owned();
15367 runner.state.config.agents = agents;
15368 seed_public_summary(&mut runner);
15369 runner
15370 }
15371
15372 fn gate_questions(runner: &Runner) -> usize {
15373 ask::Questions::open()
15374 .list()
15375 .into_iter()
15376 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15377 .count()
15378 }
15379
15380 #[tokio::test]
15381 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15382 crate::run::pin_test_home();
15383 let dir = tempfile::tempdir().unwrap();
15384 let mut runner = rewrite_runner(
15385 dir.path(),
15386 true,
15387 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15388 );
15389 runner.state.config.roles.language_judge =
15390 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15391 let winner = runner.state.candidates[0].clone();
15392 let msg = runner
15393 .guarded_pr_message(&winner, None, true)
15394 .await
15395 .unwrap()
15396 .expect("an English rewrite posts without asking");
15397 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15398 assert!(msg.body.contains("<details>kept</details>"));
15399 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15400 assert_eq!(gate_questions(&runner), 0);
15401 assert!(runner.state.github_text.is_none());
15402 assert!(!runner.state.seats.contains_key("impl-A"));
15403 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15404 }
15405
15406 #[tokio::test]
15407 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15408 crate::run::pin_test_home();
15409 let dir = tempfile::tempdir().unwrap();
15410 let mut runner = rewrite_runner(
15411 dir.path(),
15412 false,
15413 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15414 );
15415 runner.state.config.roles.language_judge =
15416 Some(crate::config::AgentChoice::One("beta".into()));
15417 runner.state.seats.insert(
15418 "impl-A".into(),
15419 SeatState::new("impl-A", "alpha", runner.state.seed),
15420 );
15421 let winner = runner.state.candidates[0].clone();
15422 let msg = runner
15423 .guarded_pr_message(&winner, None, true)
15424 .await
15425 .unwrap()
15426 .expect("rewritten");
15427 assert!(msg.body.contains("<details>kept</details>"));
15428 assert_eq!(runner.state.seats["impl-A"].turns, 0);
15429 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15430 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15431 }
15432
15433 #[tokio::test]
15434 async fn rewrite_reuses_a_resumable_implementer_session() {
15435 crate::run::pin_test_home();
15436 let dir = tempfile::tempdir().unwrap();
15437 let mut runner = rewrite_runner(
15438 dir.path(),
15439 false,
15440 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15441 );
15442 runner.state.config.roles.language_judge =
15443 Some(crate::config::AgentChoice::One("beta".into()));
15444 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15445 seat.turns = 1;
15446 runner.state.seats.insert("impl-A".into(), seat);
15447 let winner = runner.state.candidates[0].clone();
15448 runner
15449 .guarded_pr_message(&winner, None, true)
15450 .await
15451 .unwrap()
15452 .expect("rewritten");
15453 assert_eq!(runner.state.seats["impl-A"].turns, 2);
15454 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15455 }
15456
15457 #[tokio::test]
15458 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15459 crate::run::pin_test_home();
15460 let dir = tempfile::tempdir().unwrap();
15461 let mut broken = spec("bad");
15462 broken.command = vec!["false".into()];
15463 let mut runner = rewrite_runner(
15464 dir.path(),
15465 true,
15466 vec![
15467 broken,
15468 scripted("noformat", "Add retries."),
15469 scripted("good", EN_REWRITE),
15470 ],
15471 );
15472 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15473 "bad".into(),
15474 "noformat".into(),
15475 "good".into(),
15476 ]));
15477 let winner = runner.state.candidates[0].clone();
15478 let msg = runner
15479 .guarded_pr_message(&winner, None, true)
15480 .await
15481 .unwrap()
15482 .expect("the third agent rewrites");
15483 assert!(msg.body.contains("`src/net.rs`"));
15484 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15485 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15486 }
15487
15488 #[tokio::test]
15489 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15490 crate::run::pin_test_home();
15491 for (agents, role, saved) in [
15493 (
15494 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15495 Some("beta"),
15496 true,
15497 ),
15498 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15499 (Vec::new(), None, false),
15500 ] {
15501 let dir = tempfile::tempdir().unwrap();
15502 let mut runner = rewrite_runner(dir.path(), true, agents);
15503 runner.state.config.roles.language_judge =
15504 role.map(|r| crate::config::AgentChoice::One(r.into()));
15505 let winner = runner.state.candidates[0].clone();
15506 let first = runner
15507 .guarded_pr_message(&winner, None, true)
15508 .await
15509 .unwrap();
15510 assert!(first.is_none(), "withheld text parks the run");
15511 assert!(runner.state.parked);
15512 assert_eq!(gate_questions(&runner), 1);
15513 let g = runner.state.github_text.as_ref().unwrap();
15514 assert_eq!(g.rewrite.is_some(), saved);
15515 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15516 }
15517 }
15518
15519 #[tokio::test]
15520 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15521 crate::run::pin_test_home();
15522 let dir = tempfile::tempdir().unwrap();
15523 let mut runner = runner_at(RunStatus::Gating);
15524 runner.state =
15525 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15526 runner.state.repo = dir.path().to_owned();
15527 runner.state.candidates[0].worktree = dir.path().to_owned();
15528 let mut author = spec("alpha");
15529 author.command = vec![
15530 "sh".into(),
15531 "-c".into(),
15532 "printf '%s' \"$REWRITE_REPLY\"".into(),
15533 ];
15534 author.env.insert(
15535 "REWRITE_REPLY".into(),
15536 format!(
15537 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15538 "English sentence. ".repeat(600)
15539 ),
15540 );
15541 runner.state.config.agents = vec![author];
15542 seed_public_summary(&mut runner);
15543 let winner = runner.state.candidates[0].clone();
15544 assert!(
15545 runner
15546 .guarded_pr_message(&winner, None, true)
15547 .await
15548 .unwrap()
15549 .is_none()
15550 );
15551 let record = runner.state.github_text.clone().unwrap();
15552 let rewrite = record.rewrite.clone().unwrap();
15553 assert_eq!(rewrite.failed, ["body-language"]);
15554 assert!(
15555 rewrite
15556 .body
15557 .ends_with(&format!("magi:run/{}", runner.state.id))
15558 );
15559 let store = ask::Questions::open();
15560 let q = store.get(record.question.as_ref().unwrap()).unwrap();
15561 assert!(
15562 q.choices
15563 .contains(&crate::github_text::USE_REWRITE.to_owned())
15564 );
15565 for label in [
15566 "ORIGINAL",
15567 "FALLBACK",
15568 "REWRITE",
15569 "Rules still failed: body-language",
15570 "truncated",
15571 ] {
15572 assert!(q.detail.contains(label), "{label}");
15573 }
15574 let artifact = std::fs::read_to_string(crate::run::artifact_path(
15575 &runner.state,
15576 &crate::github_text::artifact_name(&record.fingerprint),
15577 ))
15578 .unwrap();
15579 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15580 runner.state =
15582 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15583 store
15584 .update(&q.id, |q| {
15585 q.say("まだ日本語")?;
15586 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15587 })
15588 .unwrap();
15589 assert!(
15590 runner
15591 .guarded_pr_message(&winner, None, true)
15592 .await
15593 .unwrap()
15594 .is_none()
15595 );
15596 let retry = runner.state.github_text.as_ref().unwrap();
15597 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15598 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15599 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15600 assert!(
15601 q.posting_comparison
15602 .as_ref()
15603 .unwrap()
15604 .matches_detail(&q.detail)
15605 );
15606 store
15607 .update(&q.id, |q| {
15608 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15609 })
15610 .unwrap();
15611 let message = runner
15612 .guarded_pr_message(&winner, None, true)
15613 .await
15614 .unwrap()
15615 .unwrap();
15616 assert_eq!(message.title, rewrite.title);
15617 assert_eq!(message.body, rewrite.body);
15618 assert_eq!(
15619 runner.state.github_text.as_ref().unwrap().choice,
15620 Some(GithubTextChoice::Rewrite)
15621 );
15622 let again = runner
15623 .guarded_pr_message(&winner, None, true)
15624 .await
15625 .unwrap()
15626 .unwrap();
15627 assert_eq!(again.title, message.title);
15628 assert_eq!(again.body, message.body);
15629 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15630 assert!(
15631 !runner
15632 .state
15633 .events
15634 .iter()
15635 .any(|e| e.message.contains(&rewrite.title)
15636 || e.message.contains("English sentence."))
15637 );
15638 }
15639
15640 #[tokio::test]
15641 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15642 crate::run::pin_test_home();
15643 let dir = tempfile::tempdir().unwrap();
15644 let mut runner = runner_at(RunStatus::Gating);
15645 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15646 runner.state.repo = dir.path().to_owned();
15647 runner.state.candidates[0].worktree = dir.path().to_owned();
15648 seed_public_summary(&mut runner);
15649 let winner = runner.state.candidates[0].clone();
15650 assert!(
15651 runner
15652 .guarded_pr_message(&winner, None, true)
15653 .await
15654 .unwrap()
15655 .is_none()
15656 );
15657 let record = runner.state.github_text.as_ref().unwrap();
15658 assert!(record.rewrite.is_none());
15659 let mut old = serde_json::to_value(record).unwrap();
15661 old.as_object_mut().unwrap().remove("rewrite");
15662 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15663 assert!(old.rewrite.is_none());
15664 let id = record.question.clone().unwrap();
15665 ask::Questions::open()
15666 .update(&id, |q| {
15667 q.choices.push(crate::github_text::USE_REWRITE.into());
15668 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15669 })
15670 .unwrap();
15671 let message = runner
15672 .guarded_pr_message(&winner, None, true)
15673 .await
15674 .unwrap()
15675 .unwrap();
15676 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15677 assert_eq!(
15678 runner.state.github_text.as_ref().unwrap().choice,
15679 Some(GithubTextChoice::Fallback)
15680 );
15681 }
15682
15683 #[tokio::test]
15684 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15685 crate::run::pin_test_home();
15686 let dir = tempfile::tempdir().unwrap();
15687 let mut runner = runner_at(RunStatus::Gating);
15688 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15689 runner.state.repo = dir.path().to_owned();
15690 runner.state.candidates[0].worktree = dir.path().to_owned();
15691 seed_public_summary(&mut runner);
15692 let winner = runner.state.candidates[0].clone();
15693 assert!(
15694 runner
15695 .guarded_pr_message(&winner, None, true)
15696 .await
15697 .unwrap()
15698 .is_none()
15699 );
15700 let store = ask::Questions::open();
15701 let find = |run: &str| {
15702 store
15703 .list()
15704 .into_iter()
15705 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15706 .unwrap()
15707 };
15708 let run = runner.state.id.clone();
15709 let q = find(&run);
15710 let fp = runner
15711 .state
15712 .github_text
15713 .as_ref()
15714 .unwrap()
15715 .fingerprint
15716 .clone();
15717 let name = crate::github_text::artifact_name(&fp);
15718 let path = crate::run::artifact_path(&runner.state, &name);
15719 assert!(q.detail.contains(&path.display().to_string()));
15720 assert!(q.detail.contains("title-language: title"));
15721 let saved = std::fs::read_to_string(&path).expect("artifact written");
15722 assert!(saved.contains("日本語のタイトル"));
15723 assert!(q.cwd.is_none());
15724 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15725 let say = |id: &str, text: &str, choice: &str| {
15727 store
15728 .update(id, |q| {
15729 q.thread.push(ask::Turn {
15730 who: ask::Who::Operator,
15731 body: text.to_owned(),
15732 at: jiff::Timestamp::now(),
15733 note: None,
15734 });
15735 q.answer(ask::Answer::Choice(choice.to_owned()))
15736 })
15737 .unwrap();
15738 };
15739 say(&q.id, "まだ日本語", "use my text");
15740 assert!(
15741 runner
15742 .guarded_pr_message(&winner, None, true)
15743 .await
15744 .unwrap()
15745 .is_none()
15746 );
15747 let second = find(&run);
15748 assert_ne!(second.id, q.id);
15749 assert!(second.detail.contains("did not pass"));
15750 assert!(second.detail.contains("日本語のタイトル"));
15753 assert!(!second.detail.contains("まだ日本語"));
15754 assert!(second.detail.contains(&path.display().to_string()));
15755 say(&second.id, "fix: retry failed requests", "use my text");
15756 let message = runner
15757 .guarded_pr_message(&winner, None, true)
15758 .await
15759 .unwrap()
15760 .expect("a vetted replacement is posted");
15761 assert_eq!(message.title, "fix: retry failed requests");
15762 assert_eq!(
15763 runner
15764 .state
15765 .github_text
15766 .as_ref()
15767 .unwrap()
15768 .chosen_title
15769 .as_deref(),
15770 Some("fix: retry failed requests")
15771 );
15772 assert!(runner.state.github_text.as_ref().unwrap().resolved);
15773 let again = runner
15775 .guarded_pr_message(&winner, None, true)
15776 .await
15777 .unwrap()
15778 .unwrap();
15779 assert_eq!(again.title, message.title);
15780 assert_eq!(
15781 store
15782 .list()
15783 .into_iter()
15784 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15785 .count(),
15786 2
15787 );
15788 }
15789
15790 #[test]
15791 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15792 let mut state = state_with_summary(
15796 "add retries",
15797 "TITLE: fix(web): batch reads\n- reads run.json once",
15798 );
15799 state.config.graph.language = "ja".to_owned();
15800 let m = pr_message(&state, 'A');
15801 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15802 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15803
15804 let task = "今回やってほしいこと: results projector を直す";
15807 let mut state = state_with_summary(task, "- no title line");
15808 state.config.graph.language = "ja".to_owned();
15809 let m = pr_message(&state, 'A');
15810 assert_eq!(
15811 m.title,
15812 format!("chore: land candidate A of run {}", state.id)
15813 );
15814 assert!(
15815 m.body.contains(&format!(
15816 "<summary>Original task</summary>\n\n{task}\n\n</details>"
15817 )),
15818 "{}",
15819 m.body
15820 );
15821 }
15822
15823 #[test]
15824 fn pr_message_scrubs_home_paths_and_addresses() {
15825 let state = state_with_summary(
15826 "fix it in /Users/someone/src/x",
15827 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15828 );
15829 let m = pr_message(&state, 'A');
15830 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15831 assert!(!m.body.contains(leak), "{}", m.body);
15832 }
15833 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15834 }
15835
15836 #[test]
15837 fn pr_message_survives_a_task_that_closes_details() {
15838 let state = state_with_summary("a </details> b", "TITLE: fix: x");
15839 let m = pr_message(&state, 'A');
15840 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15841 }
15842
15843 #[test]
15844 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15845 let cmd = manual_merge_command(
15846 MergeStyle::Squash,
15847 Path::new("/repo"),
15848 "b",
15849 "fix: \"quoted\" $(x) `y`\n\nbody",
15850 );
15851 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15852 }
15853
15854 #[test]
15855 fn manual_merge_command_matches_the_configured_style() {
15856 let repo = Path::new("/repo");
15857 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15858
15859 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15860 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15861
15862 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15863 assert_eq!(
15864 squash,
15865 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15866 \"Merge magi run 0832 (candidate A)\""
15867 );
15868
15869 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15870 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15871 }
15872
15873 #[test]
15874 fn a_nudge_gets_a_quarter_of_the_budget() {
15875 assert_eq!(retry_budget(secs(1200), true), secs(300));
15877 assert_eq!(retry_budget(secs(3600), true), secs(900));
15878 }
15879
15880 #[test]
15881 fn a_resent_prompt_keeps_the_whole_budget() {
15882 assert_eq!(retry_budget(secs(1200), false), secs(1200));
15885 assert_eq!(retry_budget(secs(60), false), secs(60));
15886 }
15887
15888 #[test]
15889 fn the_floor_never_exceeds_the_original_budget() {
15890 assert_eq!(retry_budget(secs(60), true), secs(60));
15894 assert_eq!(retry_budget(secs(480), true), secs(120));
15895 assert_eq!(retry_budget(secs(0), true), secs(0));
15896 }
15897
15898 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15899 agent::CommandEvidence {
15900 id: "item1".to_owned(),
15901 description: "cargo test".to_owned(),
15902 exit_code,
15903 result_summary: String::new(),
15904 source: "codex".to_owned(),
15905 }
15906 }
15907
15908 #[test]
15909 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15910 assert!(!has_unconfirmed_command(&[]));
15914 }
15915
15916 #[test]
15917 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15918 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15922 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15923 assert!(!has_unconfirmed_command(&[
15924 evidence(Some(0)),
15925 evidence(Some(101))
15926 ]));
15927 }
15928
15929 #[test]
15930 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15931 assert!(has_unconfirmed_command(&[
15932 evidence(Some(0)),
15933 evidence(None)
15934 ]));
15935 }
15936
15937 #[test]
15938 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15939 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15940 assert_eq!(
15941 verified_noop_claim(true, &[], text).as_deref(),
15942 Some("already fixed by b32cfc4, on main.")
15943 );
15944 }
15945
15946 #[test]
15947 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15948 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15951 assert!(verified_noop_claim(false, &[], text).is_none());
15952 }
15953
15954 #[test]
15955 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15956 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15957 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15958 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15960 }
15961
15962 #[test]
15963 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15964 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15965 }
15966
15967 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15970 runner.state.candidates = shape
15971 .iter()
15972 .enumerate()
15973 .map(|(i, &(empty, verified))| Candidate {
15974 index: i,
15975 label: (b'A' + i as u8) as char,
15976 agent: "sonnet".to_owned(),
15977 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15978 worktree: PathBuf::from(format!("/wt/{i}")),
15979 summary: String::new(),
15980 stat: String::new(),
15981 files: 0,
15982 commits: 0,
15983 empty,
15984 failed: None,
15985 verified_noop: verified.map(str::to_owned),
15986 duration_ms: 0,
15987 folded: false,
15988 })
15989 .collect();
15990 }
15991
15992 #[test]
15993 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15994 ask_test_home();
15995 let mut runner = runner_at(RunStatus::Implementing);
15996 set_candidates(
15997 &mut runner,
15998 &[
15999 (true, Some("already on main at b32cfc4")),
16000 (true, Some("same fix, see the existing test")),
16001 ],
16002 );
16003
16004 runner
16005 .after_implement()
16006 .expect("a verified no-op is not an error");
16007
16008 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
16009 }
16010
16011 #[test]
16012 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
16013 ask_test_home();
16014 let mut runner = runner_at(RunStatus::Implementing);
16015 set_candidates(
16019 &mut runner,
16020 &[(true, Some("already on main at b32cfc4")), (true, None)],
16021 );
16022
16023 let err = runner
16024 .after_implement()
16025 .expect_err("an unverified empty candidate must still fail the run");
16026
16027 assert!(
16028 err.to_string().contains("no candidate produced a change"),
16029 "{err}"
16030 );
16031 assert_eq!(runner.state.status, RunStatus::Failed);
16032 }
16033
16034 #[test]
16035 fn after_implement_still_fails_an_ordinary_all_empty_run() {
16036 ask_test_home();
16037 let mut runner = runner_at(RunStatus::Implementing);
16038 set_candidates(&mut runner, &[(true, None), (true, None)]);
16039
16040 let err = runner
16041 .after_implement()
16042 .expect_err("no candidate declared anything; this is an ordinary failure");
16043
16044 assert!(
16045 err.to_string().contains("no candidate produced a change"),
16046 "{err}"
16047 );
16048 assert_eq!(runner.state.status, RunStatus::Failed);
16049 }
16050}
16051
16052#[derive(Debug)]
16055struct GraftRestoreFailed(String);
16056
16057impl std::fmt::Display for GraftRestoreFailed {
16058 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16059 write!(
16060 f,
16061 "could not restore the winner to its pre-graft commit: {}",
16062 self.0
16063 )
16064 }
16065}
16066
16067impl std::error::Error for GraftRestoreFailed {}
16068
16069#[cfg(test)]
16073mod resource_wait_tests {
16074 use super::*;
16075 use std::collections::BTreeMap;
16076
16077 fn sh(id: &str, script: &str) -> AgentSpec {
16078 AgentSpec {
16079 id: id.to_owned(),
16080 kind: crate::config::AgentKind::Command,
16081 model: None,
16082 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16083 extra_args: Vec::new(),
16084 env: BTreeMap::new(),
16085 prompt_delivery: None,
16086 }
16087 }
16088
16089 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16090 SeatJob {
16091 seat: SeatState::new("impl-A", &spec.id, 1),
16092 spec,
16093 cwd: dir.to_path_buf(),
16094 prompt: "do it".to_owned(),
16095 timeout,
16096 allow_write: true,
16097 sessions: false,
16098 artifacts: dir.join("art"),
16099 stem: "impl-A".to_owned(),
16100 handover: None,
16101 }
16102 }
16103
16104 fn state(resource_wait_secs: u64) -> RunState {
16105 let mut config = Config::default();
16106 config.graph.timeout_resource_wait = resource_wait_secs;
16107 RunState::new(
16108 PathBuf::from("/repo"),
16109 "main".to_owned(),
16110 "abc1234".to_owned(),
16111 "task".to_owned(),
16112 config,
16113 )
16114 }
16115
16116 fn hold(cache: &Path) -> crate::cache::Guard {
16117 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16118 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16119 crate::cache::AcquireOutcome::Acquired(g) => g,
16120 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16121 }
16122 }
16123
16124 async fn run(
16125 jobs: Vec<SeatJob>,
16126 sem: Arc<Semaphore>,
16127 cache: Option<&Path>,
16128 st: &mut RunState,
16129 ) -> Vec<(usize, SeatState, AgentOutcome)> {
16130 let prompts = Prompts::default();
16131 let ctx = WaveCtx {
16132 run: "r",
16133 node: "implement",
16134 prompts: &prompts,
16135 cache,
16136 round: None,
16137 carry_seats: false,
16138 pauses: None,
16139 };
16140 wave(jobs, sem, &ctx, st, 0).await
16141 }
16142
16143 #[tokio::test]
16144 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16145 crate::run::pin_test_home();
16146 let tmp = tempfile::tempdir().unwrap();
16147 let cache = tmp.path().join("target-a");
16148 let guard = hold(&cache);
16149 tokio::spawn(async move {
16151 tokio::time::sleep(Duration::from_millis(1500)).await;
16152 guard.release();
16153 });
16154 let mut st = state(60);
16155 let results = run(
16156 vec![job(
16157 sh("alpha", "echo ok"),
16158 tmp.path(),
16159 Duration::from_secs(1),
16160 )],
16161 Arc::new(Semaphore::new(1)),
16162 Some(&cache),
16163 &mut st,
16164 )
16165 .await;
16166 assert!(
16167 matches!(results[0].2, AgentOutcome::Ok(_)),
16168 "the wait must not be charged to the agent"
16169 );
16170 assert!(st.resource_block.is_none());
16171 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16172 assert!(st.resource_wait.is_none() && st.active.is_empty());
16173 }
16174
16175 #[tokio::test]
16176 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16177 crate::run::pin_test_home();
16178 let tmp = tempfile::tempdir().unwrap();
16179 let cache = tmp.path().join("target-b");
16180 let _held = hold(&cache);
16181 let mut st = state(1);
16182 let results = run(
16183 vec![job(
16184 sh("alpha", "echo ok"),
16185 tmp.path(),
16186 Duration::from_secs(1),
16187 )],
16188 Arc::new(Semaphore::new(1)),
16189 Some(&cache),
16190 &mut st,
16191 )
16192 .await;
16193 let (_, seat, out) = &results[0];
16194 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16195 assert!(wave_blocked(&results));
16196 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16197 assert_eq!(seat.turns, 0, "nothing was asked");
16198 assert!(
16199 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16200 "no agent was spawned"
16201 );
16202 assert!(FailClass::of(out).is_none(), "not a failure class");
16203 let block = st.resource_block.as_ref().expect("recorded for the resume");
16204 assert_eq!(block.kind, "shared build cache");
16205 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16206 assert!(st.active.is_empty());
16207 }
16208
16209 #[tokio::test]
16210 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16211 crate::run::pin_test_home();
16212 let tmp = tempfile::tempdir().unwrap();
16213 let mut st = state(1);
16214 let results = run(
16215 vec![job(
16216 sh("alpha", "echo ok"),
16217 tmp.path(),
16218 Duration::from_secs(1),
16219 )],
16220 Arc::new(Semaphore::new(0)),
16221 None,
16222 &mut st,
16223 )
16224 .await;
16225 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16226 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16227 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16228 }
16229
16230 #[tokio::test]
16231 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16232 crate::run::pin_test_home();
16233 let tmp = tempfile::tempdir().unwrap();
16234 let mut st = state(60);
16235 let results = run(
16236 vec![job(
16237 sh("alpha", "sleep 30"),
16238 tmp.path(),
16239 Duration::from_secs(1),
16240 )],
16241 Arc::new(Semaphore::new(1)),
16242 None,
16243 &mut st,
16244 )
16245 .await;
16246 let out = &results[0].2;
16247 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16248 let class = FailClass::of(out).expect("a failure");
16249 assert_eq!(class, FailClass::Timeout);
16250 assert!(should_hand_over(None, &class), "existing handover policy");
16251 assert!(st.resource_block.is_none());
16252 }
16253}