1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35 AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36 ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51 GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52 MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, ParkCause,
53 QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory,
54 Tally, VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58 ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62const OUTPUT_TAIL: usize = 8_000;
64
65const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106const BASE_SYNC_ROUNDS: usize = 4;
119
120const MAX_FIX_CONTINUATIONS: usize = 2;
136
137#[derive(Clone)]
143struct SeatJob {
144 spec: AgentSpec,
145 seat: SeatState,
146 cwd: PathBuf,
147 prompt: String,
148 timeout: Duration,
149 allow_write: bool,
150 sessions: bool,
151 artifacts: PathBuf,
152 stem: String,
153 handover: Option<String>,
158}
159
160enum AgentOutcome {
172 Ok(AgentOutput),
174 Quota(AgentOutput),
176 Dropped(AgentOutput),
179 Failed(String),
181 ResourceBlocked(String),
187}
188
189#[derive(Debug, Clone, Default)]
216pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<ParkCause>>>);
217
218impl Pause {
219 #[must_use]
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 pub fn park(&self) {
227 self.0.store(true, Ordering::SeqCst);
228 }
229
230 pub fn park_because(&self, reason: impl Into<String>) {
236 self.park_for(ParkCause::Interrupt {
237 reason: reason.into(),
238 });
239 }
240
241 pub fn park_for(&self, cause: ParkCause) {
244 let mut reason_guard = self
245 .1
246 .lock()
247 .unwrap_or_else(std::sync::PoisonError::into_inner);
248 if reason_guard.is_none() {
249 *reason_guard = Some(cause);
250 }
251 drop(reason_guard);
252 self.park();
253 }
254
255 #[must_use]
257 pub fn parked(&self) -> bool {
258 self.0.load(Ordering::SeqCst)
259 }
260
261 #[must_use]
263 pub fn reason(&self) -> Option<String> {
264 self.cause().map(|c| c.label())
265 }
266
267 #[must_use]
269 pub fn cause(&self) -> Option<ParkCause> {
270 self.1
271 .lock()
272 .unwrap_or_else(std::sync::PoisonError::into_inner)
273 .clone()
274 }
275}
276
277pub struct Runner {
279 pub state: RunState,
281 roles: ResolvedRoles,
282 sem: Arc<Semaphore>,
283 pause: Pause,
287 interrupt: Pause,
293}
294
295async fn review_base(
300 repo: &Path,
301 remote: &str,
302 base_branch: &str,
303 base_commit: &str,
304 branch: &str,
305) -> String {
306 review_base_checked(repo, remote, base_branch, base_commit, branch)
307 .await
308 .0
309}
310
311async fn review_base_checked(
316 repo: &Path,
317 remote: &str,
318 base_branch: &str,
319 base_commit: &str,
320 branch: &str,
321) -> (String, bool) {
322 let tracking = format!("{remote}/{base_branch}");
323 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
324 if git::rev_exists(repo, &tracking).await
325 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
326 && !mb.is_empty()
327 {
328 return (mb, fresh);
329 }
330 let mb = git::merge_base(repo, base_commit, branch)
331 .await
332 .ok()
333 .filter(|mb| !mb.is_empty())
334 .unwrap_or_else(|| base_commit.to_owned());
335 (mb, false)
336}
337
338pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
342 if !is_review_run(state) {
343 return;
344 }
345 let base = review_base(
346 &state.repo,
347 &state.config.merge.remote,
348 &state.base_branch,
349 &state.base_commit,
350 branch,
351 )
352 .await;
353 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
354 && !subjects.is_empty()
355 && state.reviewed_commits.as_ref() != Some(&subjects)
356 {
357 state.reviewed_commits = Some(subjects);
358 state.save().ok();
359 }
360}
361
362async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
366 let (base, trusted) = review_base_checked(
367 &state.repo,
368 &state.config.merge.remote,
369 &state.base_branch,
370 &state.base_commit,
371 branch,
372 )
373 .await;
374 if !trusted {
375 return None;
376 }
377 git::subjects(&state.repo, &state.base_commit, &base)
378 .await
379 .ok()
380}
381
382pub(crate) fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
386 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
387 if is_leak(computed) {
388 return false;
389 }
390 let cur = current.trim();
391 cur.is_empty()
392 || cur.starts_with(REVIEW_PROMPT_OPENING)
393 || cur.starts_with("chore: land candidate")
394 || cur.starts_with("magi: candidate")
395 || is_leak(cur)
396}
397
398async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
427 let tracking = format!("{remote}/{branch}");
428 let fetched = git::fetch(repo, remote, branch).await;
429 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
430 let local_exists = git::branch_exists(repo, branch).await?;
431 if !fresh {
432 if !local_exists {
433 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
434 }
435 tracing::warn!(
436 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
437 );
438 return Ok(());
439 }
440 let remote_sha = git::rev_parse(repo, &tracking).await?;
441 if !local_exists {
442 git::git(repo, &["branch", branch, &tracking]).await?;
443 return Ok(());
444 }
445 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
446 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
447 return Ok(());
448 }
449 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
450 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
456 .await?
457 {
458 crate::reconcile::Reconciliation::Pushed => {
459 tracing::warn!(
460 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
461 short(&local_sha),
462 short(&remote_sha)
463 );
464 return Ok(());
465 }
466 crate::reconcile::Reconciliation::Placeholder => {}
467 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
468 }
469 }
470 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
471 if !out.ok() {
472 bail!(
473 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
474 short(&local_sha),
475 short(&remote_sha),
476 out.stderr
477 );
478 }
479 tracing::warn!(
480 "local `{branch}` was stale: fast-forwarded {} -> {}",
481 short(&local_sha),
482 short(&remote_sha)
483 );
484 Ok(())
485}
486
487async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
488 let tracking = format!("{remote}/{base_branch}");
489 let fetched = git::fetch(repo, remote, base_branch).await;
490 if let Ok(out) = &fetched
491 && out.ok()
492 && git::rev_exists(repo, &tracking).await
493 {
494 return git::rev_parse(repo, &tracking).await;
495 }
496 let why = match &fetched {
497 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
498 Ok(_) => format!("{remote} has no {base_branch}"),
499 Err(e) => e.to_string(),
500 };
501 bail!(
504 "cannot read {tracking} ({why}); refusing to branch off the local \
505 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
506 base / remote in magi.toml"
507 )
508}
509
510struct FixClaim {
527 path: PathBuf,
528}
529
530impl FixClaim {
531 fn acquire(dir: &Path) -> Result<Self> {
532 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
533 let path = dir.join("fix.lock");
534 match Self::create(&path) {
535 Ok(claim) => Ok(claim),
536 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
537 if Self::reclaim_if_dead(&path) {
538 Self::create(&path).with_context(|| format!("lock {}", path.display()))
539 } else {
540 bail!(
541 "another `magi fix` is already running for this run ({} exists)",
542 path.display()
543 )
544 }
545 }
546 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
547 }
548 }
549
550 fn create(path: &Path) -> std::io::Result<Self> {
551 let mut f = std::fs::OpenOptions::new()
552 .write(true)
553 .create_new(true)
554 .open(path)?;
555 use std::io::Write as _;
556 writeln!(f, "{}", std::process::id())?;
558 Ok(Self {
559 path: path.to_owned(),
560 })
561 }
562
563 fn reclaim_if_dead(path: &Path) -> bool {
567 let dead = std::fs::read_to_string(path)
568 .ok()
569 .and_then(|body| body.trim().parse::<u32>().ok())
570 .is_some_and(|pid| !crate::proc::pid_alive(pid));
571 dead && std::fs::remove_file(path).is_ok()
572 }
573}
574
575impl Drop for FixClaim {
576 fn drop(&mut self) {
577 let _ = std::fs::remove_file(&self.path);
578 }
579}
580
581impl Runner {
582 pub async fn start(
584 repo: &Path,
585 instruction: String,
586 config: Config,
587 origin: Origin,
588 ) -> Result<Self> {
589 Self::start_naming(repo, instruction, "", config, origin).await
590 }
591
592 pub async fn start_naming(
596 repo: &Path,
597 instruction: String,
598 also_scan: &str,
599 config: Config,
600 origin: Origin,
601 ) -> Result<Self> {
602 let repo = git::toplevel(repo).await?;
603 let missing = agent::missing_programs(&config.agents);
604 if !missing.is_empty() {
605 bail!(
606 "these agent programs are not on PATH: {}. Fix the roster in \
607 magi.toml or install them.",
608 missing.join(", ")
609 );
610 }
611 let base_branch =
612 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
613 .await?;
614 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
615 let roles = config.resolve_roles()?;
616 let max_parallel = config.graph.max_parallel.max(1);
617 let seeds = refs::resolve(
620 &repo,
621 &base_commit,
622 &config.merge.remote,
623 &format!("{also_scan}\n{instruction}"),
624 )
625 .await;
626 refs::plan(&repo, &seeds).await?;
627 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
628 state.origin = Some(origin);
632 for seed in &seeds {
633 state.event(
634 "seed",
635 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
636 );
637 }
638 state.seeds = seeds;
639 state.event("start", format!("run {} created", state.id));
640 state.save()?;
641 Ok(Self {
642 state,
643 roles,
644 sem: Arc::new(Semaphore::new(max_parallel)),
645 pause: Pause::new(),
646 interrupt: Pause::new(),
647 })
648 }
649
650 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
664 Self::review_taking_over(repo, branch, config, None, origin).await
665 }
666
667 pub async fn review_taking_over(
674 repo: &Path,
675 branch: &str,
676 config: Config,
677 takeover: Option<crate::handover::Takeover>,
678 origin: Origin,
679 ) -> Result<Self> {
680 let repo = git::toplevel(repo).await?;
681 let missing = agent::missing_programs(&config.agents);
682 if !missing.is_empty() {
683 bail!(
684 "these agent programs are not on PATH: {}. Fix the roster in \
685 magi.toml or install them.",
686 missing.join(", ")
687 );
688 }
689 let base_branch =
690 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
691 .await?;
692 if base_branch == branch {
693 bail!("`{branch}` is the base branch; there is nothing to review against");
694 }
695 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
696
697 let roles = config.resolve_roles()?;
698 let max_parallel = config.graph.max_parallel.max(1);
699 let mut state = RunState::new(
700 repo.clone(),
701 base_branch,
702 base_commit.clone(),
703 String::new(),
704 config,
705 );
706 state.origin = Some(origin);
707
708 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
715 earlier: Vec::new(),
716 home: crate::run::home(),
717 choice: None,
718 task_instruction: None,
719 });
720 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
721 if let Some(released) = &released {
722 state.event(
723 "release",
724 format!(
725 "took `{branch}` over from run {}: its worktree was released: {}",
726 crate::run::short_of(&released.old_id),
727 released.audit
728 ),
729 );
730 }
731 if let Some(choice) = takeover.choice.as_ref()
735 && let Err(e) =
736 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
737 .await
738 {
739 if let Some(released) = &released {
740 released.restore(&repo, branch).await;
741 }
742 return Err(e.context("applying the owner's answer about the diverged branch"));
743 }
744 for id in takeover.earlier.iter().rev() {
750 if let Ok(old) = RunState::load_under(id, &takeover.home)
751 && let Some(head) = old.pr_head.clone()
752 && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
753 {
754 state.pr_head = Some(head);
755 break;
756 }
757 }
758 let opened = Self::open_review(
759 &repo,
760 branch,
761 state,
762 roles,
763 max_parallel,
764 base_commit,
765 &takeover,
766 )
767 .await;
768 if opened.is_err()
769 && let Some(released) = &released
770 {
771 released.restore(&repo, branch).await;
772 }
773 opened
774 }
775
776 async fn inherit(
781 repo: &Path,
782 branch: &str,
783 base_commit: &str,
784 takeover: &crate::handover::Takeover,
785 ) -> Option<crate::recovery::RecoveryRecord> {
786 use crate::recovery::{Relation, assess};
787 let newest = takeover.earlier.last()?;
788 let mut chosen = None;
789 for id in takeover.earlier.iter().rev() {
790 if let Ok(old) = RunState::load_under(id, &takeover.home)
791 && !old.reviews.is_empty()
792 && (old.winner().is_some_and(|w| w.branch == branch)
793 || old.pr_head.as_deref() == Some(branch))
794 {
795 chosen = Some(old);
796 break;
797 }
798 }
799 let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
800 .await
801 .unwrap_or_default();
802 let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
803 Some(last) if last.head == tip => Relation::Same,
804 Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
805 _ => Relation::Diverged,
806 };
807 let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
808 Some(assess(from, chosen.as_ref(), base_commit, relation))
809 }
810
811 async fn open_review(
814 repo: &Path,
815 branch: &str,
816 mut state: RunState,
817 roles: ResolvedRoles,
818 max_parallel: usize,
819 base_commit: String,
820 takeover: &crate::handover::Takeover,
821 ) -> Result<Self> {
822 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
823 let start = review_base(
826 repo,
827 &state.config.merge.remote,
828 &state.base_branch,
829 &base_commit,
830 branch,
831 )
832 .await;
833 let log = git::log_oneline(repo, &start, branch)
834 .await
835 .unwrap_or_default();
836 let log_text = if log.trim().is_empty() {
837 "(no commit messages)"
838 } else {
839 log.trim()
840 };
841 let mut instruction = format!(
842 "Review the work already on branch `{branch}`. There is no task \
843 statement: what the change claims to do is whatever its commits \
844 say.\n\n{log_text}"
845 );
846 if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
850 if let Some(task) = takeover
851 .task_instruction
852 .as_deref()
853 .filter(|t| !t.trim().is_empty())
854 && rec.reviewed_head.is_some()
855 {
856 instruction = format!(
857 "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
858 task.trim()
859 );
860 rec.criteria_inherited = true;
861 }
862 state.event("recovery", rec.summary());
863 state.recovery = Some(rec);
864 }
865 state.instruction = instruction;
866 state.reviewed_commits = Some(
867 git::subjects(repo, &start, branch)
868 .await
869 .unwrap_or_default(),
870 );
871
872 let worktree = state.worktree_root().join("under-review");
875 if let Some(parent) = worktree.parent() {
876 tokio::fs::create_dir_all(parent).await.ok();
877 }
878 let path = worktree.to_string_lossy().to_string();
879 git::git(repo, &["worktree", "add", &path, branch])
880 .await
881 .with_context(|| {
882 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
883 })?;
884
885 let setup = match ensure_setup_config(&mut state, repo).await {
886 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
887 Err(e) => Err(e),
888 };
889 if let Err(e) = setup {
890 git::worktree_remove(repo, &worktree).await.ok();
891 return Err(e);
892 }
893 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
894 .await
895 .unwrap_or(0);
896 if commits == 0 {
897 git::worktree_remove(repo, &worktree).await.ok();
898 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
899 }
900 let files = git::changed_files(&worktree, &base_commit, "HEAD")
901 .await
902 .map(|f| f.len())
903 .unwrap_or(0);
904 if files == 0
905 && let (Ok(head_tree), Ok(base_tree)) = (
906 git::tree_of(&worktree, "HEAD").await,
907 git::tree_of(&worktree, &base_commit).await,
908 )
909 && head_tree == base_tree
910 {
911 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
912 git::worktree_remove(repo, &worktree).await.ok();
913 bail!(
914 "`{branch}` at {} has a tree identical to base {}; this usually means \
915 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
916 against `{}/{branch}`) rather than an empty change",
917 short(&head),
918 short(&base_commit),
919 state.config.merge.remote
920 );
921 }
922 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
923 .await
924 .unwrap_or_default();
925
926 state.candidates.push(Candidate {
927 index: 0,
928 label: 'A',
929 agent: EXISTING_BRANCH.to_owned(),
932 branch: branch.to_owned(),
933 worktree,
934 summary: String::new(),
935 stat,
936 files,
937 commits,
938 empty: false,
939 failed: None,
940 verified_noop: None,
941 duration_ms: 0,
942 folded: false,
943 });
944 state.tally = Some(Tally {
945 first_choice: BTreeMap::from([('A', 0)]),
946 borda: BTreeMap::new(),
947 winner: 'A',
948 rankings: 0,
949 unanimous_initial: false,
950 deliberated: false,
951 changed_votes: 0,
952 unanimous_final: false,
953 tie_break: None,
954 judges: 0,
958 present: 0,
959 quorum: 0,
960 met_quorum: true,
961 uncontested: Some("review-only run: nothing competed".to_owned()),
962 });
963 state.status = RunStatus::Reviewing;
964 state.event(
965 "start",
966 format!(
967 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
968 state.id
969 ),
970 );
971 state.save()?;
972 Ok(Self {
973 state,
974 roles,
975 sem: Arc::new(Semaphore::new(max_parallel)),
976 pause: Pause::new(),
977 interrupt: Pause::new(),
978 })
979 }
980
981 pub fn resume(id: &str) -> Result<Self> {
983 let state = RunState::load(id)?;
984 if let Some(to) = &state.released_to {
985 bail!(
986 "run {} cannot be resumed: its worktree was released to run {}",
987 state.short(),
988 crate::run::short_of(to)
989 );
990 }
991 let roles = state.config.resolve_roles()?;
992 let max_parallel = state.config.graph.max_parallel.max(1);
993 Ok(Self {
994 state,
995 roles,
996 sem: Arc::new(Semaphore::new(max_parallel)),
997 pause: Pause::new(),
998 interrupt: Pause::new(),
999 })
1000 }
1001
1002 pub async fn execute(&mut self) -> Result<()> {
1009 let result = self.execute_graph().await;
1010 self.mark_driver_exited();
1011 let ended = if result.is_err() {
1012 Some(crate::notices::run_stopped(&self.state.id, &self.state))
1013 } else {
1014 crate::notices::run_ended(&self.state)
1015 };
1016 if let Some(notice) = ended {
1017 crate::notices::raise(notice);
1018 }
1019 result
1020 }
1021
1022 fn mark_driver_exited(&mut self) {
1031 self.state.driver_exited = true;
1032 let pid = std::process::id();
1033 let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1034 return;
1035 };
1036 let Ok(mut disk) = RunState::load(&self.state.id) else {
1037 return;
1038 };
1039 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1040 return;
1041 }
1042 disk.driver_exited = true;
1043 if let Err(e) = disk.save() {
1044 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1045 }
1046 }
1047
1048 fn claim_driver(&mut self) -> Result<()> {
1050 let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1051 let disk = RunState::load(&self.state.id)?;
1052 crate::pr_head::ensure_owner(&disk)?;
1053 self.state.parked = false;
1058 self.state.resource_wait = None;
1059 self.state.resource_block = None;
1060 self.state.clear_active();
1067 if let Some(to) = &disk.released_to {
1085 bail!(
1086 "run {} cannot continue: its worktree was released to run {}",
1087 self.state.short(),
1088 crate::run::short_of(to)
1089 );
1090 }
1091 let pid = std::process::id();
1092 self.state.driver_pid = Some(pid);
1093 self.state.driver_started_at = crate::proc::process_started_at(pid);
1094 self.state.driver_exited = false;
1095 self.state.begin_pass();
1096 self.state.save()?;
1097 Ok(())
1098 }
1099
1100 async fn execute_graph(&mut self) -> Result<()> {
1101 self.claim_driver()?;
1102 if self.state.pr_publication_pending {
1105 self.merge().await?;
1106 if self.resource_halt()? {
1107 return Ok(());
1108 }
1109 self.state.save()?;
1110 self.settle_questions();
1111 return Ok(());
1112 }
1113 if self.state.status == RunStatus::Stalled {
1126 if self.recover_stall().await? {
1127 self.finish_after_tally().await?;
1128 } else {
1129 self.state.save()?;
1131 }
1132 return Ok(());
1133 }
1134 if self.state.status == RunStatus::Landing {
1144 self.run_land().await?;
1145 self.settle_questions();
1150 return Ok(());
1151 }
1152 if self.state.github_text.is_some()
1157 && self.state.merge.is_none()
1158 && self.state.status == RunStatus::Gating
1159 {
1160 self.merge().await?;
1161 if self.resource_halt()? {
1162 return Ok(());
1163 }
1164 self.state.save()?;
1165 return Ok(());
1166 }
1167 self.prep().await?;
1168 if self.park_here()? {
1169 return Ok(());
1170 }
1171 self.advise().await?;
1172 if self.park_here()? {
1173 return Ok(());
1174 }
1175 self.implement().await?;
1176 if self.park_here()? {
1177 return Ok(());
1178 }
1179 if self.state.status == RunStatus::VerifiedNoop {
1183 return Ok(());
1184 }
1185 self.judge().await?;
1186 if self.park_here()? {
1187 return Ok(());
1188 }
1189 self.deliberate().await?;
1190 if self.park_here()? {
1191 return Ok(());
1192 }
1193 self.vote().await?;
1194 if self.park_here()? {
1195 return Ok(());
1196 }
1197 self.tally()?;
1198 if self.state.status == RunStatus::Stalled {
1203 self.state.save()?;
1207 return Ok(());
1208 }
1209 self.finish_after_tally().await?;
1210 Ok(())
1211 }
1212
1213 fn stop_for_resource(&mut self) -> Result<()> {
1218 if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1220 {
1221 self.state.status = RunStatus::Blocked;
1222 }
1223 self.state.save()
1224 }
1225
1226 fn resource_halt(&mut self) -> Result<bool> {
1228 if self.state.resource_block.is_none() {
1229 return Ok(false);
1230 }
1231 self.stop_for_resource()?;
1232 Ok(true)
1233 }
1234
1235 fn park_here(&mut self) -> Result<bool> {
1242 let parking = self.pause.parked() || self.interrupt.parked();
1243 if !parking && self.state.resource_block.is_some() {
1244 self.stop_for_resource()?;
1245 return Ok(true);
1246 }
1247 if !self.pause.parked() && !self.interrupt.parked() {
1252 return Ok(false);
1253 }
1254 let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1255 let why = match cause.as_ref().map(ParkCause::label) {
1256 Some(reason) => format!(
1257 "parked after `{}` ({reason}) — resume to carry on from here",
1258 self.state.status.as_str()
1259 ),
1260 None => format!(
1261 "parked after `{}` — resume to carry on from here",
1262 self.state.status.as_str()
1263 ),
1264 };
1265 self.state.event("park", why);
1266 self.state
1267 .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1268 self.state.parked = true;
1269 self.state.save()?;
1270 Ok(true)
1271 }
1272
1273 pub fn on_pause(&mut self, pause: Pause) {
1275 self.pause = pause;
1276 }
1277
1278 pub fn watch_interrupt(&mut self, pause: Pause) {
1284 self.interrupt = pause;
1285 }
1286
1287 fn settle_questions(&mut self) {
1307 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1308 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1309 }
1310 }
1311
1312 async fn finish_after_tally(&mut self) -> Result<()> {
1315 self.graft().await?;
1318 if self.resource_halt()? {
1319 return Ok(());
1320 }
1321 self.fold_losers().await?;
1322 self.sync_to_base().await?;
1327 if self.state.status == RunStatus::AlreadyInBase {
1328 return Ok(());
1329 }
1330 self.review_loop().await?;
1331 if self.resource_halt()? {
1332 return Ok(());
1333 }
1334 self.sync_to_base().await?;
1335 if self.state.status == RunStatus::AlreadyInBase {
1336 return Ok(());
1337 }
1338 self.gate().await?;
1339 self.merge().await?;
1340 if self.resource_halt()? {
1341 return Ok(());
1342 }
1343 self.state.save()?;
1344 Ok(())
1345 }
1346
1347 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1354 let result = match ensure_setup_config(&mut self.state, repo).await {
1355 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1356 Err(e) => Err(e),
1357 };
1358 if let Err(e) = result {
1359 git::worktree_remove(repo, wt).await.ok();
1360 if in_prep {
1361 self.rollback_prep(repo).await;
1362 }
1363 self.state.status = RunStatus::Blocked;
1364 self.state.event(
1365 "setup",
1366 format!("worktree setup failed in {}: {e:#}", wt.display()),
1367 );
1368 self.state.save()?;
1369 return Err(e);
1370 }
1371 Ok(())
1372 }
1373
1374 async fn rollback_prep(&mut self, repo: &Path) {
1376 let root = self.state.worktree_root();
1377 for c in std::mem::take(&mut self.state.candidates) {
1378 git::worktree_remove(repo, &c.worktree).await.ok();
1379 git::branch_delete(repo, &c.branch).await.ok();
1380 }
1381 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1382 for j in 1..=seats {
1383 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1384 .await
1385 .ok();
1386 }
1387 for k in 1..=self.state.config.graph.advisors {
1388 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1389 .await
1390 .ok();
1391 }
1392 }
1393
1394 async fn prep(&mut self) -> Result<()> {
1395 if !self.state.candidates.is_empty() {
1396 return Ok(());
1397 }
1398 self.state.status = RunStatus::Prep;
1399 let repo = self.state.repo.clone();
1400 let base = self.state.base_commit.clone();
1401 let plan = refs::plan(&repo, &self.state.seeds).await?;
1402 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1403 let root = self.state.worktree_root();
1404 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1405
1406 let hooks_dir = self.state.dir().join("hooks");
1409 if self.state.config.blind.commit_msg_hook {
1410 std::fs::create_dir_all(&hooks_dir)
1411 .with_context(|| format!("create {}", hooks_dir.display()))?;
1412 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1413 let path = hooks_dir.join("commit-msg");
1414 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1415 make_executable(&path)?;
1416 git::acquire_worktree_config(&repo).await?;
1424 self.state.enabled_worktree_config = true;
1425 }
1426
1427 ensure_setup_config(&mut self.state, &repo).await?;
1430
1431 for (index, (spec, label)) in self
1432 .roles
1433 .implementers
1434 .clone()
1435 .into_iter()
1436 .zip(labels)
1437 .enumerate()
1438 {
1439 let branch = self.state.branch_for(label);
1440 let worktree = root.join(format!("cand-{label}"));
1441 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1442 if self.state.config.blind.commit_msg_hook {
1443 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1444 }
1445 git::local_exclude(&worktree, "/.magi/").await?;
1446 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1447 git::worktree_remove(&repo, &worktree).await.ok();
1450 git::branch_delete(&repo, &branch).await.ok();
1451 self.rollback_prep(&repo).await;
1452 self.state.status = RunStatus::Blocked;
1453 self.state
1454 .event("prep", format!("worktree setup failed: {e:#}"));
1455 self.state.save()?;
1456 return Err(e);
1457 }
1458 for pick in &plan.picks {
1459 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1460 self.state.status = RunStatus::Blocked;
1461 self.state
1462 .event("prep", format!("cannot apply referenced commit: {e}"));
1463 self.state.save()?;
1464 return Err(e);
1465 }
1466 }
1467 self.state.candidates.push(Candidate {
1468 index,
1469 label,
1470 agent: spec.id.clone(),
1471 branch,
1472 worktree,
1473 summary: String::new(),
1474 stat: String::new(),
1475 files: 0,
1476 commits: 0,
1477 empty: false,
1478 failed: None,
1479 verified_noop: None,
1480 duration_ms: 0,
1481 folded: false,
1482 });
1483 }
1484
1485 for j in 1..=self.roles.judges.len() {
1486 let wt = root.join(format!("judge-{j}"));
1487 if !wt.exists() {
1488 git::worktree_add_detached(&repo, &wt, &base).await?;
1489 self.setup_seat_worktree(&repo, &wt, true).await?;
1490 }
1491 }
1492
1493 if self.state.config.graph.advise {
1501 for k in 1..=self.state.config.graph.advisors {
1502 let wt = root.join(format!("advisor-{k}"));
1503 if !wt.exists() {
1504 git::worktree_add_detached(&repo, &wt, &base).await?;
1505 self.setup_seat_worktree(&repo, &wt, true).await?;
1506 }
1507 }
1508 }
1509
1510 let authors: Vec<&str> = self
1515 .roles
1516 .implementers
1517 .iter()
1518 .map(|a| a.id.as_str())
1519 .collect();
1520 let overlap: Vec<String> = self
1521 .roles
1522 .judges
1523 .iter()
1524 .enumerate()
1525 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1526 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1527 .collect();
1528 if !overlap.is_empty() {
1529 let note = format!(
1530 "{} also authored a candidate; blind, but the panel is less \
1531 independent than {} distinct agents would be",
1532 overlap.join(", "),
1533 self.roles.judges.len()
1534 );
1535 self.state.event("prep", note);
1536 }
1537
1538 self.state.event(
1539 "prep",
1540 format!(
1541 "{} candidates, {} judges, base {} ({})",
1542 self.state.candidates.len(),
1543 self.roles.judges.len(),
1544 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1545 self.state.base_branch
1546 ),
1547 );
1548 self.state.status = RunStatus::Implementing;
1549 self.state.save()?;
1550 Ok(())
1551 }
1552
1553 async fn advise(&mut self) -> Result<()> {
1586 let implement_untouched = self
1587 .state
1588 .candidates
1589 .iter()
1590 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1591 if !self.state.config.graph.advise || self.state.advise_attempted {
1592 return Ok(());
1593 }
1594 if !implement_untouched {
1595 self.state.event(
1596 "advise",
1597 "skipping the design-deliberation stage: at least one \
1598 candidate already shows implementation progress, so this \
1599 run is past the point the stage exists to run before"
1600 .to_owned(),
1601 );
1602 self.state.advise_attempted = true;
1603 self.state.save()?;
1604 return Ok(());
1605 }
1606 let run_id = self.state.id.clone();
1607 let prompts = self.state.config.prompts.clone();
1608 let instruction = self.state.instruction.clone();
1609 let language = self.state.config.graph.language.clone();
1610 let root = self.state.worktree_root();
1611 let n = self.state.config.graph.advisors;
1612 let where_recorded = self.state.dir().join("run.json");
1613
1614 let seats = match self.state.config.advisors() {
1615 Ok(seats) if !seats.is_empty() => seats,
1616 Ok(_) => {
1617 self.state.event(
1618 "advise",
1619 format!(
1620 "[graph] advisors is 0; skipping the design-deliberation \
1621 stage and continuing without a synthesis brief (see {})",
1622 where_recorded.display()
1623 ),
1624 );
1625 self.state.advise_attempted = true;
1626 self.state.save()?;
1627 return Ok(());
1628 }
1629 Err(e) => {
1630 self.state.event(
1631 "advise",
1632 format!(
1633 "could not resolve advisor seats ({e:#}); continuing \
1634 without a design-deliberation brief (see {})",
1635 where_recorded.display()
1636 ),
1637 );
1638 self.state.advise_attempted = true;
1639 self.state.save()?;
1640 return Ok(());
1641 }
1642 };
1643
1644 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1645 let artifacts = agent::artifacts_dir(&self.state.dir());
1646 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1647
1648 let mut jobs = Vec::new();
1649 for (i, spec) in seats.iter().cloned().enumerate() {
1650 let seat_key = format!("advisor-{}", i + 1);
1651 let seat = self.seat(&seat_key, &spec.id);
1652 jobs.push(SeatJob {
1653 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1654 spec,
1655 seat,
1656 cwd: worktrees[i % worktrees.len()].clone(),
1657 timeout,
1658 allow_write: false,
1659 sessions: false,
1660 artifacts: artifacts.clone(),
1661 stem: seat_key,
1662 handover: None,
1663 });
1664 }
1665
1666 self.state.event(
1667 "advise",
1668 format!(
1669 "{} advisor seat(s) sketching a design in parallel",
1670 jobs.len()
1671 ),
1672 );
1673 let mut quota_losses = Vec::new();
1674 let cache = self.state.config.cache_dir();
1675 let ctx = WaveCtx {
1676 carry_seats: false,
1677 pauses: Some((&self.pause, &self.interrupt)),
1678 run: &run_id,
1679 node: "advise",
1680 prompts: &prompts,
1681 cache: cache.as_deref(),
1682 round: None,
1683 };
1684 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1685 let results = ask_json_wave::<Proposal>(
1686 jobs,
1687 Arc::clone(&self.sem),
1688 self.state.config.graph.retries,
1689 &advisor_roster,
1690 &ctx,
1691 &mut quota_losses,
1692 &mut self.state,
1693 &|p: &Proposal| p.validate(),
1694 )
1695 .await;
1696 if self.state.resource_block.is_some() {
1697 return self.stop_for_resource();
1698 }
1699 self.state.quota.extend(quota_losses);
1700
1701 let mut records = Vec::with_capacity(results.len());
1702 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1703 let agent_id = seat.agent.clone();
1704 self.state.seats.insert(seat.key.clone(), seat);
1705 match res {
1706 Ok((proposal, out)) => {
1707 self.state
1708 .event("advise", format!("advisor-{} proposed a design", i + 1));
1709 records.push(advise::AdvisorRecord::proposed(
1710 i + 1,
1711 agent_id,
1712 proposal,
1713 out.duration_ms,
1714 ));
1715 }
1716 Err(e) => {
1717 self.state.event(
1718 "advise",
1719 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1720 );
1721 records.push(advise::AdvisorRecord::failed(
1722 i + 1,
1723 agent_id,
1724 e.to_string(),
1725 ));
1726 }
1727 }
1728 }
1729
1730 let mut advice = advise::Advice {
1731 records,
1732 synthesis: None,
1733 };
1734 if advice.proposals().is_empty() {
1735 self.state.event(
1736 "advise",
1737 "no advisor produced a usable proposal; continuing without a \
1738 synthesis brief"
1739 .to_owned(),
1740 );
1741 } else {
1742 match self
1743 .synthesize_brief(
1744 &advice,
1745 &instruction,
1746 &language,
1747 &worktrees[0],
1748 &artifacts,
1749 &run_id,
1750 &prompts,
1751 cache.as_deref(),
1752 )
1753 .await
1754 {
1755 Ok(Some(text)) => {
1756 self.state.event(
1757 "advise",
1758 "synthesized a design brief for the implementer".to_owned(),
1759 );
1760 advice.synthesis = Some(text);
1761 }
1762 Ok(None) => {
1763 self.state.event(
1764 "advise",
1765 "the synthesis seat produced nothing usable; continuing \
1766 without a design brief"
1767 .to_owned(),
1768 );
1769 }
1770 Err(e) => {
1771 self.state.event(
1772 "advise",
1773 format!("could not synthesize a design brief: {e:#}"),
1774 );
1775 }
1776 }
1777 }
1778 advise::apply_reflection(&mut advice);
1779
1780 self.state.advice = Some(advice);
1781 self.state.advise_attempted = true;
1782 self.state.save()?;
1783 Ok(())
1784 }
1785
1786 #[allow(clippy::too_many_arguments)]
1798 async fn synthesize_brief(
1799 &mut self,
1800 advice: &advise::Advice,
1801 instruction: &str,
1802 language: &str,
1803 cwd: &Path,
1804 artifacts: &Path,
1805 run_id: &str,
1806 prompts: &Prompts,
1807 cache: Option<&Path>,
1808 ) -> Result<Option<String>> {
1809 let chain = agent::pick_chain(
1810 &self.state.config.agents,
1811 self.state.config.roles.synthesizer.as_ref(),
1812 &agent::installed,
1813 "synthesizer",
1814 )?;
1815 let proposals = advice.proposals();
1816 let mut prompt = prompt::with_overlay(
1817 prompt::synthesize_brief(instruction, &proposals, language),
1818 prompts.overlay("advise"),
1819 );
1820 if cache.is_some() {
1821 prompt.push('\n');
1826 prompt.push_str(&prompt::build_cache_note("advise", false));
1827 }
1828 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1829 let mut last = None;
1834 for (n, spec) in chain.iter().enumerate() {
1835 if n > 0 {
1836 self.state
1837 .event("advise", format!("synthesis falling back to {}", spec.id));
1838 }
1839 let mut seat = self.seat("advise-synthesis", &spec.id);
1840 let outcome = agent::invoke(
1841 spec,
1842 &mut seat,
1843 &Invocation {
1844 cwd,
1845 prompt: &prompt,
1846 timeout,
1847 allow_write: false,
1848 unsandboxed: false,
1849 sessions: false,
1850 artifacts,
1851 stem: &if n == 0 {
1852 "advise-synthesis".to_owned()
1853 } else {
1854 format!("advise-synthesis-{}", spec.id)
1855 },
1856 run: run_id,
1857 node: "advise",
1858 cache_dir: None,
1859 attachments: &[],
1860 writable: &[],
1861 },
1862 )
1863 .await;
1864 if outcome.is_ok() {
1865 self.state.seats.insert(seat.key.clone(), seat);
1866 }
1867 let advance = agent::chain_advances(&outcome);
1868 last = Some(outcome);
1869 if !advance {
1870 break;
1871 }
1872 }
1873 let out = last.expect("a chain holds at least one agent")?;
1876 if !out.usable() {
1877 return Ok(None);
1878 }
1879 let text =
1880 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1881 Ok((!text.trim().is_empty()).then_some(text))
1882 }
1883
1884 async fn implement(&mut self) -> Result<()> {
1887 let run_id = self.state.id.clone();
1892 let prompts = self.state.config.prompts.clone();
1893 let todo: Vec<usize> = self
1894 .state
1895 .candidates
1896 .iter()
1897 .enumerate()
1898 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1899 .map(|(i, _)| i)
1900 .collect();
1901 if todo.is_empty() {
1902 return self.after_implement();
1903 }
1904 self.state.status = RunStatus::Implementing;
1905
1906 let language = self.state.config.graph.language.clone();
1907 let github_language = self.state.config.graph.github_language.clone();
1908 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1909 let sessions = self.state.config.graph.sessions;
1910 let artifacts = agent::artifacts_dir(&self.state.dir());
1911 let brief = self
1915 .state
1916 .advice
1917 .as_ref()
1918 .and_then(|a| a.synthesis.as_deref())
1919 .map(str::to_owned);
1920 let attachments = self.state.attachments.clone();
1921
1922 let mut jobs = Vec::new();
1923 for &i in &todo {
1924 let (index, label, worktree) = {
1925 let c = &self.state.candidates[i];
1926 (c.index, c.label, c.worktree.clone())
1927 };
1928 let spec = self.roles.implementers[index].clone();
1929 let seat_key = format!("impl-{label}");
1930 let seat = self.seat(&seat_key, &spec.id);
1931 let instruction = seeded_instruction(&self.state);
1932 jobs.push(SeatJob {
1933 spec,
1934 seat,
1935 prompt: prompt::implement_in(
1936 &instruction,
1937 &worktree.to_string_lossy(),
1938 &language,
1939 &github_language,
1940 brief.as_deref(),
1941 &attachments,
1942 ),
1943 cwd: worktree,
1944 timeout,
1945 allow_write: true,
1946 sessions,
1947 artifacts: artifacts.clone(),
1948 stem: format!("impl-{label}"),
1949 handover: None,
1950 });
1951 }
1952
1953 self.state.event(
1954 "implement",
1955 format!("{} candidates in parallel", jobs.len()),
1956 );
1957 let mut sent = jobs.clone();
1963 let cache = self.state.config.cache_dir();
1964 let ctx = WaveCtx {
1965 carry_seats: false,
1966 pauses: Some((&self.pause, &self.interrupt)),
1967 run: &run_id,
1968 node: "implement",
1969 prompts: &prompts,
1970 cache: cache.as_deref(),
1971 round: None,
1972 };
1973 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1974 if wave_blocked(&results) {
1975 return self.stop_for_resource();
1976 }
1977 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1978 .await;
1979 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1980 .await;
1981 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1982 .await;
1983 if self.state.resource_block.is_some() {
1984 return self.stop_for_resource();
1985 }
1986
1987 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1988 let seat_key = seat.key.clone();
1989 let agent = seat.agent.clone();
1998 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1999 self.state.seats.insert(seat.key.clone(), seat);
2000 let label = self.state.candidates[i].label;
2001 let worktree = self.state.candidates[i].worktree.clone();
2002 let base = self.state.base_commit.clone();
2003
2004 let (summary, duration, failed, verified_claim) = match out {
2005 AgentOutcome::Ok(o) => {
2006 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2007 let failed = (!o.usable()).then(|| {
2008 if o.timed_out {
2009 "agent timed out".to_owned()
2010 } else {
2011 format!("agent exited with {:?}", o.exit_code)
2012 }
2013 });
2014 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2015 (text, o.duration_ms, failed, verified_claim)
2016 }
2017 AgentOutcome::Dropped(o) => {
2023 let why = o
2024 .dropped
2025 .as_ref()
2026 .map(|d| d.why.as_str())
2027 .unwrap_or("the CLI ended the stream without delivering its answer");
2028 (
2029 String::new(),
2030 o.duration_ms,
2031 Some(format!("the CLI dropped the stream ({why})")),
2032 None,
2033 )
2034 }
2035 AgentOutcome::Quota(o) => {
2036 self.state.quota.push(QuotaLoss {
2037 seat: seat_key,
2038 node: "implement".to_owned(),
2039 at: Timestamp::now(),
2040 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2041 });
2042 (
2043 String::new(),
2044 o.duration_ms,
2045 Some("rate limited (quota); produced no change".to_owned()),
2046 None,
2047 )
2048 }
2049 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2050 (String::new(), 0, Some(e), None)
2051 }
2052 };
2053
2054 let rescued = match git::rescue_commit(
2057 &worktree,
2058 &format!("magi: candidate {label} (uncommitted work)"),
2059 )
2060 .await
2061 {
2062 Ok(r) => {
2063 self.state.note_withheld("implement", &r.withheld);
2064 r.committed
2065 }
2066 Err(_) => false,
2067 };
2068 let commits = git::commits_ahead(&worktree, &base, "HEAD")
2069 .await
2070 .unwrap_or(0);
2071 let patch = git::diff(&worktree, &base, "HEAD")
2072 .await
2073 .unwrap_or_default();
2074 let stat = git::diff_stat(&worktree, &base, "HEAD")
2075 .await
2076 .unwrap_or_default();
2077 let files = git::changed_files(&worktree, &base, "HEAD")
2078 .await
2079 .map(|f| f.len())
2080 .unwrap_or(0);
2081 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2082
2083 let c = &mut self.state.candidates[i];
2084 if !exhausted_the_fallback_chain {
2085 c.agent = agent;
2086 }
2087 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2088 c.stat = stat;
2089 c.files = files;
2090 c.commits = commits;
2091 c.duration_ms = duration;
2092 c.empty = commits == 0 || patch.trim().is_empty();
2093 c.failed = match failed {
2096 Some(_) if c.empty => failed,
2097 _ => None,
2098 };
2099 c.verified_noop = if c.empty { verified_claim } else { None };
2104 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2105 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2106 (None, true, Some(_), _) => {
2107 format!("candidate {label}: no change produced (agent-verified no-op)")
2108 }
2109 (None, true, None, _) => format!("candidate {label}: no change produced"),
2110 (None, false, _, true) => {
2111 format!(
2112 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2113 )
2114 }
2115 (None, false, _, false) => {
2116 format!("candidate {label}: {files} files, {commits} commits")
2117 }
2118 };
2119 self.state.event("implement", note);
2120 self.state.save()?;
2121 }
2122
2123 self.after_implement()
2124 }
2125
2126 async fn resume_undelivered(
2154 &mut self,
2155 results: &mut [(usize, SeatState, AgentOutcome)],
2156 sent: &[SeatJob],
2157 prompts: &Prompts,
2158 run_id: &str,
2159 ) {
2160 for (wi, seat, out) in results.iter_mut() {
2161 let Some(dropped) = (match &*out {
2162 AgentOutcome::Dropped(o) => o.dropped.clone(),
2163 _ => None,
2164 }) else {
2165 continue;
2166 };
2167 let Some(job) = sent.get(*wi) else { continue };
2168 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2170 self.state.event(
2171 "implement",
2172 format!(
2173 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2174 work is in the tree",
2175 seat.key, dropped.output_tokens, dropped.why
2176 ),
2177 );
2178 continue;
2179 }
2180 if !has_context(&job.spec, seat, job.sessions) {
2188 self.state.event(
2189 "implement",
2190 format!(
2191 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2192 is no session left to resume",
2193 seat.key, dropped.output_tokens, dropped.why
2194 ),
2195 );
2196 continue;
2197 }
2198 self.state.event(
2199 "implement",
2200 format!(
2201 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2202 conversation",
2203 seat.key, dropped.output_tokens, dropped.why
2204 ),
2205 );
2206 let mut retry = job.clone();
2207 retry.seat = seat.clone();
2208 retry.prompt = prompt::resume_after_drop(&dropped.why);
2209 retry.timeout = retry_budget(job.timeout, true);
2210 retry.stem = format!("{}-resume", job.stem);
2211 let cache = self.state.config.cache_dir();
2212 let ctx = WaveCtx {
2213 carry_seats: false,
2214 pauses: Some((&self.pause, &self.interrupt)),
2215 run: run_id,
2216 node: "implement",
2217 prompts,
2218 cache: cache.as_deref(),
2219 round: None,
2220 };
2221 let (resumed_seat, resumed) =
2222 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2223 *seat = resumed_seat;
2224 *out = resumed;
2225 }
2226 }
2227
2228 async fn resume_seat_handovers(
2293 &mut self,
2294 results: &mut [(usize, SeatState, AgentOutcome)],
2295 sent: &mut [SeatJob],
2296 prompts: &Prompts,
2297 run_id: &str,
2298 ) {
2299 let instruction = seeded_instruction(&self.state);
2300 let language = self.state.config.graph.language.clone();
2301 let github_language = self.state.config.graph.github_language.clone();
2302 let brief = self
2303 .state
2304 .advice
2305 .as_ref()
2306 .and_then(|a| a.synthesis.as_deref())
2307 .map(str::to_owned);
2308 let attachments = self.state.attachments.clone();
2309 for (wi, seat, out) in results.iter_mut() {
2310 let others: BTreeSet<String> = sent
2313 .iter()
2314 .enumerate()
2315 .filter(|(j, _)| j != wi)
2316 .map(|(_, j)| j.spec.id.clone())
2317 .collect();
2318 let Some(job) = sent.get_mut(*wi) else {
2319 continue;
2320 };
2321 let start = self
2326 .roles
2327 .implementer_roster
2328 .iter()
2329 .position(|s| s.id == job.spec.id)
2330 .unwrap_or(0);
2331 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2332 let mut fallback_attempt = 0usize;
2333 let mut prev: Option<FailClass> = None;
2334 while let Some(cur) = FailClass::of(&*out) {
2335 if !should_hand_over(prev.as_ref(), &cur) {
2336 break;
2337 }
2338 let Some(next) =
2339 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2340 .cloned()
2341 else {
2342 break;
2343 };
2344 tried.insert(next.id.clone());
2345 fallback_attempt += 1;
2346
2347 if let Ok(r) = git::rescue_commit(
2348 &job.cwd,
2349 &format!(
2350 "magi: candidate {} (uncommitted work before {} fallback)",
2351 seat.key,
2352 if cur == FailClass::Quota {
2353 "quota"
2354 } else {
2355 "handover"
2356 }
2357 ),
2358 )
2359 .await
2360 {
2361 self.state.note_withheld("implement", &r.withheld);
2362 }
2363
2364 record_handover(
2365 &mut self.state,
2366 "implement",
2367 &seat.key,
2368 &seat.agent,
2369 &next.id,
2370 &cur,
2371 &fail_reason(&*out),
2372 );
2373 prev = Some(cur.clone());
2374
2375 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2376 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2377 job.spec = next.clone();
2385 let mut retry = job.clone();
2386 retry.seat = new_seat;
2387 retry.prompt = prompt::implement_in(
2388 &instruction,
2389 &job.cwd.to_string_lossy(),
2390 &language,
2391 &github_language,
2392 brief.as_deref(),
2393 &attachments,
2394 );
2395 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2396 let cache = self.state.config.cache_dir();
2397 let ctx = WaveCtx {
2398 carry_seats: false,
2399 pauses: Some((&self.pause, &self.interrupt)),
2400 run: run_id,
2401 node: "implement",
2402 prompts,
2403 cache: cache.as_deref(),
2404 round: None,
2405 };
2406 let (fallback_seat, fallback_out) = run_one(
2407 retry,
2408 Arc::clone(&self.sem),
2409 &ctx,
2410 &mut self.state,
2411 fallback_attempt,
2412 )
2413 .await;
2414 *seat = fallback_seat;
2415 *out = fallback_out;
2416 }
2417 }
2418 }
2419
2420 async fn resume_unconfirmed_commands(
2444 &mut self,
2445 results: &mut [(usize, SeatState, AgentOutcome)],
2446 sent: &[SeatJob],
2447 prompts: &Prompts,
2448 run_id: &str,
2449 ) {
2450 for (wi, seat, out) in results.iter_mut() {
2451 let AgentOutcome::Ok(o) = &*out else {
2452 continue;
2453 };
2454 if !has_unconfirmed_command(&o.commands) {
2455 continue;
2456 }
2457 let Some(job) = sent.get(*wi) else { continue };
2458 if !has_context(&job.spec, seat, job.sessions) {
2459 self.state.event(
2460 "implement",
2461 format!(
2462 "{}: the reply named a command whose own CLI never confirmed the exit \
2463 status of, but there is no session left to resume",
2464 seat.key
2465 ),
2466 );
2467 continue;
2468 }
2469 self.state.event(
2470 "implement",
2471 format!(
2472 "{}: the reply named a command whose own CLI never confirmed the exit \
2473 status of; resuming the conversation",
2474 seat.key
2475 ),
2476 );
2477 let mut retry = job.clone();
2478 retry.seat = seat.clone();
2479 retry.prompt = prompt::resume_incomplete(
2480 "a command in your last reply had no confirmed exit status",
2481 );
2482 retry.timeout = retry_budget(job.timeout, true);
2483 retry.stem = format!("{}-confirm", job.stem);
2484 let cache = self.state.config.cache_dir();
2485 let ctx = WaveCtx {
2486 carry_seats: false,
2487 pauses: Some((&self.pause, &self.interrupt)),
2488 run: run_id,
2489 node: "implement",
2490 prompts,
2491 cache: cache.as_deref(),
2492 round: None,
2493 };
2494 let (resumed_seat, resumed) =
2495 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2496 *seat = resumed_seat;
2497 *out = resumed;
2498 }
2499 }
2500
2501 async fn continue_fix_report(
2522 &mut self,
2523 mut seat: SeatState,
2524 parse_err: String,
2525 job: &SeatJob,
2526 prompts: &Prompts,
2527 run_id: &str,
2528 round: usize,
2529 ) -> (
2530 SeatState,
2531 Option<FixReport>,
2532 Option<String>,
2533 ContinuationRecord,
2534 ) {
2535 let mut last_err = parse_err;
2536 let mut cumulative_wait_ms = 0u64;
2537 let mut attempts = 0usize;
2538 loop {
2539 if !has_context(&job.spec, &seat, job.sessions) {
2540 self.state.event(
2541 "fix",
2542 format!(
2543 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2544 session left to resume into"
2545 ),
2546 );
2547 let outcome = if attempts == 0 {
2548 ContinuationOutcome::NoSession
2549 } else {
2550 ContinuationOutcome::Exhausted
2551 };
2552 return (
2553 seat,
2554 None,
2555 Some(format!("unparsable fix report: {last_err}")),
2556 ContinuationRecord {
2557 attempts,
2558 cumulative_wait_ms,
2559 outcome,
2560 },
2561 );
2562 }
2563 if attempts >= MAX_FIX_CONTINUATIONS {
2564 self.state.event(
2565 "fix",
2566 format!(
2567 "round {round}: fixer's reply still had no adoption report after \
2568 {attempts} continuation(s) ({last_err}); giving up"
2569 ),
2570 );
2571 return (
2572 seat,
2573 None,
2574 Some(format!(
2575 "unparsable fix report after {attempts} continuation(s): {last_err}"
2576 )),
2577 ContinuationRecord {
2578 attempts,
2579 cumulative_wait_ms,
2580 outcome: ContinuationOutcome::Exhausted,
2581 },
2582 );
2583 }
2584 attempts += 1;
2585 self.state.event(
2586 "fix",
2587 format!(
2588 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2589 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2590 ),
2591 );
2592 let mut retry = job.clone();
2593 retry.seat = seat.clone();
2594 retry.prompt = prompt::resume_incomplete(&last_err);
2595 retry.timeout = retry_budget(job.timeout, true);
2596 retry.stem = format!("{}-continue{attempts}", job.stem);
2597 let cache = self.state.config.cache_dir();
2598 let ctx = WaveCtx {
2599 carry_seats: false,
2600 pauses: Some((&self.pause, &self.interrupt)),
2601 run: run_id,
2602 node: "fix",
2603 prompts,
2604 cache: cache.as_deref(),
2605 round: Some(round),
2606 };
2607 let (resumed_seat, resumed_out) = run_one(
2608 retry,
2609 Arc::clone(&self.sem),
2610 &ctx,
2611 &mut self.state,
2612 attempts,
2613 )
2614 .await;
2615 seat = resumed_seat;
2616 match resumed_out {
2617 AgentOutcome::Ok(o) => {
2618 cumulative_wait_ms += o.duration_ms;
2619 match verdict::extract_json::<FixReport>(&o.text) {
2620 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2621 self.state.event(
2622 "fix",
2623 format!(
2624 "round {round}: fixer's adoption report recovered after \
2625 {attempts} continuation(s)"
2626 ),
2627 );
2628 return (
2629 seat,
2630 Some(report),
2631 None,
2632 ContinuationRecord {
2633 attempts,
2634 cumulative_wait_ms,
2635 outcome: ContinuationOutcome::Resumed,
2636 },
2637 );
2638 }
2639 Ok(_) => {
2647 last_err = "the reply parsed, but it reported a command whose own CLI \
2648 never confirmed an exit status"
2649 .to_owned();
2650 }
2651 Err(e) => last_err = e.to_string(),
2652 }
2653 }
2654 AgentOutcome::Quota(o) => {
2655 cumulative_wait_ms += o.duration_ms;
2656 self.state.quota.push(QuotaLoss {
2657 seat: seat.key.clone(),
2658 node: "fix".to_owned(),
2659 at: Timestamp::now(),
2660 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2661 });
2662 self.state.event(
2663 "fix",
2664 format!(
2665 "round {round}: continuation rate limited (quota); not retrying now"
2666 ),
2667 );
2668 return (
2669 seat,
2670 None,
2671 Some("rate limited (quota) while recovering the fix report".to_owned()),
2672 ContinuationRecord {
2673 attempts,
2674 cumulative_wait_ms,
2675 outcome: ContinuationOutcome::QuotaLost,
2676 },
2677 );
2678 }
2679 AgentOutcome::Dropped(o) => {
2680 cumulative_wait_ms += o.duration_ms;
2681 let why = o
2682 .dropped
2683 .as_ref()
2684 .map(|d| d.why.as_str())
2685 .unwrap_or("the CLI ended the stream without delivering its answer");
2686 last_err = format!("the CLI dropped the stream ({why})");
2687 }
2688 AgentOutcome::Failed(e) => last_err = e,
2689 AgentOutcome::ResourceBlocked(e) => {
2692 return (
2693 seat,
2694 None,
2695 Some(format!("the continuation was not started: {e}")),
2696 ContinuationRecord {
2697 attempts: attempts.saturating_sub(1),
2698 cumulative_wait_ms,
2699 outcome: ContinuationOutcome::NoSession,
2700 },
2701 );
2702 }
2703 }
2704 }
2705 }
2706
2707 fn after_implement(&mut self) -> Result<()> {
2708 if self.state.leaks.is_empty() {
2710 let cfg = self.state.config.blind.clone();
2711 let mut leaks = Vec::new();
2712 for c in &self.state.candidates {
2713 let Some(patch) =
2714 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2715 else {
2716 continue;
2717 };
2718 leaks.extend(blind::scan(
2719 &format!("candidate {} patch", c.label),
2720 &patch,
2721 &cfg.vendor_tokens,
2722 ));
2723 }
2724 if !leaks.is_empty() {
2725 let summary = leaks
2726 .iter()
2727 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2728 .collect::<Vec<_>>()
2729 .join(", ");
2730 match cfg.on_leak {
2731 LeakPolicy::Fail => {
2732 self.state.status = RunStatus::Failed;
2733 self.state
2734 .event("blind", format!("vendor text in a patch: {summary}"));
2735 self.state.leaks = leaks;
2736 self.state.save()?;
2737 self.settle_questions();
2738 bail!(
2739 "blind.on_leak = \"fail\" and vendor text reached a \
2740 judged patch: {summary}"
2741 );
2742 }
2743 LeakPolicy::Redact => self.state.event(
2744 "blind",
2745 format!("redacting vendor text for judging: {summary}"),
2746 ),
2747 LeakPolicy::Warn => self.state.event(
2748 "blind",
2749 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2750 ),
2751 }
2752 self.state.leaks = leaks;
2753 }
2754 }
2755
2756 if self.state.viable().is_empty() {
2757 if self.state.all_candidates_verified_noop() {
2758 self.state.status = RunStatus::VerifiedNoop;
2769 self.state.save()?;
2770 self.settle_questions();
2771 return Ok(());
2772 }
2773 self.state.status = RunStatus::Failed;
2774 self.state.save()?;
2775 self.settle_questions();
2776 bail!("no candidate produced a change; nothing to judge");
2777 }
2778 self.state.status = RunStatus::Judging;
2779 self.state.save()?;
2780 Ok(())
2781 }
2782
2783 async fn judge(&mut self) -> Result<()> {
2786 let run_id = self.state.id.clone();
2791 let prompts = self.state.config.prompts.clone();
2792 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2793 return Ok(());
2794 }
2795 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2796 if viable.len() == 1 {
2797 self.state.judge_skipped = true;
2804 self.state.event(
2805 "judge",
2806 format!(
2807 "only candidate {} produced a change; judging skipped",
2808 viable[0].label
2809 ),
2810 );
2811 self.state.save()?;
2812 return Ok(());
2813 }
2814 self.state.status = RunStatus::Judging;
2815
2816 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2817 let language = self.state.config.graph.language.clone();
2818 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2819 let sessions = self.state.config.graph.sessions;
2820 let artifacts = agent::artifacts_dir(&self.state.dir());
2821 let root = self.state.worktree_root();
2822 let base_short = short(&self.state.base_commit);
2823
2824 let mut jobs = Vec::new();
2825 let mut orders = Vec::new();
2826 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2827 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2828 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2829 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2830 let seat_key = format!("judge-{}", j + 1);
2831 let seat = self.seat(&seat_key, &spec.id);
2832 jobs.push(SeatJob {
2833 prompt: prompt::judge(
2834 &self.state.instruction,
2835 &views,
2836 self.roles.judges.len(),
2837 &base_short,
2838 &language,
2839 ),
2840 spec,
2841 seat,
2842 cwd: root.join(format!("judge-{}", j + 1)),
2843 timeout,
2844 allow_write: false,
2845 sessions,
2846 artifacts: artifacts.clone(),
2847 stem: format!("judge-{}", j + 1),
2848 handover: None,
2849 });
2850 }
2851
2852 self.state.event(
2853 "judge",
2854 format!(
2855 "{} judges ranking {} candidates blind",
2856 jobs.len(),
2857 viable.len()
2858 ),
2859 );
2860 let labels_for_check = labels.clone();
2861 let mut quota_losses = Vec::new();
2862 let cache = self.state.config.cache_dir();
2863 let ctx = WaveCtx {
2864 carry_seats: false,
2865 pauses: Some((&self.pause, &self.interrupt)),
2866 run: &run_id,
2867 node: "judge",
2868 prompts: &prompts,
2869 cache: cache.as_deref(),
2870 round: None,
2871 };
2872 let results = ask_json_wave::<Ranking>(
2873 jobs,
2874 Arc::clone(&self.sem),
2875 self.state.config.graph.retries,
2876 &self.roles.judge_roster,
2877 &ctx,
2878 &mut quota_losses,
2879 &mut self.state,
2880 &move |r: &Ranking| r.validate(&labels_for_check),
2881 )
2882 .await;
2883 if self.state.resource_block.is_some() {
2884 return self.stop_for_resource();
2885 }
2886 self.state.quota.extend(quota_losses);
2887
2888 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2889 let agent_id = seat.agent.clone();
2890 self.state.seats.insert(seat.key.clone(), seat);
2891 let mut record = Judgement {
2892 judge: j + 1,
2893 seat: format!("judge-{}", j + 1),
2894 agent: agent_id,
2895 ranking: Vec::new(),
2896 reasons: BTreeMap::new(),
2897 confidence: None,
2898 strengths: BTreeMap::new(),
2899 order: orders[j].clone(),
2900 failed: None,
2901 duration_ms: 0,
2902 };
2903 match res {
2904 Ok((ranking, out)) => {
2905 record.ranking = ranking.normalized();
2906 record.reasons = ranking.reasons;
2907 record.strengths = ranking.strengths;
2908 record.confidence = ranking.confidence;
2909 record.duration_ms = out.duration_ms;
2910 self.state.event(
2911 "judge",
2912 format!(
2913 "judge {} ranked {}",
2914 j + 1,
2915 record.ranking.iter().collect::<String>()
2916 ),
2917 );
2918 }
2919 Err(e) => {
2920 record.failed = Some(e.to_string());
2921 self.state
2922 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2923 }
2924 }
2925 self.state.judgements.push(record);
2926 self.state.save()?;
2927 }
2928 Ok(())
2929 }
2930
2931 async fn deliberate(&mut self) -> Result<()> {
2934 let run_id = self.state.id.clone();
2939 let prompts = self.state.config.prompts.clone();
2940 if !self.state.deliberation.is_empty() {
2941 return Ok(());
2942 }
2943 let tops: Vec<char> = self
2944 .state
2945 .judgements
2946 .iter()
2947 .filter_map(|j| j.ranking.first().copied())
2948 .collect();
2949 let rounds = self.state.config.graph.deliberate_rounds;
2950 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2951 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2952 self.state.event(
2953 "deliberate",
2954 format!("judges agreed on {} outright; no deliberation", tops[0]),
2955 );
2956 }
2957 self.state.status = RunStatus::Voting;
2958 self.state.save()?;
2959 return Ok(());
2960 }
2961
2962 self.state.status = RunStatus::Deliberating;
2963 self.state.event(
2964 "deliberate",
2965 format!(
2966 "split: first choices were {} — opening {rounds} round(s)",
2967 tops.iter().collect::<String>()
2968 ),
2969 );
2970
2971 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2972 let language = self.state.config.graph.language.clone();
2973 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2974 let sessions = self.state.config.graph.sessions;
2975 let artifacts = agent::artifacts_dir(&self.state.dir());
2976 let root = self.state.worktree_root();
2977 let base_short = short(&self.state.base_commit);
2978
2979 for round in 1..=rounds {
2983 let mut turns: Vec<DeliberationTurn> = Vec::new();
2984 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2985 if self.state.judgements[j].failed.is_some() {
2986 continue;
2987 }
2988 let seat_key = format!("judge-{}", j + 1);
2989 let spec = self.occupant(&seat_key, spec);
2990 let mut seat = self.seat(&seat_key, &spec.id);
2991 let transcript = self.transcript(&turns, j);
2992 let build = |context: Option<&str>| {
2993 prompt::deliberate(
2994 &self.state.instruction,
2995 context,
2996 &transcript,
2997 round,
2998 rounds,
2999 &language,
3000 )
3001 };
3002 let block = self.candidate_block(&viable, &base_short);
3003 let full = build(Some(&block));
3004 let text = if has_context(&spec, &seat, sessions) {
3005 build(None)
3006 } else {
3007 full.clone()
3008 };
3009 let job = SeatJob {
3010 spec,
3011 seat: seat.clone(),
3012 prompt: text,
3013 cwd: root.join(format!("judge-{}", j + 1)),
3014 timeout,
3015 allow_write: false,
3016 sessions,
3017 artifacts: artifacts.clone(),
3018 stem: format!("delib-{round}-judge-{}", j + 1),
3019 handover: Some(full),
3020 };
3021 let cache = self.state.config.cache_dir();
3022 let ctx = WaveCtx {
3023 carry_seats: false,
3024 pauses: Some((&self.pause, &self.interrupt)),
3025 run: &run_id,
3026 node: "deliberate",
3027 prompts: &prompts,
3028 cache: cache.as_deref(),
3029 round: None,
3030 };
3031 let mut losses = Vec::new();
3035 let mut results = ask_wave_with::<String>(
3036 vec![job],
3037 Arc::clone(&self.sem),
3038 0,
3039 &self.roles.judge_roster,
3040 &ctx,
3041 &mut losses,
3042 &mut self.state,
3043 &|text: &str| {
3044 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3045 },
3046 )
3047 .await;
3048 self.state.quota.extend(losses);
3049 let (updated, res, _) = results.pop().expect("one job in, one result out");
3050 seat = updated;
3051 let agent_id = seat.agent.clone();
3052 self.state.seats.insert(seat.key.clone(), seat);
3053 let body = match res {
3054 Ok((body, _)) => body,
3055 Err(e) => {
3058 self.state
3059 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3060 continue;
3061 }
3062 };
3063 let tentative = verdict::extract_json::<Position>(&body)
3064 .ok()
3065 .and_then(|p| p.tentative)
3066 .and_then(|s| s.trim().chars().next())
3067 .map(|c| c.to_ascii_uppercase());
3068 self.state.event(
3069 "deliberate",
3070 format!(
3071 "round {round}: judge {} now favours {}",
3072 j + 1,
3073 tentative.map_or("—".to_owned(), |c| c.to_string())
3074 ),
3075 );
3076 turns.push(DeliberationTurn {
3077 judge: j + 1,
3078 agent: agent_id,
3079 body: blind::sanitize_prose(&body, &self.state.config.blind),
3080 tentative,
3081 });
3082 }
3083 self.state
3084 .deliberation
3085 .push(DeliberationRound { round, turns });
3086 self.state.save()?;
3087 }
3088
3089 self.state.status = RunStatus::Voting;
3090 self.state.save()?;
3091 Ok(())
3092 }
3093
3094 async fn vote(&mut self) -> Result<()> {
3097 let run_id = self.state.id.clone();
3102 let prompts = self.state.config.prompts.clone();
3103 if !self.state.votes.is_empty() {
3104 return Ok(());
3105 }
3106 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3107 if viable.len() == 1 {
3108 return Ok(());
3109 }
3110 self.state.status = RunStatus::Voting;
3111
3112 let language = self.state.config.graph.language.clone();
3113 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3114 let sessions = self.state.config.graph.sessions;
3115 let artifacts = agent::artifacts_dir(&self.state.dir());
3116 let root = self.state.worktree_root();
3117 let base_short = short(&self.state.base_commit);
3118 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3119
3120 let mut jobs = Vec::new();
3121 let mut seats_at = Vec::new();
3122 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3123 if self
3124 .state
3125 .judgements
3126 .get(j)
3127 .is_some_and(|r| r.failed.is_some())
3128 {
3129 continue;
3130 }
3131 let seat_key = format!("judge-{}", j + 1);
3132 let spec = self.occupant(&seat_key, spec);
3133 let seat = self.seat(&seat_key, &spec.id);
3134 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3135 let text = if has_context(&spec, &seat, sessions) {
3136 prompt::final_vote(&viable, &language)
3137 } else {
3138 full.clone()
3139 };
3140 jobs.push(SeatJob {
3141 spec,
3142 seat,
3143 prompt: text,
3144 cwd: root.join(format!("judge-{}", j + 1)),
3145 timeout,
3146 allow_write: false,
3147 sessions,
3148 artifacts: artifacts.clone(),
3149 stem: format!("vote-judge-{}", j + 1),
3150 handover: Some(full),
3151 });
3152 seats_at.push(j);
3153 }
3154
3155 self.state.event(
3156 "vote",
3157 format!(
3158 "collecting {} final votes one by one, privately",
3159 jobs.len()
3160 ),
3161 );
3162 let allowed = viable.clone();
3163 let mut quota_losses = Vec::new();
3164 let cache = self.state.config.cache_dir();
3165 let ctx = WaveCtx {
3166 carry_seats: false,
3167 pauses: Some((&self.pause, &self.interrupt)),
3168 run: &run_id,
3169 node: "vote",
3170 prompts: &prompts,
3171 cache: cache.as_deref(),
3172 round: None,
3173 };
3174 let results = ask_json_wave::<FinalVote>(
3175 jobs,
3176 Arc::clone(&self.sem),
3177 self.state.config.graph.retries,
3178 &self.roles.judge_roster,
3179 &ctx,
3180 &mut quota_losses,
3181 &mut self.state,
3182 &move |v: &FinalVote| match v.label() {
3183 Some(c) if allowed.contains(&c) => Ok(()),
3184 other => bail!("vote {other:?} is not one of {allowed:?}"),
3185 },
3186 )
3187 .await;
3188 if self.state.resource_block.is_some() {
3189 return self.stop_for_resource();
3190 }
3191 self.state.quota.extend(quota_losses);
3192
3193 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3194 let agent_id = seat.agent.clone();
3195 self.state.seats.insert(seat.key.clone(), seat);
3196 let initial = self
3197 .state
3198 .judgements
3199 .get(j)
3200 .and_then(|r| r.ranking.first().copied());
3201 let mut record = VoteRecord {
3202 judge: j + 1,
3203 agent: agent_id,
3204 vote: None,
3205 reason: String::new(),
3206 changed: false,
3207 };
3208 match res {
3209 Ok((v, _)) => {
3210 record.vote = v.label();
3211 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3212 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3213 self.state.event(
3214 "vote",
3215 format!(
3216 "judge {} voted {}{}",
3217 j + 1,
3218 record.vote.unwrap_or('?'),
3219 if record.changed { " (changed)" } else { "" }
3220 ),
3221 );
3222 }
3223 Err(e) => {
3224 self.state
3225 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3226 }
3227 }
3228 self.state.votes.push(record);
3229 self.state.save()?;
3230 }
3231 Ok(())
3232 }
3233
3234 fn tally(&mut self) -> Result<()> {
3237 if self.state.tally.is_some() {
3238 return Ok(());
3239 }
3240 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3241 let tops: Vec<char> = self
3242 .state
3243 .judgements
3244 .iter()
3245 .filter_map(|j| j.ranking.first().copied())
3246 .collect();
3247 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3248
3249 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3252 let mut cast: Vec<char> = Vec::new();
3253 for (i, j) in self.state.judgements.iter().enumerate() {
3254 let vote = self
3255 .state
3256 .votes
3257 .iter()
3258 .find(|v| v.judge == i + 1)
3259 .and_then(|v| v.vote)
3260 .or_else(|| j.ranking.first().copied());
3261 if let Some(v) = vote {
3262 *first_choice.entry(v).or_insert(0) += 1;
3263 cast.push(v);
3264 }
3265 }
3266
3267 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3268 for j in &self.state.judgements {
3269 let n = j.ranking.len();
3270 for (pos, label) in j.ranking.iter().enumerate() {
3271 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3272 }
3273 }
3274
3275 let best = first_choice.values().copied().max().unwrap_or(0);
3276 let mut leaders: Vec<char> = first_choice
3277 .iter()
3278 .filter(|(_, v)| **v == best)
3279 .map(|(k, _)| *k)
3280 .collect();
3281 let mut tie_break = None;
3282 if leaders.len() > 1 {
3283 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3284 let borda_leaders: Vec<char> = leaders
3285 .iter()
3286 .copied()
3287 .filter(|l| borda[l] == top_borda)
3288 .collect();
3289 tie_break = Some(if borda_leaders.len() == 1 {
3290 format!(
3291 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3292 leaders.len()
3293 )
3294 } else {
3295 format!(
3296 "{} way tie on both first-choice votes and Borda points, broken by label order",
3297 leaders.len()
3298 )
3299 });
3300 leaders = borda_leaders;
3301 leaders.sort_unstable();
3302 }
3303 let winner = *leaders
3304 .first()
3305 .or(viable.first())
3306 .context("no candidate to declare a winner from")?;
3307
3308 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3309 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3310 let deliberated = !self.state.deliberation.is_empty();
3311
3312 let quota_seats: std::collections::BTreeSet<&str> =
3316 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3317 let mut present = 0usize;
3318 for (i, j) in self.state.judgements.iter().enumerate() {
3319 if quota_seats.contains(j.seat.as_str()) {
3320 continue;
3321 }
3322 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3323 let voted = self
3324 .state
3325 .votes
3326 .iter()
3327 .any(|v| v.judge == i + 1 && v.vote.is_some());
3328 if ranked || voted {
3329 present += 1;
3330 }
3331 }
3332 let needs_quorum = viable.len() > 1;
3338 let judges_total = if needs_quorum {
3339 self.roles.judges.len()
3340 } else {
3341 0
3342 };
3343 let quorum = if needs_quorum {
3344 judges_total / 2 + 1
3345 } else {
3346 0
3347 };
3348 let met_quorum = !needs_quorum || present >= quorum;
3349 let uncontested = (!needs_quorum).then(|| {
3350 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3351 });
3352
3353 self.state.event(
3354 "tally",
3355 match &uncontested {
3356 Some(reason) => format!("winner {winner} — {reason}"),
3357 None => format!(
3358 "winner {winner} — votes {} | initial {} | {} changed | \
3359 {present}/{judges_total} judges{}",
3360 first_choice
3361 .iter()
3362 .map(|(k, v)| format!("{k}:{v}"))
3363 .collect::<Vec<_>>()
3364 .join(" "),
3365 if unanimous_initial {
3366 "unanimous"
3367 } else {
3368 "split"
3369 },
3370 changed_votes,
3371 if met_quorum {
3372 String::new()
3373 } else {
3374 format!(" — below quorum ({quorum} required)")
3375 },
3376 ),
3377 },
3378 );
3379 if !met_quorum {
3380 self.state.event(
3381 "stall",
3382 format!(
3383 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3384 the run stops here, resumable"
3385 ),
3386 );
3387 }
3388 self.state.tally = Some(Tally {
3389 first_choice,
3390 borda,
3391 winner,
3392 rankings: tops.len(),
3393 unanimous_initial,
3394 deliberated,
3395 changed_votes,
3396 unanimous_final,
3397 tie_break,
3398 judges: judges_total,
3399 present,
3400 quorum,
3401 met_quorum,
3402 uncontested,
3403 });
3404 self.state.status = if met_quorum {
3405 RunStatus::Reviewing
3406 } else {
3407 RunStatus::Stalled
3408 };
3409 self.state.save()?;
3410 Ok(())
3411 }
3412
3413 #[allow(clippy::too_many_lines)]
3434 async fn recover_stall(&mut self) -> Result<bool> {
3435 let run_id = self.state.id.clone();
3440 let prompts = self.state.config.prompts.clone();
3441 let quota_seats: BTreeSet<&str> =
3446 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3447 let absent: Vec<String> = self
3448 .state
3449 .judgements
3450 .iter()
3451 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3452 .map(|j| j.seat.clone())
3453 .collect();
3454 if absent.is_empty() {
3455 return Ok(false);
3456 }
3457 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3458 if viable.len() <= 1 {
3459 return Ok(false);
3460 }
3461 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3462 let language = self.state.config.graph.language.clone();
3463 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3464 let sessions = self.state.config.graph.sessions;
3465 let artifacts = agent::artifacts_dir(&self.state.dir());
3466 let root = self.state.worktree_root();
3467 let base_short = short(&self.state.base_commit);
3468 let candidates: Vec<Candidate> = viable.clone();
3469
3470 let mut positions: Vec<usize> = absent
3472 .iter()
3473 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3474 .collect();
3475 if positions.is_empty() {
3476 return Ok(false);
3477 }
3478 positions.sort_unstable();
3479 positions.dedup();
3480
3481 let mut judge_jobs = Vec::new();
3483 for &j in &positions {
3484 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3485 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3486 let seat_key = format!("judge-{}", j + 1);
3487 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3488 let seat = self.seat(&seat_key, &spec.id);
3489 judge_jobs.push(SeatJob {
3490 spec,
3491 seat,
3492 prompt: prompt::judge(
3493 &self.state.instruction,
3494 &views,
3495 self.roles.judges.len(),
3496 &base_short,
3497 &language,
3498 ),
3499 cwd: root.join(seat_key),
3500 timeout,
3501 allow_write: false,
3502 sessions,
3503 artifacts: artifacts.clone(),
3504 stem: format!("judge-{}-recover", j + 1),
3505 handover: None,
3506 });
3507 }
3508
3509 let labels_for_check = labels.clone();
3510 let mut judge_losses = Vec::new();
3511 let retries = self.state.config.graph.retries;
3512 let cache = self.state.config.cache_dir();
3513 let ctx = WaveCtx {
3514 carry_seats: false,
3515 pauses: Some((&self.pause, &self.interrupt)),
3516 run: &run_id,
3517 node: "judge",
3518 prompts: &prompts,
3519 cache: cache.as_deref(),
3520 round: None,
3521 };
3522 let results = ask_json_wave::<Ranking>(
3523 judge_jobs,
3524 Arc::clone(&self.sem),
3525 retries,
3526 &self.roles.judge_roster,
3527 &ctx,
3528 &mut judge_losses,
3529 &mut self.state,
3530 &move |r: &Ranking| r.validate(&labels_for_check),
3531 )
3532 .await;
3533 if self.state.resource_block.is_some() {
3534 self.stop_for_resource()?;
3535 return Ok(false);
3536 }
3537
3538 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3540 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3541 let agent_id = seat.agent.clone();
3542 self.state.seats.insert(seat.key.clone(), seat);
3543 let record = &mut self.state.judgements[j];
3544 match res {
3545 Ok((ranking, out)) => {
3546 record.agent = agent_id;
3547 record.ranking = ranking.normalized();
3548 record.reasons = ranking.reasons;
3549 record.strengths = ranking.strengths;
3550 record.confidence = ranking.confidence;
3551 record.failed = None;
3552 record.duration_ms = out.duration_ms;
3553 recovered.insert(j);
3554 self.state.event(
3555 "recover",
3556 format!("judge {} ranked again after the limit", j + 1),
3557 );
3558 }
3559 Err(e) => {
3560 self.state
3561 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3562 }
3563 }
3564 }
3565
3566 let mut vote_jobs = Vec::new();
3568 let mut vote_pos: Vec<usize> = Vec::new();
3569 for &j in &recovered {
3570 let seat_key = format!("judge-{}", j + 1);
3571 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3572 let seat = self.seat(&seat_key, &spec.id);
3573 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3574 let text = if has_context(&spec, &seat, sessions) {
3575 prompt::final_vote(&labels, &language)
3576 } else {
3577 full.clone()
3578 };
3579 vote_jobs.push(SeatJob {
3580 spec,
3581 seat,
3582 prompt: text,
3583 cwd: root.join(seat_key),
3584 timeout,
3585 allow_write: false,
3586 sessions,
3587 artifacts: artifacts.clone(),
3588 stem: format!("vote-judge-{}-recover", j + 1),
3589 handover: Some(full),
3590 });
3591 vote_pos.push(j);
3592 }
3593 let allowed = labels.clone();
3594 let mut vote_losses = Vec::new();
3595 let vote_retries = self.state.config.graph.retries;
3596 let vote_cache = self.state.config.cache_dir();
3597 let ctx = WaveCtx {
3598 carry_seats: false,
3599 pauses: Some((&self.pause, &self.interrupt)),
3600 run: &run_id,
3601 node: "vote",
3602 prompts: &prompts,
3603 cache: vote_cache.as_deref(),
3604 round: None,
3605 };
3606 let votes = ask_json_wave::<FinalVote>(
3607 vote_jobs,
3608 Arc::clone(&self.sem),
3609 vote_retries,
3610 &self.roles.judge_roster,
3611 &ctx,
3612 &mut vote_losses,
3613 &mut self.state,
3614 &move |v: &FinalVote| match v.label() {
3615 Some(c) if allowed.contains(&c) => Ok(()),
3616 other => bail!("vote {other:?} is not one of {allowed:?}"),
3617 },
3618 )
3619 .await;
3620 if self.state.resource_block.is_some() {
3621 self.stop_for_resource()?;
3622 return Ok(false);
3623 }
3624 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3625 let agent_id = seat.agent.clone();
3626 self.state.seats.insert(seat.key.clone(), seat);
3627 match res {
3628 Ok((v, _)) => {
3629 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3630 rec.vote = v.label();
3631 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3632 } else {
3633 self.state.votes.push(VoteRecord {
3634 judge: j + 1,
3635 agent: agent_id,
3636 vote: v.label(),
3637 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3638 changed: false,
3639 });
3640 }
3641 self.state.event(
3642 "recover",
3643 format!("judge {} voted again after the limit", j + 1),
3644 );
3645 }
3646 Err(e) => {
3647 self.state
3648 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3649 }
3650 }
3651 }
3652
3653 let recovered_keys: BTreeSet<String> = recovered
3657 .iter()
3658 .map(|&j| format!("judge-{}", j + 1))
3659 .collect();
3660 self.state
3661 .quota
3662 .retain(|q| !recovered_keys.contains(&q.seat));
3663 for loss in judge_losses.into_iter().chain(vote_losses) {
3667 if recovered_keys.contains(&loss.seat) {
3668 continue;
3669 }
3670 self.state.quota.retain(|q| q.seat != loss.seat);
3671 self.state.quota.push(loss);
3672 }
3673
3674 self.state.tally = None;
3676 self.tally()?;
3677 Ok(self
3678 .state
3679 .tally
3680 .as_ref()
3681 .map(|t| t.met_quorum)
3682 .unwrap_or(false))
3683 }
3684
3685 async fn graft(&mut self) -> Result<()> {
3702 let Err(e) = self.graft_inner().await else {
3703 return Ok(());
3704 };
3705 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3706 self.state.status = RunStatus::Blocked;
3707 self.state.event("graft", format!("blocked: {e:#}"));
3708 self.state.save()?;
3709 return Err(e);
3710 }
3711 if let Some(rec) = self.state.graft.clone()
3716 && !rec.outcome.settled()
3717 {
3718 if let Some(winner) = self.state.winner().cloned() {
3719 if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3720 self.state.status = RunStatus::Blocked;
3721 self.state.event("graft", format!("blocked: {re:#}"));
3722 self.state.save()?;
3723 return Err(re);
3724 }
3725 }
3726 if let Some(g) = self.state.graft.as_mut() {
3727 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3728 }
3729 }
3730 self.state.event("graft", format!("skipped: {e:#}"));
3731 if let Err(e) = self.state.save() {
3732 tracing::warn!("save after graft: {e:#}");
3733 }
3734 Ok(())
3735 }
3736
3737 async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3740 let restored = git::restore_to(wt, pre).await;
3741 let tip = git::rev_parse(wt, "HEAD").await;
3742 match (restored, tip) {
3743 (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3744 (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3745 (Ok(()), Ok(tip)) => {
3746 Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3747 }
3748 (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3749 }
3750 }
3751
3752 fn graft_skipped(&mut self, why: &str) {
3754 let message = format!("skipped: {why}");
3755 if !self
3756 .state
3757 .events
3758 .iter()
3759 .any(|e| e.node == "graft" && e.message == message)
3760 {
3761 self.state.event("graft", message);
3762 }
3763 }
3764
3765 async fn graft_inner(&mut self) -> Result<()> {
3766 if let Some(rec) = self.state.graft.clone()
3769 && !rec.outcome.settled()
3770 {
3771 if self.state.reviews.is_empty()
3775 && let Some(winner) = self.state.winner().cloned()
3776 {
3777 Self::graft_restore(&winner.worktree, &rec.pre).await?;
3778 }
3779 if let Some(g) = self.state.graft.as_mut() {
3780 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3781 }
3782 self.state.event(
3783 "graft",
3784 "rolled back: the previous round was interrupted; the winner is untouched",
3785 );
3786 self.state.save()?;
3787 return Ok(());
3788 }
3789 let plan = match graft::plan(&self.state) {
3790 Ok(plan) => plan,
3791 Err(skip) => {
3792 if skip != graft::Skip::AlreadyDone {
3793 self.graft_skipped(skip.reason());
3794 self.state.save()?;
3795 }
3796 return Ok(());
3797 }
3798 };
3799 let Some(winner) = self.state.winner().cloned() else {
3800 return Ok(());
3801 };
3802 let wt = winner.worktree.clone();
3803 if !git::is_clean(&wt).await? {
3804 self.graft_skipped("the winner's worktree has uncommitted changes");
3805 self.state.save()?;
3806 return Ok(());
3807 }
3808 let pre = git::rev_parse(&wt, "HEAD").await?;
3809 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3810 else {
3811 self.graft_skipped("the winner's diff could not be measured");
3812 self.state.save()?;
3813 return Ok(());
3814 };
3815 let limit = graft::limit(winner_lines);
3816 self.state.graft = Some(GraftRecord {
3819 pre: pre.clone(),
3820 post: None,
3821 lines: None,
3822 limit: Some(limit),
3823 agent: None,
3824 outcome: GraftOutcome::Running,
3825 requested: graft::requested(&plan),
3826 adoption: None,
3827 adoption_error: None,
3828 });
3829 self.state.event(
3830 "graft",
3831 format!(
3832 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3833 plan.strengths
3834 .iter()
3835 .map(|(l, _, _)| l.to_string())
3836 .collect::<Vec<_>>()
3837 .join(", "),
3838 plan.winner
3839 ),
3840 );
3841 self.state.save()?;
3842
3843 let fix_prompt = prompt::graft(
3844 &self.state.instruction,
3845 plan.winner,
3846 &plan.strengths,
3847 limit,
3848 &self.state.config.graph.language,
3849 );
3850 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3851 let sessions = self.state.config.graph.sessions;
3852 let artifacts = agent::artifacts_dir(&self.state.dir());
3853 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3854 let (_, seat, out) = self
3855 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3856 SeatJob {
3857 prompt: fix_prompt.clone(),
3858 spec,
3859 seat,
3860 cwd: wt.clone(),
3861 timeout,
3862 allow_write: true,
3863 sessions,
3864 artifacts: artifacts.clone(),
3865 stem: "graft".to_owned(),
3866 handover: None,
3867 }
3868 })
3869 .await;
3870 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
3875 Self::graft_restore(&wt, &pre).await?;
3876 self.state.graft = None;
3877 self.state.save()?;
3878 return self.stop_for_resource();
3879 }
3880 let mut why: Option<String> = None;
3881 match &out {
3882 AgentOutcome::Ok(o) if agent::output_advances(o) => {
3883 why = Some("the fixer produced nothing usable".to_owned());
3884 }
3885 AgentOutcome::Ok(_) => {}
3886 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3887 AgentOutcome::Quota(o) => {
3888 self.state.quota.push(QuotaLoss {
3889 seat: seat.key.clone(),
3890 node: "graft".to_owned(),
3891 at: Timestamp::now(),
3892 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3893 });
3894 why = Some("rate limited (quota)".to_owned());
3895 }
3896 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
3897 why = Some(format!(
3898 "the fixer failed: {}",
3899 e.lines()
3900 .next()
3901 .unwrap_or("")
3902 .chars()
3903 .take(160)
3904 .collect::<String>()
3905 ));
3906 }
3907 }
3908 let agent_id = seat.agent.clone();
3909 self.state.seats.insert(seat.key.clone(), seat);
3910
3911 let mut post = None;
3912 let mut lines = None;
3913 if why.is_none() {
3914 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3915 if let Ok(r) = &rescued {
3916 self.state.note_withheld("graft", &r.withheld);
3917 }
3918 let head = git::rev_parse(&wt, "HEAD").await?;
3919 let dirty = match &rescued {
3923 Ok(r) => git::status_porcelain(&wt)
3924 .await
3925 .map(|st| {
3926 st.lines().any(|l| {
3927 !r.withheld
3928 .iter()
3929 .any(|w| l.trim_end().ends_with(w.path.as_str()))
3930 })
3931 })
3932 .unwrap_or(true),
3933 Err(_) => true,
3934 };
3935 if dirty {
3936 why = Some("the fixer left changes that could not be committed".to_owned());
3937 } else if head == pre {
3938 why = Some("the fixer changed nothing".to_owned());
3939 } else {
3940 lines = git::changed_lines(&wt, &pre, &head).await;
3941 if graft::within_budget(lines, limit) {
3942 post = Some(head);
3943 } else {
3944 why = Some(format!(
3945 "{} changed lines against a limit of {limit}",
3946 lines.map_or_else(
3947 || "an unmeasurable number of".to_owned(),
3948 |l| l.to_string()
3949 )
3950 ));
3951 }
3952 }
3953 }
3954 let (adoption, adoption_error) = match &out {
3955 AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
3956 _ => (None, None),
3957 };
3958 let requested = graft::requested(&plan);
3959 let outcome = match why {
3960 None => {
3961 self.state.event(
3962 "graft",
3963 format!(
3964 "applied: {} changed lines (limit {limit})",
3965 lines.unwrap_or(0)
3966 ),
3967 );
3968 GraftOutcome::Applied
3969 }
3970 Some(why) => {
3971 Self::graft_restore(&wt, &pre).await?;
3974 self.state.event(
3975 "graft",
3976 format!("rolled back: {why}; the winner is untouched"),
3977 );
3978 GraftOutcome::RolledBack(why)
3979 }
3980 };
3981 self.state.graft = Some(GraftRecord {
3982 pre,
3983 post,
3984 lines,
3985 limit: Some(limit),
3986 agent: Some(agent_id),
3987 outcome,
3988 requested,
3989 adoption,
3990 adoption_error,
3991 });
3992 self.state.save()?;
3993 Ok(())
3994 }
3995
3996 async fn fold_losers(&mut self) -> Result<()> {
3999 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4000 return Ok(());
4001 };
4002 let repo = self.state.repo.clone();
4003 let mut folded = Vec::new();
4004 for i in 0..self.state.candidates.len() {
4005 let c = &self.state.candidates[i];
4006 if c.label == winner || c.folded {
4007 continue;
4008 }
4009 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4010 git::worktree_remove(&repo, &wt).await.ok();
4011 git::branch_delete(&repo, &branch).await.ok();
4012 self.state.candidates[i].folded = true;
4013 folded.push(label.to_string());
4014 }
4015 let root = self.state.worktree_root();
4017 for j in 1..=self.roles.judges.len() {
4018 let wt = root.join(format!("judge-{j}"));
4019 if wt.exists() {
4020 git::worktree_remove(&repo, &wt).await.ok();
4021 }
4022 }
4023 if self.state.config.graph.advise {
4026 for k in 1..=self.state.config.graph.advisors {
4027 let wt = root.join(format!("advisor-{k}"));
4028 if wt.exists() {
4029 git::worktree_remove(&repo, &wt).await.ok();
4030 }
4031 }
4032 }
4033 if !folded.is_empty() {
4034 self.state
4035 .event("fold", format!("folded candidates {}", folded.join(", ")));
4036 self.state.save()?;
4037 }
4038 Ok(())
4039 }
4040
4041 async fn sync_to_base(&mut self) -> Result<()> {
4081 if self.state.status == RunStatus::AlreadyInBase {
4082 return Ok(());
4083 }
4084 let conflicted = self
4085 .state
4086 .base_sync
4087 .as_ref()
4088 .is_some_and(|s| s.conflict.is_some());
4089 let Some(winner) = self.state.winner().cloned() else {
4090 return Ok(());
4091 };
4092
4093 let repo = self.state.repo.clone();
4094 let remote = self.state.config.merge.remote.clone();
4095 let base_branch = self.state.base_branch.clone();
4096 let tracking = format!("{remote}/{base_branch}");
4097
4098 git::fetch(&repo, &remote, &base_branch).await.ok();
4099 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4104 return Ok(());
4105 };
4106
4107 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4108 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4109 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4110
4111 if (behind > 0 || conflicted || head == tip)
4120 && self
4121 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4122 .await?
4123 {
4124 return Ok(());
4125 }
4126 if conflicted {
4127 return Ok(());
4128 }
4129
4130 if behind == 0 {
4131 if let Some(from) = self
4140 .state
4141 .rebase_fixes
4142 .iter()
4143 .rev()
4144 .find_map(|r| r.from.clone())
4145 && from != head
4146 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4147 .await
4148 .is_ok_and(|o| o.ok())
4149 {
4150 git::sync_to_head(&winner.worktree).await?;
4151 }
4152 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4158 if let Some(why) = &conflict {
4159 self.state.status = RunStatus::Blocked;
4160 self.state.event("land", why.clone());
4161 }
4162 self.state.base_sync = Some(BaseSync {
4163 tip,
4164 behind: 0,
4165 attempts,
4166 conflict,
4167 already_in: None,
4168 });
4169 self.state.save()?;
4170 return Ok(());
4171 }
4172
4173 if attempts >= BASE_SYNC_ROUNDS {
4174 let why = format!(
4175 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4176 rebase(s); rebasing again would only race it",
4177 winner.branch
4178 );
4179 self.state.status = RunStatus::Blocked;
4180 self.state.base_sync = Some(BaseSync {
4181 tip,
4182 behind,
4183 attempts,
4184 conflict: Some(why.clone()),
4185 already_in: None,
4186 });
4187 self.state.event("land", why);
4188 self.state.save()?;
4189 return Ok(());
4190 }
4191
4192 self.state.event(
4193 "land",
4194 format!(
4195 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4196 winner.branch
4197 ),
4198 );
4199 self.state.save()?;
4200
4201 let branch_tracking = format!("{remote}/{}", winner.branch);
4207 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4208 let remote_tip = if self.state.task_pr_branch().is_none()
4209 && matches!(&fetched_branch, Ok(o) if o.ok())
4210 {
4211 git::rev_parse(&repo, &branch_tracking).await.ok()
4212 } else {
4213 None
4214 };
4215 if let Some(theirs) = &remote_tip
4219 && !git::is_ancestor(&repo, theirs, &head).await
4220 && !crate::reconcile::origin_missing(&repo, &head, theirs)
4221 .await
4222 .is_ok_and(|missing| missing.is_empty())
4223 {
4224 let why = format!(
4225 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4226 them",
4227 short(theirs),
4228 winner.branch
4229 );
4230 self.state.status = RunStatus::Blocked;
4231 self.state.base_sync = Some(BaseSync {
4232 tip,
4233 behind,
4234 attempts,
4235 conflict: Some(why.clone()),
4236 already_in: None,
4237 });
4238 self.state.event("land", why);
4239 self.state.save()?;
4240 return Ok(());
4241 }
4242
4243 let scratch = self.state.dir().join("base-sync");
4244 let rebased = match crate::rebase::rebase_with_fixer(
4245 &mut self.state,
4246 &scratch,
4247 &winner.branch,
4248 &tracking,
4249 )
4250 .await
4251 {
4252 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4253 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4254 Err(e) => Err(e),
4255 };
4256 let attempts = attempts + 1;
4257 match rebased {
4258 Ok(None) => {
4259 git::sync_to_head(&winner.worktree).await?;
4263 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4264 let mut conflict = None;
4265 if let Some(pinned) = &remote_tip {
4266 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4267 match pushed {
4268 Ok(o) if o.ok() => self.state.event(
4269 "land",
4270 format!("pushed rebased {} to {remote}", winner.branch),
4271 ),
4272 Ok(o) => {
4273 conflict = Some(format!(
4274 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4275 winner.branch,
4276 short(pinned),
4277 o.stderr.chars().take(600).collect::<String>()
4278 ));
4279 }
4280 Err(e) => {
4281 conflict = Some(format!(
4282 "rebased {} locally but could not push it: {e:#}",
4283 winner.branch
4284 ));
4285 }
4286 }
4287 }
4288 if let Some(why) = &conflict {
4289 self.state.status = RunStatus::Blocked;
4290 self.state.event("land", why.clone());
4291 }
4292 self.state.base_sync = Some(BaseSync {
4293 tip: tip.clone(),
4294 behind: 0,
4295 attempts,
4296 conflict,
4297 already_in: None,
4298 });
4299 self.state
4300 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4301 }
4302 Ok(Some(conflict)) => {
4303 let why = format!(
4304 "{} conflicts with {tracking} and did not rebase: {}",
4305 winner.branch,
4306 conflict.chars().take(600).collect::<String>()
4307 );
4308 self.state.status = RunStatus::Blocked;
4309 self.state.base_sync = Some(BaseSync {
4310 tip,
4311 behind,
4312 attempts,
4313 conflict: Some(why.clone()),
4314 already_in: None,
4315 });
4316 self.state.event("land", why);
4317 }
4318 Err(e) => {
4319 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4320 self.state.status = RunStatus::Blocked;
4321 self.state.base_sync = Some(BaseSync {
4322 tip,
4323 behind,
4324 attempts,
4325 conflict: Some(why.clone()),
4326 already_in: None,
4327 });
4328 self.state.event("land", why);
4329 }
4330 }
4331 self.state.save()?;
4332 Ok(())
4333 }
4334
4335 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4341 if self.state.task_pr_branch().is_some() {
4344 return None;
4345 }
4346 let repo = self.state.repo.clone();
4347 let remote = self.state.config.merge.remote.clone();
4348 let fetched = git::fetch(&repo, &remote, branch).await;
4349 if !matches!(&fetched, Ok(o) if o.ok()) {
4350 return None;
4351 }
4352 let branch_tracking = format!("{remote}/{branch}");
4353 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4354 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4355 return None;
4356 }
4357 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4358 .await
4359 .is_ok_and(|missing| missing.is_empty())
4360 {
4361 return Some(format!(
4362 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4363 them",
4364 short(&theirs)
4365 ));
4366 }
4367 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4368 Ok(o) if o.ok() => {
4369 self.state
4370 .event("land", format!("pushed rebased {branch} to {remote}"));
4371 None
4372 }
4373 Ok(o) => Some(format!(
4374 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4375 someone may have pushed): {}",
4376 short(&theirs),
4377 o.stderr.chars().take(600).collect::<String>()
4378 )),
4379 Err(e) => Some(format!(
4380 "{branch} is rebased locally but could not be pushed: {e:#}"
4381 )),
4382 }
4383 }
4384
4385 async fn settle_already_in(
4402 &mut self,
4403 branch: &str,
4404 tip: &str,
4405 head: &str,
4406 attempts: usize,
4407 behind: usize,
4408 ) -> Result<bool> {
4409 let repo = self.state.repo.clone();
4410 let remote = self.state.config.merge.remote.clone();
4411 let start = self.state.base_commit.clone();
4412 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4413 Ok(Some(e)) => e,
4414 Ok(None) => return Ok(false),
4415 Err(e) => {
4416 tracing::warn!("already-in-base check for {branch}: {e:#}");
4417 return Ok(false);
4418 }
4419 };
4420 let mut verified = vec![head.to_owned()];
4421 let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4422 let fetched = git::fetch(&repo, &remote, &public_branch).await;
4423 if matches!(&fetched, Ok(o) if o.ok())
4424 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4425 && theirs != head
4426 {
4427 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4428 Ok(Some(_)) => verified.push(theirs),
4429 _ => return Ok(false),
4430 }
4431 }
4432 let base_branch = self.state.base_branch.clone();
4433 let message = format!(
4434 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4435 land",
4436 evidence.names(),
4437 evidence.proof.as_str()
4438 );
4439 let closed =
4440 crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4441 .await;
4442 match closed {
4443 Ok(Ok(url)) => self
4444 .state
4445 .event("land", format!("closed {url}: superseded on {base_branch}")),
4446 Ok(Err(why)) => self
4447 .state
4448 .event("land", format!("did not close a pull request: {why}")),
4449 Err(e) => {
4450 let why = format!(
4451 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4452 not be closed ({e:#}); resume to retry"
4453 );
4454 self.state.status = RunStatus::Blocked;
4455 self.state.base_sync = Some(BaseSync {
4456 tip: tip.to_owned(),
4457 behind,
4458 attempts,
4459 conflict: Some(why.clone()),
4460 already_in: None,
4461 });
4462 self.state.event("land", why);
4463 self.state.save()?;
4464 return Ok(true);
4465 }
4466 }
4467 self.state.status = RunStatus::AlreadyInBase;
4468 self.state.base_sync = Some(BaseSync {
4469 tip: tip.to_owned(),
4470 behind,
4471 attempts,
4472 conflict: None,
4473 already_in: Some(evidence),
4474 });
4475 self.state.event("land", message);
4476 self.state.save()?;
4477 self.settle_questions();
4478 Ok(true)
4479 }
4480
4481 fn landing_base(&self) -> String {
4491 self.state
4492 .base_sync
4493 .as_ref()
4494 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4495 }
4496
4497 pub async fn fix_selected(
4530 &mut self,
4531 ids: &[String],
4532 reason: &str,
4533 allow_stale: bool,
4534 ) -> Result<()> {
4535 let reason = reason.trim();
4536 if reason.is_empty() {
4537 bail!("a fix request needs a reason — that is the operator's own record of why");
4538 }
4539 if ids.is_empty() {
4540 bail!("no finding id given");
4541 }
4542 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4543 bail!(
4544 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4545 has already concluded — can be given a targeted fix. A run still \
4546 in progress should simply be resumed; a `merged` run's branch has \
4547 already landed, so its answer is a fresh `magi review <branch>`, \
4548 not reopening this run's own record",
4549 self.state.id,
4550 self.state.status.as_str()
4551 );
4552 }
4553 let Some(winner) = self.state.winner().cloned() else {
4554 bail!("run {} has no winning candidate to fix", self.state.id);
4555 };
4556 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4557 bail!(
4558 "branch `{}` no longer exists; this run cannot be extended",
4559 winner.branch
4560 );
4561 }
4562 let home = crate::run::home();
4563 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4564 bail!(
4565 "run {} is currently being worked on by another magi process",
4566 self.state.id
4567 );
4568 }
4569 let _claim = FixClaim::acquire(&self.state.dir())?;
4575
4576 let mut seen = BTreeSet::new();
4580 let mut findings = Vec::new();
4581 let mut missing = Vec::new();
4582 for id in ids {
4583 if !seen.insert(id.clone()) {
4584 continue;
4585 }
4586 match self.state.finding(id) {
4587 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4588 id: f.id.clone(),
4589 severity: f.severity,
4590 reviewer_vote: rec.vote,
4591 round: round.round,
4592 round_head: round.head.clone(),
4593 reviewer: rec.reviewer,
4594 agent: rec.agent.clone(),
4595 file: f.file.clone(),
4596 line: f.line,
4597 title: f.title.clone(),
4598 detail: f.detail.clone(),
4599 outcome: OperatorFixOutcome::Pending,
4600 }),
4601 None => missing.push(id.clone()),
4602 }
4603 }
4604 if !missing.is_empty() {
4605 bail!(
4606 "unknown finding id(s): {}; nothing was changed",
4607 missing.join(", ")
4608 );
4609 }
4610
4611 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4612 let stale_details: Vec<(String, String)> = findings
4613 .iter()
4614 .filter(|f| f.round_head != head_at_request)
4615 .map(|f| (f.id.clone(), f.round_head.clone()))
4616 .collect();
4617 let stale = !stale_details.is_empty();
4618 if stale && !allow_stale {
4619 bail!(
4620 "the branch has moved since some finding(s) were raised — {} — now \
4621 at {}; pass --allow-stale to fix anyway, or re-run review first",
4622 stale_details
4623 .iter()
4624 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4625 .collect::<Vec<_>>()
4626 .join(", "),
4627 short(&head_at_request)
4628 );
4629 }
4630
4631 let request = OperatorFixRequest {
4632 requested_at: Timestamp::now(),
4633 reason: reason.to_owned(),
4634 findings,
4635 head_at_request: head_at_request.clone(),
4636 allow_stale,
4637 stale,
4638 fix: None,
4639 result_head: None,
4640 follow_up_review_run: None,
4641 };
4642 self.state.event(
4643 "fix",
4644 format!(
4645 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4646 request.findings.len(),
4647 request
4648 .findings
4649 .iter()
4650 .map(|f| f.id.as_str())
4651 .collect::<Vec<_>>()
4652 .join(", "),
4653 ),
4654 );
4655 self.state.operator_fixes.push(request);
4662 self.state.save()?;
4663 let request_index = self.state.operator_fixes.len() - 1;
4664
4665 if winner.worktree.exists() {
4674 let dirty = git::git(
4677 &winner.worktree,
4678 &["status", "--porcelain", "--untracked-files=all"],
4679 )
4680 .await?;
4681 let only_withheld = dirty.lines().all(|l| {
4682 l.strip_prefix("?? ")
4683 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4684 });
4685 if !only_withheld {
4686 bail!(
4687 "`{}` has uncommitted changes; refusing to touch it — commit or \
4688 discard them first",
4689 winner.worktree.display()
4690 );
4691 }
4692 git::worktree_remove(&self.state.repo, &winner.worktree)
4693 .await
4694 .ok();
4695 }
4696 let fix_worktree = self.state.worktree_root().join("operator-fix");
4697 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4698 git::git(
4699 &self.state.repo,
4700 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4701 )
4702 .await
4703 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4704 if !git::is_clean(&fix_worktree).await? {
4705 git::worktree_remove(&self.state.repo, &fix_worktree)
4706 .await
4707 .ok();
4708 bail!(
4709 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4710 winner.branch
4711 );
4712 }
4713 let repo_for_setup = self.state.repo.clone();
4714 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4715 Ok(()) => {
4716 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4717 }
4718 Err(e) => Err(e),
4719 };
4720 if let Err(e) = setup {
4721 git::worktree_remove(&self.state.repo, &fix_worktree)
4722 .await
4723 .ok();
4724 return Err(e);
4725 }
4726
4727 let run_id = self.state.id.clone();
4728 let prompts = self.state.config.prompts.clone();
4729 let language = self.state.config.graph.language.clone();
4730 let sessions = self.state.config.graph.sessions;
4731 let artifacts = agent::artifacts_dir(&self.state.dir());
4732 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4733 .findings
4734 .iter()
4735 .map(|f| Finding {
4736 id: f.id.clone(),
4737 severity: f.severity,
4738 file: f.file.clone(),
4739 line: f.line,
4740 title: f.title.clone(),
4741 detail: f.detail.clone(),
4742 })
4743 .collect();
4744 let fix_prompt = prompt::operator_fix_in(
4745 &self.state.instruction,
4746 &finding_list,
4747 reason,
4748 &stale_details,
4749 &head_at_request,
4750 &language,
4751 &self.state.config.graph.github_language,
4752 );
4753 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4754 let (job, seat, out) = self
4755 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4756 prompt: fix_prompt.clone(),
4757 spec,
4758 seat,
4759 cwd: fix_worktree.clone(),
4760 timeout,
4761 allow_write: true,
4762 sessions,
4763 artifacts: artifacts.clone(),
4764 stem: "operator-fix".to_owned(),
4765 handover: None,
4766 })
4767 .await;
4768 let agent_id = seat.agent.clone();
4769
4770 let mut fix = FixRecord {
4771 agent: agent_id,
4772 addressed: Vec::new(),
4773 rejected: Vec::new(),
4774 notes: String::new(),
4775 committed: false,
4776 failed: None,
4777 duration_ms: 0,
4778 continuation: None,
4779 };
4780 let mut final_seat = seat.clone();
4781 match out {
4782 AgentOutcome::Ok(o) => {
4783 fix.duration_ms = o.duration_ms;
4784 let parsed = verdict::extract_json::<FixReport>(&o.text);
4785 let incomplete_reason = match &parsed {
4786 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4787 "the reply parsed, but it reported a command whose own CLI \
4788 never confirmed an exit status"
4789 .to_owned(),
4790 ),
4791 Ok(_) => None,
4792 Err(e) => Some(e.to_string()),
4793 };
4794 match incomplete_reason {
4795 None => {
4796 let report = parsed.expect("checked Ok above");
4797 fix.addressed = report.addressed;
4798 fix.rejected = report.rejected;
4799 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4800 }
4801 Some(reason) => {
4802 let (resumed_seat, resolved, failure, cont) = self
4803 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4804 .await;
4805 fix.duration_ms += cont.cumulative_wait_ms;
4806 fix.continuation = Some(cont);
4807 final_seat = resumed_seat;
4808 match resolved {
4809 Some(report) => {
4810 fix.addressed = report.addressed;
4811 fix.rejected = report.rejected;
4812 fix.notes =
4813 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4814 }
4815 None => fix.failed = failure,
4816 }
4817 }
4818 }
4819 }
4820 AgentOutcome::Dropped(o) => {
4821 fix.duration_ms = o.duration_ms;
4822 let why = o
4823 .dropped
4824 .as_ref()
4825 .map(|d| d.why.as_str())
4826 .unwrap_or("the CLI ended the stream without delivering its answer");
4827 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4828 }
4829 AgentOutcome::Quota(o) => {
4830 self.state.quota.push(QuotaLoss {
4831 seat: final_seat.key.clone(),
4832 node: "fix".to_owned(),
4833 at: Timestamp::now(),
4834 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4835 });
4836 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4837 }
4838 AgentOutcome::Failed(e) => fix.failed = Some(e),
4839 AgentOutcome::ResourceBlocked(e) => {
4840 fix.failed = Some(format!("the fixer was not started: {e}"));
4841 }
4842 }
4843 if fix.continuation.is_none() {
4844 fix.continuation = Some(ContinuationRecord::not_needed());
4845 }
4846 self.state.seats.insert(final_seat.key.clone(), final_seat);
4847
4848 let rescue_message = format!(
4849 "magi: operator-selected fix ({}) (uncommitted work)",
4850 self.state.operator_fixes[request_index]
4851 .findings
4852 .iter()
4853 .map(|f| f.id.as_str())
4854 .collect::<Vec<_>>()
4855 .join(", ")
4856 );
4857 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4858 self.state.note_withheld("fix", &r.withheld);
4859 }
4860 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4861 fix.committed = after != head_at_request;
4862 git::worktree_remove(&self.state.repo, &fix_worktree)
4863 .await
4864 .ok();
4865
4866 self.state.event(
4867 "fix",
4868 match &fix.failed {
4869 Some(reason) => format!(
4870 "operator fix: adoption report was lost ({reason}); {}",
4871 if fix.committed {
4872 "committed"
4873 } else {
4874 "NO new commit"
4875 }
4876 ),
4877 None => format!(
4878 "operator fix: {} addressed, {} rejected, {}",
4879 fix.addressed.len(),
4880 fix.rejected.len(),
4881 if fix.committed {
4882 "committed"
4883 } else {
4884 "NO new commit"
4885 }
4886 ),
4887 },
4888 );
4889
4890 for f in &mut self.state.operator_fixes[request_index].findings {
4897 f.outcome = if fix.failed.is_some() {
4898 OperatorFixOutcome::Unreported
4899 } else if fix.addressed.contains(&f.id) {
4900 OperatorFixOutcome::Addressed
4901 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4902 OperatorFixOutcome::Rejected { why: r.why.clone() }
4903 } else {
4904 OperatorFixOutcome::Unreported
4905 };
4906 }
4907
4908 let committed = fix.committed;
4909 if committed {
4910 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4911 }
4912 self.state.operator_fixes[request_index].fix = Some(fix);
4913 self.state.save()?;
4916
4917 if committed {
4918 self.state.event(
4919 "fix",
4920 format!(
4921 "operator fix committed {}; opening a follow-up review-only run",
4922 short(&after)
4923 ),
4924 );
4925 let origin =
4928 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4929 match Self::review(
4930 &self.state.repo,
4931 &winner.branch,
4932 self.state.config.clone(),
4933 origin,
4934 )
4935 .await
4936 {
4937 Ok(mut follow_up) => {
4938 follow_up.state.event(
4939 "start",
4940 format!(
4941 "requested by an operator fix on run {} for finding(s) {}",
4942 self.state.id,
4943 self.state.operator_fixes[request_index]
4944 .findings
4945 .iter()
4946 .map(|f| f.id.as_str())
4947 .collect::<Vec<_>>()
4948 .join(", "),
4949 ),
4950 );
4951 follow_up.state.save()?;
4952 let follow_up_id = follow_up.state.id.clone();
4953 let adopted = match crate::direct::adopt(
4956 &follow_up.state,
4957 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4958 ) {
4959 Ok(a) => a,
4960 Err(e) => {
4961 self.state.event(
4964 "fix",
4965 format!(
4966 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4967 ),
4968 );
4969 self.state.save()?;
4970 return Ok(());
4971 }
4972 };
4973 let executed = follow_up.execute().await;
4974 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4975 if let Some(a) = adopted {
4976 a.finish(&follow_up.state, executed);
4977 }
4978 if let Some(e) = failure {
4979 self.state.event(
4980 "fix",
4981 format!(
4982 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4983 ),
4984 );
4985 }
4986 self.state.operator_fixes[request_index].follow_up_review_run =
4987 Some(follow_up_id);
4988 }
4989 Err(e) => {
4990 self.state.event(
4991 "fix",
4992 format!("committed the fix but could not open a follow-up review: {e:#}"),
4993 );
4994 }
4995 }
4996 self.state.save()?;
4997 }
4998
4999 Ok(())
5000 }
5001
5002 async fn ask_fixer(
5022 &mut self,
5023 winner: &Candidate,
5024 node: &'static str,
5025 round: Option<usize>,
5026 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5027 ) -> (SeatJob, SeatState, AgentOutcome) {
5028 let attempts = fixer::attempts(&self.state, &self.roles, winner);
5029 self.ask_fixer_among(attempts, node, round, None, build)
5030 .await
5031 }
5032
5033 async fn ask_fixer_among(
5038 &mut self,
5039 attempts: Vec<(AgentSpec, String)>,
5040 node: &'static str,
5041 round: Option<usize>,
5042 restore: Option<(&Path, &str)>,
5043 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5044 ) -> (SeatJob, SeatState, AgentOutcome) {
5045 let run_id = self.state.id.clone();
5046 let prompts = self.state.config.prompts.clone();
5047 let cache = self.state.config.cache_dir();
5048 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5049 let mut last = None;
5050 for (i, (spec, key)) in attempts.into_iter().enumerate() {
5051 let seat = self.seat(&key, &spec.id);
5052 let mut job = build(spec.clone(), seat);
5053 if i > 0 {
5054 job.stem = format!("{}-{}", job.stem, spec.id);
5055 }
5056 let ctx = WaveCtx {
5057 carry_seats: false,
5058 pauses: Some((&self.pause, &self.interrupt)),
5059 run: &run_id,
5060 node,
5061 prompts: &prompts,
5062 cache: cache.as_deref(),
5063 round,
5064 };
5065 let (seat, out) =
5066 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5067 let advances = match &out {
5070 AgentOutcome::Ok(o) => agent::output_advances(o),
5071 AgentOutcome::ResourceBlocked(_) => false,
5072 _ => true,
5073 };
5074 if let Some(next) = ids.get(i + 1)
5075 && advances
5076 {
5077 self.state.seats.insert(seat.key.clone(), seat.clone());
5078 let class =
5079 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5080 let reason = match &out {
5081 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5082 other => fail_reason(other),
5083 };
5084 record_handover(
5085 &mut self.state,
5086 node,
5087 &seat.key,
5088 &spec.id,
5089 next,
5090 &class,
5091 &reason,
5092 );
5093 if let Some((wt, rev)) = restore
5097 && let Err(e) = git::restore_to(wt, rev).await
5098 {
5099 let why =
5100 format!("could not restore the worktree before the next fixer: {e:#}");
5101 tracing::warn!("{why}");
5102 last = Some((job, seat, AgentOutcome::Failed(why)));
5103 break;
5104 }
5105 continue;
5106 }
5107 last = Some((job, seat, out));
5108 break;
5109 }
5110 last.expect("the fixer chain always has an entry")
5111 }
5112
5113 async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5124 use crate::recovery::{Mode, Repair};
5125 if !self.state.reviews.is_empty() {
5126 return Ok(());
5127 }
5128 let Some(mut rec) = self.state.recovery.clone() else {
5129 return Ok(());
5130 };
5131 if rec.mode != Mode::VerifyRepair {
5132 return Ok(());
5133 }
5134 let wt = winner.worktree.clone();
5135 let head = git::rev_parse(&wt, "HEAD").await?;
5136 match rec.repair.clone() {
5137 Repair::Done { .. } => return Ok(()),
5138 Repair::Started { before } => {
5139 if let Err(e) = Self::graft_restore(&wt, &before).await {
5143 return self
5144 .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5145 .await;
5146 }
5147 rec.repair = Repair::Done {
5148 before: before.clone(),
5149 after: before,
5150 committed: false,
5151 failed: Some("interrupted; rolled back".to_owned()),
5152 };
5153 self.state
5154 .event("recovery", "the repair was interrupted and rolled back");
5155 self.state.recovery = Some(rec);
5156 self.state.save()?;
5157 return Ok(());
5158 }
5159 Repair::NotStarted => {}
5160 }
5161 if crate::recovery::recheck(&mut rec, &head) {
5162 self.state.event("recovery", rec.summary());
5163 self.state.recovery = Some(rec);
5164 self.state.save()?;
5165 return Ok(());
5166 }
5167 rec.repair = Repair::Started {
5168 before: head.clone(),
5169 };
5170 self.state.recovery = Some(rec.clone());
5171 self.state.save()?;
5172
5173 let language = self.state.config.graph.language.clone();
5174 let sessions = self.state.config.graph.sessions;
5175 let artifacts = agent::artifacts_dir(&self.state.dir());
5176 let fix_prompt = prompt::recovery_fix(
5177 &self.state.instruction,
5178 &rec,
5179 self.state.config.graph.review_rounds,
5180 &language,
5181 );
5182 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5183 self.state.event(
5184 "recovery",
5185 format!(
5186 "repair: the fixer starts from the failed verification on {}",
5187 short(&head)
5188 ),
5189 );
5190 let (_job, seat, out) = self
5191 .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5192 prompt: fix_prompt.clone(),
5193 spec,
5194 seat,
5195 cwd: wt.clone(),
5196 timeout,
5197 allow_write: true,
5198 sessions,
5199 artifacts: artifacts.clone(),
5200 stem: "fix-recovery".to_owned(),
5201 handover: None,
5202 })
5203 .await;
5204 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5207 if let Err(e) = Self::graft_restore(&wt, &head).await {
5208 return self
5209 .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5210 .await;
5211 }
5212 rec.repair = Repair::NotStarted;
5213 self.state.recovery = Some(rec);
5214 self.state.save()?;
5215 return self.stop_for_resource();
5216 }
5217 let failed = match &out {
5218 AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5219 AgentOutcome::Quota(o) => {
5220 self.state.quota.push(QuotaLoss {
5221 seat: seat.key.clone(),
5222 node: "fix".to_owned(),
5223 at: Timestamp::now(),
5224 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5225 });
5226 Some("rate limited (quota); the fixer could not run".to_owned())
5227 }
5228 AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5229 AgentOutcome::Failed(e) => Some(e.clone()),
5230 };
5231 self.state.seats.insert(seat.key.clone(), seat);
5232 let failed = match failed {
5233 Some(why) => {
5235 if let Err(e) = git::restore_to(&wt, &head).await {
5236 return self
5237 .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5238 .await;
5239 }
5240 Some(why)
5241 }
5242 None => {
5243 if let Ok(r) = git::rescue_commit(
5244 &wt,
5245 "magi: recovery repair of the failed verification (uncommitted work)",
5246 )
5247 .await
5248 {
5249 self.state.note_withheld("fix", &r.withheld);
5250 }
5251 None
5252 }
5253 };
5254 let after = git::rev_parse(&wt, "HEAD").await?;
5255 self.state.event(
5256 "recovery",
5257 match &failed {
5258 Some(why) => format!("repair: nothing kept ({why})"),
5259 None if after != head => {
5260 format!("repair: committed {}", short(&after))
5261 }
5262 None => "repair: the fixer made no commit".to_owned(),
5263 },
5264 );
5265 rec.repair = Repair::Done {
5266 before: head.clone(),
5267 committed: after != head,
5268 after,
5269 failed,
5270 };
5271 self.state.recovery = Some(rec);
5272 self.state.save()?;
5273 Ok(())
5274 }
5275
5276 async fn stop_recovery(
5279 &mut self,
5280 mut rec: crate::recovery::RecoveryRecord,
5281 head: &str,
5282 why: String,
5283 ) -> Result<()> {
5284 rec.repair = crate::recovery::Repair::Started {
5286 before: head.to_owned(),
5287 };
5288 self.state.recovery = Some(rec);
5289 self.state
5290 .event("recovery", format!("repair could not be undone: {why}"));
5291 self.state.status = RunStatus::Blocked;
5292 self.state.save()?;
5293 bail!("recovery repair could not be rolled back: {why}")
5294 }
5295
5296 async fn review_loop(&mut self) -> Result<()> {
5297 if self
5302 .state
5303 .base_sync
5304 .as_ref()
5305 .is_some_and(|s| s.conflict.is_some())
5306 {
5307 return Ok(());
5308 }
5309 let run_id = self.state.id.clone();
5314 let prompts = self.state.config.prompts.clone();
5315 let Some(winner) = self.state.winner().cloned() else {
5316 return Ok(());
5317 };
5318 let max_rounds = self.state.config.graph.review_rounds;
5319 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5329 if status == RunStatus::Gating {
5332 self.record_contested_handoff();
5333 }
5334 self.state.status = status;
5335 self.state.save()?;
5336 return Ok(());
5337 }
5338 self.state.status = RunStatus::Reviewing;
5339 if self
5349 .state
5350 .reviews
5351 .last()
5352 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5353 {
5354 let shell = self.state.config.shell();
5355 return self
5356 .stop_reviewing(
5357 "the last round's own verification never resolved",
5358 &shell,
5359 &winner.worktree,
5360 )
5361 .await;
5362 }
5363
5364 self.recovery_repair(&winner).await?;
5365 if self.resource_halt()? {
5366 return Ok(());
5367 }
5368
5369 let repo = self.state.repo.clone();
5370 let root = self.state.worktree_root();
5371 let language = self.state.config.graph.language.clone();
5372 let sessions = self.state.config.graph.sessions;
5373 let artifacts = agent::artifacts_dir(&self.state.dir());
5374 let base = self.landing_base();
5375 let base_short = short(&base);
5376 let reviewers = self.roles.reviewers.clone();
5377 let shell = self.state.config.shell();
5378
5379 for round in (self.state.reviews.len() + 1)..=max_rounds {
5380 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5381 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5382 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5383 let prev_verification = self
5392 .state
5393 .reviews
5394 .last()
5395 .and_then(|r| r.verification_summary(&head));
5396 let prior_section = match (&self.state.recovery, round) {
5399 (Some(rec), 1) if self.state.reviews.is_empty() => {
5400 let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5401 (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5402 Some(crate::recovery::head_of(
5403 &git::diff(&winner.worktree, reviewed, "HEAD")
5404 .await
5405 .unwrap_or_default(),
5406 crate::recovery::SINCE_DIFF_MAX,
5407 ))
5408 }
5409 _ => None,
5410 };
5411 Some(prompt::recovery_review(rec, since.as_deref()))
5412 }
5413 _ => None,
5414 };
5415
5416 let mut jobs = Vec::new();
5420 for (r, spec) in reviewers.iter().cloned().enumerate() {
5421 let wt = root.join(format!("review-{}", r + 1));
5422 if wt.exists() {
5425 git::reset_detached(&wt, &head).await?;
5426 } else {
5427 git::worktree_add_detached(&repo, &wt, &head).await?;
5428 }
5429 self.setup_seat_worktree(&repo, &wt, false).await?;
5430 let seat_key = format!("review-{}", r + 1);
5431 let spec = pick_start_spec(
5434 &self.roles.reviewer_roster,
5435 spec,
5436 self.state.seat_history.get(&seat_key),
5437 );
5438 let seat = self.seat(&seat_key, &spec.id);
5439 jobs.push(SeatJob {
5440 prompt: prompt::review(&prompt::ReviewCtx {
5441 instruction: &self.state.instruction,
5442 branch: &winner.branch,
5443 base_short: &base_short,
5444 stat: &stat,
5445 patch: &patch,
5446 verification: prev_verification.as_ref(),
5447 prior: prior_section.as_deref(),
5448 reviewers: reviewers.len(),
5449 round,
5450 rounds: max_rounds,
5451 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5454 lens: Lens::for_seat(r),
5455 language: &language,
5456 github_language: &self.state.config.graph.github_language,
5457 }),
5458 spec,
5459 seat,
5460 cwd: wt,
5461 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5462 allow_write: false,
5463 sessions,
5464 artifacts: artifacts.clone(),
5465 stem: format!("review-{round}-{}", r + 1),
5466 handover: None,
5467 });
5468 }
5469
5470 self.state.event(
5471 "review",
5472 format!(
5473 "round {round}: {} reviewers on {}",
5474 jobs.len(),
5475 short(&head)
5476 ),
5477 );
5478 let mut quota_losses = Vec::new();
5479 let review_retries = self.state.config.graph.retries;
5480 let review_cache = self.state.config.cache_dir();
5481 let ctx = WaveCtx {
5482 carry_seats: true,
5483 pauses: Some((&self.pause, &self.interrupt)),
5484 run: &run_id,
5485 node: "review",
5486 prompts: &prompts,
5487 cache: review_cache.as_deref(),
5488 round: Some(round),
5489 };
5490 let results = ask_json_wave::<Review>(
5491 jobs,
5492 Arc::clone(&self.sem),
5493 review_retries,
5494 &self.roles.reviewer_roster,
5495 &ctx,
5496 &mut quota_losses,
5497 &mut self.state,
5498 &|_: &Review| Ok(()),
5499 )
5500 .await;
5501 if self.state.resource_block.is_some() {
5502 return self.stop_for_resource();
5503 }
5504 let round_quota_missing = quota_losses.len();
5508 self.state.quota.extend(quota_losses);
5509
5510 let mut records = Vec::new();
5511 let mut all_findings = Vec::new();
5512 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5513 let agent_id = seat.agent.clone();
5514 self.state.seats.insert(seat.key.clone(), seat);
5515 let mut record = ReviewRecord {
5516 reviewer: r + 1,
5517 agent: agent_id,
5518 summary: String::new(),
5519 findings: Vec::new(),
5520 vote: None,
5521 failed: None,
5522 duration_ms: 0,
5523 attempts,
5529 };
5530 match res {
5531 Ok((review, out)) => {
5532 record.summary =
5540 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5541 record.vote = Some(review.vote);
5542 record.duration_ms = out.duration_ms;
5543 for (n, mut f) in review.findings.into_iter().enumerate() {
5544 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5547 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5548 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5549 f.file = f
5555 .file
5556 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5557 all_findings.push(f.clone());
5558 record.findings.push(f);
5559 }
5560 self.state.event(
5561 "review",
5562 format!(
5563 "round {round}: reviewer {} voted {} with {} finding(s)",
5564 r + 1,
5565 review.vote.label(),
5566 record.findings.len()
5567 ),
5568 );
5569 }
5570 Err(e) => {
5571 record.failed = Some(e.to_string());
5572 self.state.event(
5573 "review",
5574 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5575 );
5576 }
5577 }
5578 records.push(record);
5579 }
5580
5581 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5588 let vote_split =
5589 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5590 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5591 if vote_split {
5592 self.state.event(
5593 "review",
5594 format!(
5595 "round {round}: votes split ({}) — one round of reconsideration",
5596 initial_votes
5597 .iter()
5598 .map(|v| v.label())
5599 .collect::<Vec<_>>()
5600 .join(", ")
5601 ),
5602 );
5603 let panel: Vec<ReviewSeatReport<'_>> = records
5606 .iter()
5607 .filter_map(|r| {
5608 r.vote.map(|vote| ReviewSeatReport {
5609 reviewer: r.reviewer,
5610 vote,
5611 summary: &r.summary,
5612 findings: &r.findings,
5613 })
5614 })
5615 .collect();
5616
5617 let mut jobs = Vec::new();
5618 let mut seats_at = Vec::new();
5619 for (r, spec) in reviewers.iter().cloned().enumerate() {
5620 if records[r].vote.is_none() {
5624 continue;
5625 }
5626 let wt = root.join(format!("review-{}", r + 1));
5627 let seat_key = format!("review-{}", r + 1);
5628 let spec = self.occupant(&seat_key, spec);
5629 let seat = self.seat(&seat_key, &spec.id);
5630 let build = |with_patch: bool| {
5638 prompt::review_reconsider(&ReviewReconsiderCtx {
5639 instruction: &self.state.instruction,
5640 reviewer: r + 1,
5641 lens: Lens::for_seat(r),
5642 panel: &panel,
5643 patch: with_patch.then_some(ReviewPatch {
5644 branch: &winner.branch,
5645 base_short: &base_short,
5646 stat: &stat,
5647 patch: &patch,
5648 }),
5649 round,
5650 rounds: max_rounds,
5651 language: &language,
5652 })
5653 };
5654 let full = build(true);
5655 let prompt = if has_context(&spec, &seat, sessions) {
5656 build(false)
5657 } else {
5658 full.clone()
5659 };
5660 jobs.push(SeatJob {
5661 prompt,
5662 spec,
5663 seat,
5664 cwd: wt,
5665 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5666 allow_write: false,
5667 sessions,
5668 artifacts: artifacts.clone(),
5669 stem: format!("review-{round}-reconsider-{}", r + 1),
5670 handover: Some(full),
5671 });
5672 seats_at.push(r);
5673 }
5674
5675 let mut recon_quota_losses = Vec::new();
5676 let recon_cache = self.state.config.cache_dir();
5677 let recon_ctx = WaveCtx {
5678 carry_seats: true,
5679 pauses: Some((&self.pause, &self.interrupt)),
5680 run: &run_id,
5681 node: "review",
5682 prompts: &prompts,
5683 cache: recon_cache.as_deref(),
5684 round: Some(round),
5685 };
5686 let recon_results = ask_json_wave::<ReviewRevote>(
5687 jobs,
5688 Arc::clone(&self.sem),
5689 review_retries,
5690 &self.roles.reviewer_roster,
5691 &recon_ctx,
5692 &mut recon_quota_losses,
5693 &mut self.state,
5694 &|_: &ReviewRevote| Ok(()),
5695 )
5696 .await;
5697 if self.state.resource_block.is_some() {
5698 return self.stop_for_resource();
5699 }
5700 self.state.quota.extend(recon_quota_losses);
5701
5702 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5703 let agent_id = seat.agent.clone();
5704 self.state.seats.insert(seat.key.clone(), seat);
5705 let mut rec = ReviewRevoteRecord {
5706 reviewer: r + 1,
5707 agent: agent_id,
5708 vote: None,
5709 reason: String::new(),
5710 failed: None,
5711 };
5712 match res {
5713 Ok((rv, _)) => {
5714 rec.vote = Some(rv.vote);
5715 rec.reason =
5716 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5717 self.state.event(
5718 "review",
5719 format!(
5720 "round {round}: reviewer {} revoted {}",
5721 r + 1,
5722 rv.vote.label()
5723 ),
5724 );
5725 }
5726 Err(e) => {
5727 rec.failed = Some(e.to_string());
5728 self.state.event(
5729 "review",
5730 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5731 );
5732 }
5733 }
5734 reconsideration.push(rec);
5735 }
5736 } else if initial_votes.len() > 1 {
5737 self.state.event(
5738 "review",
5739 format!(
5740 "round {round}: votes agreed ({}) — no reconsideration",
5741 initial_votes[0].label()
5742 ),
5743 );
5744 }
5745
5746 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5747 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5748 let defer_e2e =
5759 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5760 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5761 let reason =
5762 format!("{blocking} blocking finding(s) already required a fix this round");
5763 self.state.event(
5764 "verify",
5765 format!(
5766 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5767 {}); it will run once a round has none left",
5768 short(&head)
5769 ),
5770 );
5771 (Vec::new(), false, true, Some(reason))
5772 } else {
5773 let e2e_commands = self.state.config.verify.e2e.clone();
5774 let cache_dir = self.state.config.cache_dir();
5775 let context = format!("round {round}");
5776 let (e2e, verify_retried) = with_cache_lease(
5777 &mut self.state,
5778 cache_dir.as_deref(),
5779 "e2e",
5780 "e2e",
5781 &winner.worktree,
5782 &head,
5783 verify_timeout,
5784 &context,
5785 |state, budget| {
5786 let shell = shell.clone();
5787 let e2e_commands = e2e_commands.clone();
5788 let worktree = winner.worktree.clone();
5789 let context = context.clone();
5790 async move {
5791 run_e2e_with_retry(
5792 state,
5793 &shell,
5794 &e2e_commands,
5795 &worktree,
5796 budget,
5797 &context,
5798 )
5799 .await
5800 }
5801 },
5802 )
5803 .await;
5804 (e2e, verify_retried, false, None)
5805 };
5806
5807 let expected = records.len();
5808 let answered = records.iter().filter(|r| r.failed.is_none()).count();
5809 let incomplete = answered < expected;
5810 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5811 let policy = self.state.config.graph.incomplete_review;
5812 let clean = round_is_clean(
5813 blocking,
5814 e2e_ok,
5815 answered,
5816 expected,
5817 round_quota_missing,
5818 policy,
5819 );
5820
5821 let mut round_record = ReviewRound {
5822 round,
5823 head: head.clone(),
5824 verified_head: None,
5825 verified_at: None,
5826 reviews: records,
5827 e2e,
5828 verify_retried,
5829 e2e_deferred,
5830 e2e_defer_reason,
5831 fix: None,
5832 blocking,
5833 answered,
5834 expected,
5835 clean,
5836 progressed: false,
5837 vote_split,
5838 reconsideration,
5839 verdict: None,
5840 };
5841 round_record.verdict = ReviewVote::worst(
5845 round_record
5846 .final_votes()
5847 .into_iter()
5848 .map(|(_, _, vote)| vote),
5849 );
5850 if !matches!(
5861 round_record.e2e_status(),
5862 E2eStatus::Deferred | E2eStatus::NotConfigured
5863 ) {
5864 round_record.verified_head = Some(head.clone());
5865 round_record.verified_at = Some(Timestamp::now());
5866 }
5867 let this_round_verification = round_record.verification_summary(&head);
5868
5869 if incomplete {
5870 let missing: Vec<String> = round_record
5871 .reviews
5872 .iter()
5873 .filter(|r| r.failed.is_some())
5874 .map(|r| format!("review-{}", r.reviewer))
5875 .collect();
5876 self.state.event(
5877 "review",
5878 format!(
5879 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5880 missing.join(", ")
5881 ),
5882 );
5883 }
5884
5885 if clean {
5886 self.state.event(
5887 "review",
5888 if incomplete && policy == IncompleteReviewPolicy::Warn {
5889 format!(
5890 "round {round}: clean (warn policy, incomplete panel) — no \
5891 blocking findings from the seats that answered, verification green"
5892 )
5893 } else if incomplete {
5894 format!(
5895 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5896 quorum) — no blocking findings from the seats that answered, \
5897 verification green",
5898 expected - answered
5899 )
5900 } else {
5901 format!("round {round}: clean — no blocking findings, verification green")
5902 },
5903 );
5904 self.state.reviews.push(round_record);
5905 self.state.status = RunStatus::Gating;
5906 self.state.save()?;
5907 return Ok(());
5908 }
5909
5910 if incomplete && blocking == 0 && e2e_ok {
5918 self.state.reviews.push(round_record);
5919 self.state.save()?;
5920 if round == max_rounds {
5921 self.state.status = RunStatus::Blocked;
5922 self.state.event(
5923 "review",
5924 format!(
5925 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5926 refusing to call it clean",
5927 expected - answered
5928 ),
5929 );
5930 return Ok(());
5931 }
5932 continue;
5933 }
5934
5935 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5947 self.state.reviews.push(round_record);
5948 return self
5949 .stop_reviewing(
5950 "the round's own verification could not run",
5951 &shell,
5952 &winner.worktree,
5953 )
5954 .await;
5955 }
5956
5957 if round == max_rounds {
5958 self.state.reviews.push(round_record);
5959 return self
5960 .stop_reviewing(
5961 &format!(
5962 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5963 ),
5964 &shell,
5965 &winner.worktree,
5966 )
5967 .await;
5968 }
5969
5970 let blocking_findings: Vec<_> = all_findings
5973 .iter()
5974 .filter(|f| f.severity.blocks())
5975 .cloned()
5976 .collect();
5977 let fix_prompt = prompt::fix_in(
5978 &self.state.instruction,
5979 &blocking_findings,
5980 this_round_verification.as_ref(),
5981 round,
5982 max_rounds,
5983 &language,
5984 &self.state.config.graph.github_language,
5985 );
5986 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5987 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5988 let (job, seat, out) = self
5989 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5990 prompt: fix_prompt.clone(),
5991 spec,
5992 seat,
5993 cwd: winner.worktree.clone(),
5994 timeout,
5995 allow_write: true,
5996 sessions,
5997 artifacts: artifacts.clone(),
5998 stem: format!("fix-{round}"),
5999 handover: None,
6000 })
6001 .await;
6002 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6005 return self.stop_for_resource();
6006 }
6007 let agent_id = seat.agent.clone();
6008
6009 let mut fix = FixRecord {
6010 agent: agent_id,
6011 addressed: Vec::new(),
6012 rejected: Vec::new(),
6013 notes: String::new(),
6014 committed: false,
6015 failed: None,
6016 duration_ms: 0,
6017 continuation: None,
6018 };
6019 let mut continuation = ContinuationRecord::not_needed();
6020 let mut final_seat = seat.clone();
6021 match out {
6022 AgentOutcome::Ok(o) => {
6023 fix.duration_ms = o.duration_ms;
6024 let parsed = verdict::extract_json::<FixReport>(&o.text);
6025 let incomplete_reason = match &parsed {
6032 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6033 "the reply parsed, but it reported a command whose own CLI never \
6034 confirmed an exit status"
6035 .to_owned(),
6036 ),
6037 Ok(_) => None,
6038 Err(e) => Some(e.to_string()),
6039 };
6040 match incomplete_reason {
6041 None => {
6042 let report = parsed.expect("checked Ok above");
6043 fix.addressed = report.addressed;
6044 fix.rejected = report.rejected;
6045 fix.notes =
6046 blind::sanitize_prose(&report.notes, &self.state.config.blind);
6047 }
6048 Some(reason) => {
6049 let (resumed_seat, resolved, failure, cont) = self
6050 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6051 .await;
6052 fix.duration_ms += cont.cumulative_wait_ms;
6053 continuation = cont;
6054 final_seat = resumed_seat;
6055 match resolved {
6056 Some(report) => {
6057 fix.addressed = report.addressed;
6058 fix.rejected = report.rejected;
6059 fix.notes = blind::sanitize_prose(
6060 &report.notes,
6061 &self.state.config.blind,
6062 );
6063 }
6064 None => fix.failed = failure,
6065 }
6066 }
6067 }
6068 }
6069 AgentOutcome::Dropped(o) => {
6071 fix.duration_ms = o.duration_ms;
6072 let why = o
6073 .dropped
6074 .as_ref()
6075 .map(|d| d.why.as_str())
6076 .unwrap_or("the CLI ended the stream without delivering its answer");
6077 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6078 }
6079 AgentOutcome::Quota(o) => {
6080 self.state.quota.push(QuotaLoss {
6081 seat: final_seat.key.clone(),
6082 node: "fix".to_owned(),
6083 at: Timestamp::now(),
6084 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6085 });
6086 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6087 }
6088 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6089 }
6090 fix.continuation = Some(continuation);
6091 self.state.seats.insert(final_seat.key.clone(), final_seat);
6092 if let Ok(r) = git::rescue_commit(
6093 &winner.worktree,
6094 &format!("magi: review round {round} fixes (uncommitted work)"),
6095 )
6096 .await
6097 {
6098 self.state.note_withheld("fix", &r.withheld);
6099 }
6100 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6101 fix.committed = after != before;
6102 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6110 let progressed = diff_after != patch;
6111 let commit_note = if fix.committed {
6112 "committed"
6113 } else {
6114 "NO new commit"
6115 };
6116 let tree_note = if progressed {
6117 "changed vs base"
6118 } else {
6119 "unchanged vs base"
6120 };
6121 self.state.event(
6122 "fix",
6123 match &fix.failed {
6124 Some(reason) => {
6130 format!(
6131 "round {round}: fixer's adoption report was lost ({reason}); \
6132 {commit_note}, tree {tree_note}"
6133 )
6134 }
6135 None => format!(
6136 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6137 {tree_note}{}",
6138 fix.addressed.len(),
6139 fix.rejected.len(),
6140 if continuation.outcome == ContinuationOutcome::Resumed {
6141 format!(
6142 " (adoption report recovered after {} continuation(s))",
6143 continuation.attempts
6144 )
6145 } else {
6146 String::new()
6147 },
6148 ),
6149 },
6150 );
6151 round_record.fix = Some(fix);
6152 round_record.progressed = progressed;
6153 self.state.reviews.push(round_record);
6154 self.state.save()?;
6155
6156 if matches!(
6169 continuation.outcome,
6170 ContinuationOutcome::Exhausted
6171 | ContinuationOutcome::QuotaLost
6172 | ContinuationOutcome::NoSession
6173 ) {
6174 return self
6175 .stop_reviewing(
6176 "the fixer's adoption report never came back, even after resuming its \
6177 own seat; refusing to start another round against the same worktree \
6178 while that is unresolved",
6179 &shell,
6180 &winner.worktree,
6181 )
6182 .await;
6183 }
6184
6185 let streak = self
6186 .state
6187 .reviews
6188 .iter()
6189 .rev()
6190 .take_while(|r| !r.progressed)
6191 .count();
6192 if streak >= STAGNANT_LIMIT {
6193 return self
6194 .stop_reviewing(
6195 &format!(
6196 "the tree has not moved against base for {streak} round(s) in a row"
6197 ),
6198 &shell,
6199 &winner.worktree,
6200 )
6201 .await;
6202 }
6203 }
6204 Ok(())
6205 }
6206
6207 fn record_contested_handoff(&mut self) {
6241 if self.state.contested_handoff.is_some() {
6242 return;
6243 }
6244 let Some(contested) = self
6245 .state
6246 .reviews
6247 .last()
6248 .and_then(ReviewRound::contested_handoff)
6249 else {
6250 return;
6251 };
6252 self.state.event(
6253 "review",
6254 format!(
6255 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6256 wait for the owner's approval",
6257 contested.findings.len(),
6258 contested.rejecters.len()
6259 ),
6260 );
6261 self.state.contested_handoff = Some(contested);
6262 }
6263
6264 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6265 let round_idx = self.state.reviews.len() - 1;
6266 let needs_catchup_run = matches!(
6274 self.state.reviews[round_idx].e2e_status(),
6275 E2eStatus::Deferred | E2eStatus::ResourceBlocked
6276 );
6277 if needs_catchup_run {
6278 let round = self.state.reviews[round_idx].round;
6279 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6280 let commands = self.state.config.verify.e2e.clone();
6281 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6282 let cache_dir = self.state.config.cache_dir();
6283 let context = format!(
6284 "round {round}: verification unresolved, catching up before the final decision"
6285 );
6286 let (outcomes, verify_retried) = with_cache_lease(
6287 &mut self.state,
6288 cache_dir.as_deref(),
6289 "e2e",
6290 "e2e",
6291 worktree,
6292 &attempted_head,
6293 timeout,
6294 &context,
6295 |state, budget| {
6296 let shell = shell.to_vec();
6297 let commands = commands.clone();
6298 let context = context.clone();
6299 async move {
6300 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6301 .await
6302 }
6303 },
6304 )
6305 .await;
6306 let last = &mut self.state.reviews[round_idx];
6307 last.e2e = outcomes;
6308 last.verify_retried = verify_retried;
6309 last.verified_head = Some(attempted_head);
6316 last.verified_at = Some(Timestamp::now());
6317 if verify_inconclusive(&last.e2e) {
6318 self.state.save()?;
6325 return Ok(());
6326 }
6327 last.e2e_deferred = false;
6328 }
6329 let last = &self.state.reviews[round_idx];
6330 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6331
6332 match last.e2e_status() {
6333 E2eStatus::Failed => {
6334 let red: Vec<String> = last
6335 .e2e
6336 .iter()
6337 .filter(|o| !o.ok())
6338 .map(|o| {
6339 format!(
6340 "`{}` -> {:?}\n{}",
6341 o.command,
6342 o.code,
6343 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6344 )
6345 })
6346 .collect();
6347 self.state
6348 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6349 self.state.status = RunStatus::Blocked;
6350 }
6351 E2eStatus::ResourceBlocked => {
6356 self.state.event(
6357 "review",
6358 format!(
6359 "{why}; e2e could not run (shared build cache unavailable); not \
6360 deciding yet"
6361 ),
6362 );
6363 }
6364 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6365 self.state.event(
6366 "review",
6367 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6368 );
6369 self.record_contested_handoff();
6370 self.state.status = RunStatus::Gating;
6371 }
6372 }
6373 self.state.save()?;
6374 Ok(())
6375 }
6376
6377 async fn gate(&mut self) -> Result<()> {
6380 if self.state.status == RunStatus::Failed
6392 || self
6393 .state
6394 .base_sync
6395 .as_ref()
6396 .is_some_and(|s| s.conflict.is_some())
6397 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6398 != Some(RunStatus::Gating)
6399 {
6400 return Ok(());
6401 }
6402 if self.state.gate_ran {
6403 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6414 self.state.status = RunStatus::Blocked;
6415 self.state.save()?;
6416 }
6417 return Ok(());
6418 }
6419 let Some(winner) = self.state.winner().cloned() else {
6420 return Ok(());
6421 };
6422 self.state.status = RunStatus::Gating;
6423 let mut outcomes = self.run_gate(&winner).await?;
6424 loop {
6425 if verify_inconclusive(&outcomes) {
6436 self.state.save()?;
6437 return Ok(());
6438 }
6439 if outcomes.iter().all(CommandOutcome::ok) {
6440 break;
6441 }
6442 match self.gate_fix_round(&winner, &outcomes).await? {
6443 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6444 GateFix::Stop => break,
6445 GateFix::Defer => {
6446 self.state.save()?;
6447 return Ok(());
6448 }
6449 }
6450 }
6451 let passed = outcomes.iter().all(CommandOutcome::ok);
6452 self.state.gate = outcomes;
6453 self.state.gate_ran = true;
6454 self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6455 if !passed {
6456 self.state.status = RunStatus::Blocked;
6457 let spent = self.state.gate_fixes.len();
6458 self.state.event(
6459 "gate",
6460 if spent == 0 {
6461 "gate failed; not merging".to_owned()
6462 } else {
6463 format!("gate failed after {spent} gate-fix round(s); not merging")
6464 },
6465 );
6466 }
6467 self.state.save()?;
6468 Ok(())
6469 }
6470
6471 async fn run_pre_gate(&mut self, winner: &Candidate) {
6481 let commands = self.state.config.verify.pre_gate.clone();
6482 if commands.is_empty() {
6483 return;
6484 }
6485 let shell = self.state.config.shell();
6486 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6487 let (outcomes, _) = run_commands(
6488 &mut self.state,
6489 "pre_gate",
6490 "pre_gate",
6491 0,
6492 &shell,
6493 &commands,
6494 &winner.worktree,
6495 timeout,
6496 )
6497 .await;
6498 for o in &outcomes {
6499 if !o.ok() {
6500 tracing::warn!(
6501 "pre_gate `{}` failed ({:?}); the gate decides",
6502 o.command,
6503 o.code
6504 );
6505 }
6506 self.state.event(
6507 "pre_gate",
6508 format!(
6509 "`{}` -> {}",
6510 o.command,
6511 if o.ok() {
6512 "pass".to_owned()
6513 } else {
6514 format!(
6515 "FAIL ({:?})\n{}",
6516 o.code,
6517 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6518 )
6519 }
6520 ),
6521 );
6522 }
6523 self.state.pre_gate = outcomes;
6524 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6525 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6526 Ok(head) => {
6527 self.state
6528 .event("pre_gate", format!("committed mechanical fixes ({head})"));
6529 self.state.pre_gate_commit = Some(head);
6530 }
6531 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6532 },
6533 Ok(false) => {}
6534 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6535 }
6536 if let Err(e) = self.state.save() {
6537 tracing::warn!("could not persist the pre_gate record: {e:#}");
6538 }
6539 }
6540
6541 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6544 self.run_pre_gate(winner).await;
6545 let shell = self.state.config.shell();
6546 let gate_commands = self.state.config.verify.gate.clone();
6547 let outcomes = if gate_commands.is_empty() {
6556 Vec::new()
6557 } else {
6558 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6559 let cache_dir = self.state.config.cache_dir();
6560 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6561 let (outcomes, _) = with_cache_lease(
6562 &mut self.state,
6563 cache_dir.as_deref(),
6564 "gate",
6565 "gate",
6566 &winner.worktree,
6567 &head,
6568 timeout,
6569 "final gate",
6570 |state, budget| {
6571 let shell = shell.clone();
6572 let gate_commands = gate_commands.clone();
6573 let worktree = winner.worktree.clone();
6574 async move {
6575 let (outcomes, timed_out_pids) = run_commands(
6576 state,
6577 "gate",
6578 "gate",
6579 0,
6580 &shell,
6581 &gate_commands,
6582 &worktree,
6583 budget,
6584 )
6585 .await;
6586 (outcomes, false, timed_out_pids)
6587 }
6588 },
6589 )
6590 .await;
6591 outcomes
6592 };
6593 if outcomes.is_empty() {
6594 self.state.event(
6599 "gate",
6600 "no gate commands configured; nothing to check, passing",
6601 );
6602 }
6603 for o in &outcomes {
6604 self.state.event(
6605 "gate",
6606 format!(
6607 "`{}` -> {}",
6608 o.command,
6609 if o.ok() {
6610 "pass".to_owned()
6611 } else {
6612 format!(
6613 "FAIL ({:?})\n{}",
6614 o.code,
6615 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6616 )
6617 }
6618 ),
6619 );
6620 }
6621 Ok(outcomes)
6622 }
6623
6624 async fn gate_fix_round(
6637 &mut self,
6638 winner: &Candidate,
6639 outcomes: &[CommandOutcome],
6640 ) -> Result<GateFix> {
6641 let cap = self.state.config.graph.gate_fix_rounds;
6642 let spent = self.state.gate_fixes.len();
6643 if spent >= cap {
6644 if cap > 0 {
6645 self.state.event(
6646 "gate",
6647 format!("{spent} gate-fix round(s) spent and the gate still fails"),
6648 );
6649 }
6650 return Ok(GateFix::Stop);
6651 }
6652 if !gate_fixable(outcomes) {
6653 self.state.event(
6654 "gate",
6655 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6656 command or similar); not spending a fix round on it",
6657 );
6658 return Ok(GateFix::Stop);
6659 }
6660 let min_free = self.state.config.disk.min_free_bytes;
6661 if min_free > 0 {
6662 match crate::disk::free_bytes(&winner.worktree) {
6663 Ok(free) if crate::disk::enough_space(free, min_free) => {}
6664 Ok(free) => {
6665 self.state.event(
6666 "gate",
6667 format!(
6668 "only {free} bytes free ({min_free} required by `[disk] \
6669 min_free_bytes`); not spending a fix round on a failure the disk \
6670 may explain"
6671 ),
6672 );
6673 return Ok(GateFix::Stop);
6674 }
6675 Err(e) => {
6676 self.state.event(
6677 "gate",
6678 format!("free disk space could not be measured ({e:#}); no fix round"),
6679 );
6680 return Ok(GateFix::Stop);
6681 }
6682 }
6683 }
6684
6685 let attempt = spent + 1;
6686 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6687 let base = self.landing_base();
6688 let fix_prompt = prompt::gate_fix_in(
6689 &self.state.instruction,
6690 &failed,
6691 attempt,
6692 cap,
6693 &self.state.config.graph.language,
6694 &self.state.config.graph.github_language,
6695 );
6696 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6697 let sessions = self.state.config.graph.sessions;
6698 let artifacts = agent::artifacts_dir(&self.state.dir());
6699 self.state.event(
6700 "gate",
6701 format!("gate failed; gate-fix round {attempt} of {cap}"),
6702 );
6703 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6704 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6705 let (_, seat, out) = self
6706 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6707 prompt: fix_prompt.clone(),
6708 spec,
6709 seat,
6710 cwd: winner.worktree.clone(),
6711 timeout,
6712 allow_write: true,
6713 sessions,
6714 artifacts: artifacts.clone(),
6715 stem: format!("gate-fix-{attempt}"),
6716 handover: None,
6717 })
6718 .await;
6719 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6722 return Ok(GateFix::Defer);
6723 }
6724 let mut record = GateFixRecord {
6725 agent: seat.agent.clone(),
6726 failed,
6727 notes: String::new(),
6728 committed: false,
6729 error: None,
6730 };
6731 match out {
6732 AgentOutcome::Ok(o) => {
6733 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6736 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6737 }
6738 }
6739 AgentOutcome::Dropped(_) => {
6740 record.error = Some("the CLI dropped the stream".to_owned());
6741 }
6742 AgentOutcome::Quota(o) => {
6743 self.state.quota.push(QuotaLoss {
6744 seat: seat.key.clone(),
6745 node: "gate-fix".to_owned(),
6746 at: Timestamp::now(),
6747 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6748 });
6749 record.error = Some("rate limited (quota); fixer could not run".to_owned());
6750 }
6751 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6752 }
6753 self.state.seats.insert(seat.key.clone(), seat);
6754 if let Ok(r) = git::rescue_commit(
6755 &winner.worktree,
6756 &format!("magi: gate fix {attempt} (uncommitted work)"),
6757 )
6758 .await
6759 {
6760 self.state.note_withheld("gate-fix", &r.withheld);
6761 }
6762 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6763 record.committed = after != before;
6764 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6765 let note = record.error.clone();
6766 self.state.gate_fixes.push(record);
6767 self.state.save()?;
6768 if !changed {
6769 self.state.event(
6770 "gate",
6771 match note {
6772 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6773 None => format!("gate-fix round {attempt}: the tree did not change"),
6774 },
6775 );
6776 return Ok(GateFix::Stop);
6777 }
6778 self.state.event(
6779 "gate",
6780 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6781 );
6782
6783 let commands = self.state.config.verify.e2e.clone();
6784 if !commands.is_empty() {
6785 let shell = self.state.config.shell();
6786 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6787 let cache_dir = self.state.config.cache_dir();
6788 let context = format!("gate-fix round {attempt}");
6789 let (e2e, _) = with_cache_lease(
6790 &mut self.state,
6791 cache_dir.as_deref(),
6792 "e2e",
6793 "e2e",
6794 &winner.worktree,
6795 &after,
6796 timeout,
6797 &context,
6798 |state, budget| {
6799 let shell = shell.clone();
6800 let commands = commands.clone();
6801 let context = context.clone();
6802 let worktree = winner.worktree.clone();
6803 async move {
6804 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6805 .await
6806 }
6807 },
6808 )
6809 .await;
6810 if verify_inconclusive(&e2e) {
6811 return Ok(GateFix::Defer);
6812 }
6813 if e2e.iter().any(|o| !o.ok()) {
6814 self.state.event(
6815 "gate",
6816 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6817 );
6818 return Ok(GateFix::Stop);
6819 }
6820 }
6821 Ok(GateFix::Retry)
6822 }
6823
6824 async fn guarded_pr_message(
6831 &mut self,
6832 winner: &Candidate,
6833 facts: Option<&BranchFacts>,
6834 posting: bool,
6835 ) -> Result<Option<PrMessage>> {
6836 let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6837 let head = match final_head.as_deref() {
6838 Some(h) => crate::pr_outcome::FinalHead::At(h),
6839 None => crate::pr_outcome::FinalHead::Unknown,
6840 };
6841 let summarized = if posting {
6843 Some(self.summarize_change(winner, final_head.as_deref()).await)
6844 } else {
6845 None
6846 };
6847 if self.state.resource_block.is_some() {
6852 self.state.event(
6853 "pr-summary",
6854 "a shared resource was busy; nothing was published, resume to summarize again",
6855 );
6856 return Ok(None);
6857 }
6858 if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
6859 anyhow::bail!(
6860 "the final commit moved during public description generation; retry before publishing"
6861 );
6862 }
6863 let note = match (&summarized, &self.state.change_summary) {
6864 (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
6865 (Some(Err(why)), _) => ChangeNote::Unavailable(why),
6866 (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
6867 (None, _) => ChangeNote::NotAsked,
6868 };
6869 let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
6870 if !posting {
6871 let identity = crate::scrub::Identity::current();
6872 return Ok(Some(PrMessage {
6873 title: crate::scrub::scrub(&pr.title, &identity),
6874 body: crate::scrub::scrub(&pr.body, &identity),
6875 }));
6876 }
6877 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6880 if let Some(known) = self
6881 .state
6882 .github_text
6883 .clone()
6884 .filter(|g| g.fingerprint == raw_fp)
6885 {
6886 return self.settle_withheld(winner, &pr, known).await;
6887 }
6888 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6889 let mut violations =
6890 crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
6891 let identity = crate::scrub::Identity::current();
6892 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6893 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6894 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6895 {
6896 violations.push(crate::github_text::Violation::SensitiveData);
6897 }
6898 let has_language = violations
6902 .iter()
6903 .any(|v| *v != crate::github_text::Violation::SensitiveData);
6904 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6905 self.state.event(
6906 "github-text",
6907 if has_language {
6908 format!("description rejected: {violations:?}; requesting one rewrite")
6909 } else {
6910 format!("description rejected: {violations:?}; asking the owner")
6911 },
6912 );
6913 let mut rewritten = false;
6914 let mut rejected_rewrite = None;
6915 if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
6916 {
6917 let output = text;
6918 if let Some(title) = summary_title(&output) {
6919 let raw_body = summary_without_title(&output);
6922 let clean_title = crate::scrub::scrub(&title, &identity);
6923 let mut body = crate::scrub::scrub(&raw_body, &identity);
6924 let redacted = clean_title != title || body != raw_body;
6925 let title = clean_title;
6926 let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
6927 if !complete {
6928 self.state.event(
6929 "github-text",
6930 "description rewrite omitted required findings",
6931 );
6932 }
6933 if !body.trim().is_empty() {
6934 body.push_str("\n\n");
6935 body.push_str(&public_pr_evidence(&self.state, head));
6936 }
6937 let marker = format!("magi:run/{}", self.state.id);
6938 if !body.trim().is_empty() && !body.contains(&marker) {
6939 body.push_str(&format!("\n\n{marker}"));
6940 }
6941 let rewrite = PrMessage {
6942 title: title.clone(),
6943 body: body.clone(),
6944 };
6945 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6946 let mut failed = crate::github_text::check_for(
6947 &self.state.config.graph,
6948 &title,
6949 &body,
6950 redecision,
6951 );
6952 if (!crate::github_text::shareable(&title)
6953 || !crate::github_text::shareable(&body))
6954 && !failed.contains(&crate::github_text::Violation::SensitiveData)
6955 {
6956 failed.push(crate::github_text::Violation::SensitiveData);
6957 }
6958 if complete
6959 && !title.trim().is_empty()
6960 && !body.trim().is_empty()
6961 && !failed.is_empty()
6962 {
6963 rejected_rewrite = Some(crate::run::GithubTextRewrite {
6964 title: title.clone(),
6965 body: body.clone(),
6966 failed: failed
6967 .iter()
6968 .map(|v| crate::github_text::category(*v).to_owned())
6969 .collect(),
6970 });
6971 }
6972 if complete
6973 && !title.trim().is_empty()
6974 && !raw_body.trim().is_empty()
6975 && failed.is_empty()
6976 {
6977 decision = redecision;
6978 pr = PrMessage { title, body };
6979 rewritten = true;
6980 self.state
6981 .event("github-text", "description rewrite passed");
6982 if redacted {
6983 self.state.event(
6984 "github-text",
6985 "sensitive data removed from description rewrite",
6986 );
6987 }
6988 }
6989 }
6990 }
6991 if !rewritten {
6992 self.state.event(
6993 "github-text",
6994 "description rewrite unavailable or rejected; asking the owner",
6995 );
6996 if let Some(w) =
6997 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6998 {
6999 self.retire_github_text_question("the withheld text changed");
7002 let record = crate::run::GithubTextAsk {
7003 rewrite: rejected_rewrite,
7004 fingerprint: raw_fp,
7005 title: w.title,
7006 body: w.body,
7007 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7008 question: None,
7009 asks: 0,
7010 resolved: false,
7011 chosen_title: None,
7012 choice: None,
7013 };
7014 return self.settle_withheld(winner, &pr, record).await;
7015 }
7016 }
7017 }
7018 let (title, body) =
7019 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7020 Ok(Some(PrMessage { title, body }))
7021 }
7022
7023 async fn summarize_change(
7034 &mut self,
7035 winner: &Candidate,
7036 head: Option<&str>,
7037 ) -> std::result::Result<(), String> {
7038 let Some(head) = head else {
7039 return Err("the final commit could not be read".to_owned());
7040 };
7041 let remote = self.state.config.merge.remote.clone();
7042 let tracking = format!("{remote}/{}", self.state.base_branch);
7043 if !git::rev_exists(&winner.worktree, &tracking).await {
7044 return Err(format!("the remote base `{tracking}` could not be read"));
7045 }
7046 let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7047 Ok(b) if !b.is_empty() => b,
7048 _ => return Err("the merge base with the remote base could not be read".to_owned()),
7049 };
7050 let evidence = crate::pr_outcome::summary_evidence(
7051 &self.state,
7052 crate::pr_outcome::FinalHead::At(head),
7053 );
7054 let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7055 if self
7056 .state
7057 .change_summary
7058 .as_ref()
7059 .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7060 {
7061 return Ok(());
7062 }
7063 if self.state.change_summary.take().is_some() {
7064 self.state.event(
7065 "pr-summary",
7066 "the final diff or evidence changed; the earlier change summary was discarded",
7067 );
7068 }
7069 if let Err(e) = write_artifact(
7071 &self.state,
7072 &format!("public-description-{fingerprint}.md"),
7073 &format!("Final diff: {base}..{head}\n\n{evidence}"),
7074 ) {
7075 tracing::warn!("could not save public description evidence: {e:#}");
7076 }
7077 let chain = match agent::pick_chain(
7078 &self.state.config.agents,
7079 self.state.config.roles.synthesizer.as_ref(),
7080 &agent::installed,
7081 "synthesizer",
7082 ) {
7083 Ok(chain) => chain,
7084 Err(e) => {
7085 tracing::warn!("no agent for the change summary: {e:#}");
7086 self.state.event("pr-summary", "no summary agent available");
7087 return Err("no agent was available to summarize it".to_owned());
7088 }
7089 };
7090 let subjects = git::subjects(&winner.worktree, &base, head)
7091 .await
7092 .unwrap_or_default();
7093 let stat = git::diff_stat(&winner.worktree, &base, head)
7094 .await
7095 .unwrap_or_default();
7096 let stat: String = stat.chars().take(6_000).collect();
7097 let mut prompt = crate::prompt::change_summary_in(
7098 &base,
7099 head,
7100 &subjects,
7101 stat.trim(),
7102 &self.state.config.graph.github_language,
7103 );
7104 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."));
7105 let sessions = self.state.config.graph.sessions;
7106 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7107 let prompts = self.state.config.prompts.clone();
7108 let cache = self.state.config.cache_dir();
7109 let run = self.state.id.clone();
7110 for (i, spec) in chain.into_iter().enumerate() {
7111 let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7112 let job = SeatJob {
7113 spec: spec.clone(),
7114 seat,
7115 cwd: winner.worktree.clone(),
7116 prompt: prompt.clone(),
7117 timeout,
7118 allow_write: false,
7119 sessions,
7120 artifacts: agent::artifacts_dir(&self.state.dir()),
7121 stem: if i == 0 {
7122 "change-summary".to_owned()
7123 } else {
7124 format!("change-summary-{}", spec.id)
7125 },
7126 handover: None,
7127 };
7128 let ctx = WaveCtx {
7129 run: &run,
7130 node: "pr-summary",
7131 prompts: &prompts,
7132 cache: cache.as_deref(),
7133 round: None,
7134 carry_seats: false,
7135 pauses: Some((&self.pause, &self.interrupt)),
7136 };
7137 let (seat, outcome) =
7138 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7139 self.state.seats.insert(seat.key.clone(), seat);
7140 let text = match outcome {
7141 AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7142 AgentOutcome::ResourceBlocked(why) => {
7143 if self.state.resource_block.is_none() {
7146 self.state.resource_block = Some(crate::run::ResourceBlock {
7147 node: "pr-summary".to_owned(),
7148 kind: "shared resource".to_owned(),
7149 detail: why,
7150 at: Timestamp::now(),
7151 });
7152 }
7153 return Err("a shared resource was busy".to_owned());
7154 }
7155 _ => continue,
7156 };
7157 let blind = &self.state.config.blind;
7158 let Some(title) = summary_title(&text) else {
7159 continue;
7160 };
7161 let body: String = summary_without_title(&text)
7162 .lines()
7163 .filter(|l| !l.contains("magi:run/"))
7164 .collect::<Vec<_>>()
7165 .join("\n");
7166 let body = blind::sanitize_prose(body.trim(), blind);
7167 let title = blind::sanitize_prose(&title, blind);
7168 if body.trim().is_empty() || title.trim().is_empty() {
7169 continue;
7170 }
7171 if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7172 continue;
7173 }
7174 if git::rev_parse(&winner.worktree, "HEAD")
7176 .await
7177 .ok()
7178 .as_deref()
7179 != Some(head)
7180 {
7181 return Err("the branch moved while the summary was written".to_owned());
7182 }
7183 if git::merge_base(&winner.worktree, &tracking, head)
7184 .await
7185 .ok()
7186 .as_deref()
7187 != Some(base.as_str())
7188 || crate::pr_outcome::summary_evidence(
7189 &self.state,
7190 crate::pr_outcome::FinalHead::At(head),
7191 ) != evidence
7192 {
7193 return Err("the summary evidence changed while it was written".to_owned());
7194 }
7195 self.state.change_summary = Some(crate::run::ChangeSummary {
7196 head: head.to_owned(),
7197 base,
7198 evidence: fingerprint,
7199 title,
7200 body,
7201 });
7202 self.state.event(
7203 "pr-summary",
7204 format!("change summary written by {} for the final diff", spec.id),
7205 );
7206 if let Err(e) = self.state.save() {
7207 tracing::warn!("could not save the change summary: {e:#}");
7208 }
7209 return Ok(());
7210 }
7211 self.state
7212 .event("pr-summary", "no agent produced a change summary");
7213 Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7214 }
7215
7216 async fn rewrite_pr_text(
7224 &mut self,
7225 winner: &Candidate,
7226 pr: &PrMessage,
7227 violations: &[crate::github_text::Violation],
7228 ) -> Option<String> {
7229 let prompt = format!(
7230 "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{}",
7231 pr.title, pr.body
7232 );
7233 let sessions = self.state.config.graph.sessions;
7234 let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7235 let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7237 let key = format!("impl-{}", winner.label);
7238 if let Some(spec) = self
7239 .state
7240 .config
7241 .agents
7242 .iter()
7243 .find(|a| a.id == winner.agent)
7244 .cloned()
7245 && let Some(existing) = self.state.seats.get(&key)
7246 && existing.agent == spec.id
7247 && agent::has_session(spec.kind, existing, sessions)
7248 {
7249 attempts.push((spec, existing.clone(), true));
7250 } else {
7251 let chain = agent::pick_chain(
7252 &self.state.config.agents,
7253 self.state.config.roles.language_judge.as_ref(),
7254 &agent::installed,
7255 "language_judge",
7256 );
7257 match chain {
7258 Ok(chain) => {
7259 for spec in chain {
7260 let seat = crate::fixer::seat_for(
7261 &mut self.state,
7262 "github-text-rewrite",
7263 &spec.id,
7264 );
7265 attempts.push((spec, seat, false));
7266 }
7267 }
7268 Err(e) => {
7269 tracing::warn!("no agent for the description rewrite: {e:#}");
7270 self.state
7271 .event("github-text", "no rewrite agent available");
7272 return None;
7273 }
7274 }
7275 }
7276 let prompts = self.state.config.prompts.clone();
7277 let cache = self.state.config.cache_dir();
7278 let run = self.state.id.clone();
7279 for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7280 let job = SeatJob {
7281 spec: spec.clone(),
7282 seat,
7283 cwd: winner.worktree.clone(),
7284 prompt: prompt.clone(),
7285 timeout: retry_budget(timeout_full, resumed),
7286 allow_write: false,
7287 sessions,
7288 artifacts: agent::artifacts_dir(&self.state.dir()),
7289 stem: if i == 0 {
7290 "github-text-rewrite".to_owned()
7291 } else {
7292 format!("github-text-rewrite-{}", spec.id)
7293 },
7294 handover: None,
7295 };
7296 let ctx = WaveCtx {
7297 run: &run,
7298 node: "github-text",
7299 prompts: &prompts,
7300 cache: cache.as_deref(),
7301 round: None,
7302 carry_seats: false,
7303 pauses: Some((&self.pause, &self.interrupt)),
7304 };
7305 let (seat, outcome) =
7306 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7307 self.state.seats.insert(seat.key.clone(), seat);
7308 match outcome {
7309 AgentOutcome::Ok(o)
7310 if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7311 {
7312 return Some(o.text);
7313 }
7314 AgentOutcome::ResourceBlocked(_) => return None,
7315 _ => {}
7316 }
7317 }
7318 None
7319 }
7320
7321 fn retire_github_text_question(&mut self, why: &str) {
7323 let Some(id) = self
7324 .state
7325 .github_text
7326 .as_ref()
7327 .and_then(|g| g.question.clone())
7328 else {
7329 return;
7330 };
7331 let _ = ask::Questions::open().update(&id, |q| {
7332 q.abandon(why);
7333 Ok(())
7334 });
7335 }
7336
7337 async fn settle_withheld(
7341 &mut self,
7342 winner: &Candidate,
7343 pr: &PrMessage,
7344 mut g: crate::run::GithubTextAsk,
7345 ) -> Result<Option<PrMessage>> {
7346 use crate::github_text as gt;
7347 let store = ask::Questions::open();
7348 let timeout = self.state.config.graph.answer_timeout;
7349 if !g.resolved {
7350 let mut standing = g
7351 .question
7352 .as_ref()
7353 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7354 if let Some(q) = standing.as_ref()
7355 && q.status.open()
7356 && gt::expired(q, timeout)
7357 {
7358 let why = format!("no answer within {}s of asking", timeout.max(1));
7359 standing = store
7360 .update(&q.id, |q| {
7361 q.abandon(&why);
7362 Ok(())
7363 })
7364 .ok()
7365 .map(|(q, ())| q);
7366 }
7367 let mut ask_again: Option<Option<Vec<String>>> = None;
7368 match standing {
7369 None if g.asks <= 1 => {
7372 g.asks = 0;
7373 ask_again = Some(None);
7374 }
7375 None => {
7376 g.resolved = true;
7377 g.chosen_title = None;
7378 g.choice = Some(GithubTextChoice::Fallback);
7379 }
7380 Some(q) if q.status.open() => {
7381 self.park_on_github_text(&g)?;
7382 return Ok(None);
7383 }
7384 Some(q) => match gt::read_reply(&q) {
7385 gt::Reply::Fallback => {
7386 g.resolved = true;
7387 g.chosen_title = None;
7388 g.choice = Some(GithubTextChoice::Fallback);
7389 }
7390 gt::Reply::Original => {
7391 g.resolved = true;
7392 g.chosen_title = None;
7393 g.choice = Some(GithubTextChoice::Original);
7394 }
7395 gt::Reply::Rewrite => {
7396 g.resolved = true;
7397 g.chosen_title = None;
7398 g.choice = Some(if g.rewrite.is_some() {
7399 GithubTextChoice::Rewrite
7400 } else {
7401 GithubTextChoice::Fallback
7402 });
7403 }
7404 gt::Reply::Title(text) => {
7405 let first = text
7406 .lines()
7407 .map(str::trim)
7408 .find(|l| !l.is_empty())
7409 .unwrap_or("");
7410 let decision = if first.is_empty() {
7411 None
7412 } else {
7413 let candidate = PrMessage {
7414 title: first.to_owned(),
7415 body: String::new(),
7416 };
7417 self.judge_pr_language(&winner.worktree, &candidate).await
7418 };
7419 match gt::vet_title(
7420 &text,
7421 decision,
7422 self.state.config.graph.github_english(),
7423 ) {
7424 Ok(title) => {
7425 g.resolved = true;
7426 g.chosen_title = Some(title);
7427 g.choice = Some(GithubTextChoice::MyTitle);
7428 }
7429 Err(cats) if g.asks < 2 => {
7430 ask_again = Some(Some(
7431 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7432 ))
7433 }
7434 Err(_) => {
7435 g.resolved = true;
7436 g.chosen_title = None;
7437 g.choice = Some(GithubTextChoice::Fallback);
7438 }
7439 }
7440 }
7441 },
7442 }
7443 if let Some(rejected) = ask_again {
7444 let w = gt::Withheld {
7447 title: g.title,
7448 body: g.body,
7449 categories: g.categories.clone(),
7450 };
7451 let artifact = match crate::run::write_artifact(
7454 &self.state,
7455 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
7456 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7457 ) {
7458 Ok(p) => Some(p),
7459 Err(e) => {
7460 tracing::warn!("could not write the withheld-text artifact: {e:#}");
7461 None
7462 }
7463 };
7464 let (detail, comparison) = gt::question_content(
7465 &w,
7466 &pr.title,
7467 &pr.body,
7468 &self.state.id,
7469 rejected.as_deref(),
7470 artifact.as_deref(),
7471 g.rewrite.as_ref(),
7472 );
7473 let mut q = ask::Question::new(
7474 self.state.id.clone(),
7475 gt::ASK_NODE.to_owned(),
7476 gt::ASK_SEAT.to_owned(),
7477 gt::question_summary(&self.state.config.graph.language, &w),
7478 detail,
7479 gt::question_choices(&w, g.rewrite.as_ref()),
7480 );
7481 q.posting_comparison = Some(comparison);
7482 q.answer_timeout = timeout;
7483 g.question = Some(q.id.clone());
7486 g.asks += 1;
7487 self.state.github_text = Some(g.clone());
7488 self.state.event(
7489 "github-text",
7490 format!("asking the owner about the withheld text ({})", q.short()),
7491 );
7492 self.state.save()?;
7493 store
7494 .put(&mut q)
7495 .context("file the posting-gate question")?;
7496 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7497 tracing::warn!(
7498 "could not notify about posting-gate question {}: {e:#}",
7499 q.short()
7500 );
7501 }
7502 self.park_on_github_text(&g)?;
7503 return Ok(None);
7504 }
7505 }
7506 let resolved = g.chosen_title.clone();
7507 let original = g.choice == Some(GithubTextChoice::Original);
7508 self.state.event(
7510 "github-text",
7511 if original {
7512 format!(
7513 "posting the original text for: {}",
7514 match (g.title, g.body) {
7515 (true, true) => "title, description",
7516 (true, false) => "title",
7517 _ => "description",
7518 }
7519 )
7520 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7521 "posting the saved rewrite title and description".to_owned()
7522 } else if resolved.is_some() {
7523 "posting the owner's replacement title".to_owned()
7524 } else {
7525 "posting the neutral text for the withheld fields".to_owned()
7526 },
7527 );
7528 self.state.github_text = Some(g.clone());
7529 self.state.save()?;
7530 if g.choice == Some(GithubTextChoice::Rewrite)
7531 && let Some(r) = g.rewrite.as_ref()
7532 {
7533 return Ok(Some(PrMessage {
7534 title: r.title.clone(),
7535 body: r.body.clone(),
7536 }));
7537 }
7538 let w = gt::Withheld {
7539 title: g.title,
7540 body: g.body,
7541 categories: g.categories.clone(),
7542 };
7543 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7544 if let Some(t) = resolved {
7545 title = t;
7546 }
7547 let english = gt::LanguageDecision {
7549 title_english: true,
7550 body_english: true,
7551 body_complete: true,
7552 };
7553 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7554 if original {
7555 if g.title {
7558 title = pr.title.clone();
7559 }
7560 if g.body {
7561 body = pr.body.clone();
7562 }
7563 }
7564 Ok(Some(PrMessage { title, body }))
7565 }
7566
7567 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7568 self.state.github_text = Some(g.clone());
7569 self.state.end_pass_parked(ParkCause::Question);
7570 self.state.parked = true;
7571 self.state.event(
7572 "github-text",
7573 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7574 );
7575 self.state.save()
7576 }
7577
7578 async fn judge_pr_language(
7581 &mut self,
7582 cwd: &Path,
7583 pr: &PrMessage,
7584 ) -> Option<crate::github_text::LanguageDecision> {
7585 self.state.config.roles.language_judge.as_ref()?;
7586 if !self.state.config.graph.language_gate() {
7587 return None;
7588 }
7589 let decision =
7590 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7591 self.state.event(
7592 "github-text",
7593 if decision.is_some() {
7594 "language decided by the language judge"
7595 } else {
7596 "language judge unavailable; using built-in heuristics"
7597 },
7598 );
7599 decision
7600 }
7601
7602 async fn merge(&mut self) -> Result<()> {
7603 if self
7618 .state
7619 .base_sync
7620 .as_ref()
7621 .is_some_and(|s| s.conflict.is_some())
7622 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7623 != Some(RunStatus::Gating)
7624 || !self.state.gate_status().ok()
7633 {
7634 return Ok(());
7635 }
7636 if self.state.merge.is_some() && !self.state.pr_publication_pending {
7645 return Ok(());
7646 }
7647 let Some(winner) = self.state.winner().cloned() else {
7648 return Ok(());
7649 };
7650 let repo = self.state.repo.clone();
7651 let base = self.state.base_branch.clone();
7652 let mode = self.state.config.merge.mode;
7653 let style = self.state.config.merge.style;
7654 let facts = if is_review_run(&self.state) {
7655 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7656 let start = review_base(
7657 &repo,
7658 &self.state.config.merge.remote,
7659 &base,
7660 &self.state.base_commit,
7661 &winner.branch,
7662 )
7663 .await;
7664 branch_facts(&repo, &start, &winner.branch).await
7665 } else {
7666 None
7667 };
7668 let leaked = if is_review_run(&self.state) {
7671 leaked_subjects(&self.state, &winner.branch).await
7672 } else {
7673 Some(Vec::new())
7674 };
7675 let Some(pr) = self
7679 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7680 .await?
7681 else {
7682 return Ok(());
7683 };
7684 let message = pr.commit_message();
7685
7686 let outcome = match mode {
7687 MergeMode::None => MergeOutcome {
7688 mode,
7689 ok: true,
7690 detail: manual_merge_command(style, &repo, &winner.branch, &message),
7691 empty: false,
7692 },
7693 MergeMode::Pr | MergeMode::Local
7694 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7695 {
7696 MergeOutcome {
7697 mode,
7698 ok: false,
7699 detail: empty_candidate_detail(&self.state, &base),
7700 empty: true,
7701 }
7702 }
7703 MergeMode::Local => {
7704 let on = git::current_branch(&repo).await?;
7705 if on.as_deref() != Some(base.as_str()) {
7706 MergeOutcome {
7707 mode,
7708 ok: false,
7709 detail: format!(
7710 "{} has {} checked out, not the base branch {base}",
7711 repo.display(),
7712 on.unwrap_or_else(|| "a detached HEAD".to_owned())
7713 ),
7714 empty: false,
7715 }
7716 } else if !git::is_clean(&repo).await? {
7717 MergeOutcome {
7718 mode,
7719 ok: false,
7720 detail: format!("{} is dirty; refusing to merge", repo.display()),
7721 empty: false,
7722 }
7723 } else {
7724 let out = match style {
7725 MergeStyle::Merge => {
7726 git::merge_no_ff(&repo, &winner.branch, &message).await?
7727 }
7728 MergeStyle::Squash => {
7729 git::merge_squash(&repo, &winner.branch, &message).await?
7730 }
7731 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7732 };
7733 MergeOutcome {
7734 mode,
7735 ok: out.ok(),
7736 detail: if out.ok() { out.stdout } else { out.stderr },
7737 empty: false,
7738 }
7739 }
7740 }
7741 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7742 self.state.pr_publication_pending = true;
7743 self.state.save()?;
7744 let out =
7745 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7746 self.state.pr_publication_pending = out.is_err();
7747 MergeOutcome {
7748 mode,
7749 ok: out.is_ok(),
7750 detail: match out {
7751 Ok(url) => url,
7752 Err(e) => format!("{e:#}"),
7753 },
7754 empty: false,
7755 }
7756 }
7757 MergeMode::Pr => {
7758 let remote = self.state.config.merge.remote.clone();
7759 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
7760 if !pushed.ok() {
7761 MergeOutcome {
7762 mode,
7763 ok: false,
7764 detail: pushed.stderr,
7765 empty: false,
7766 }
7767 } else {
7768 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7774 let out = match pr_merge_plan(found) {
7775 PrPlan::Create => {
7776 gh_pr_create(
7777 &winner.worktree,
7778 &base,
7779 &winner.branch,
7780 &pr.title,
7781 &pr.body,
7782 )
7783 .await
7784 }
7785 PrPlan::Adopt { url, title } => {
7786 self.state
7787 .event("merge", format!("Pr: adopted open pull request {url}"));
7788 if self.state.github_text.as_ref().is_some_and(|g| {
7789 g.resolved
7790 && g.choice == Some(GithubTextChoice::Rewrite)
7791 && g.rewrite.is_some()
7792 }) {
7793 land::set_pr_text_verbatim(
7796 &winner.worktree,
7797 &url,
7798 &pr.title,
7799 &pr.body,
7800 )
7801 .await
7802 .map(|()| url)
7803 } else {
7804 if title != pr.title
7805 && (!is_review_run(&self.state)
7806 || leaked
7807 .as_deref()
7808 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7809 && let Err(e) =
7810 if self.state.github_text.as_ref().is_some_and(|g| {
7811 g.resolved
7812 && g.title
7813 && g.choice == Some(GithubTextChoice::Original)
7814 }) {
7815 land::set_pr_title_verbatim(
7817 &winner.worktree,
7818 &url,
7819 &pr.title,
7820 )
7821 .await
7822 } else {
7823 land::set_pr_title(
7824 &mut self.state,
7825 &winner.worktree,
7826 &url,
7827 &pr.title,
7828 )
7829 .await
7830 }
7831 {
7832 tracing::warn!("could not refresh title of {url}: {e:#}");
7833 self.state
7834 .event("merge", format!("Pr: title refresh failed: {e:#}"));
7835 }
7836 Ok(url)
7837 }
7838 }
7839 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7840 };
7841 match out {
7842 Ok(url) => MergeOutcome {
7843 mode,
7844 ok: true,
7845 detail: url,
7846 empty: false,
7847 },
7848 Err(e) => MergeOutcome {
7849 mode,
7850 ok: false,
7851 detail: e.to_string(),
7852 empty: false,
7853 },
7854 }
7855 }
7856 }
7857 };
7858
7859 self.state.status = match (mode, outcome.ok) {
7860 (MergeMode::None, _) => RunStatus::Ready,
7861 (_, true) => RunStatus::Merged,
7862 (_, false) => RunStatus::Blocked,
7863 };
7864 self.state.event(
7865 "merge",
7866 format!(
7867 "{:?}: {}",
7868 mode,
7869 outcome.detail.lines().next().unwrap_or("")
7870 ),
7871 );
7872 self.state.merge = Some(outcome);
7873 self.state.save()?;
7874
7875 if self.state.config.graph.land
7881 && mode == MergeMode::Pr
7882 && self.state.status == RunStatus::Merged
7883 {
7884 self.run_land().await?;
7885 }
7886 self.settle_questions();
7891 Ok(())
7892 }
7893
7894 async fn run_land(&mut self) -> Result<()> {
7905 crate::pr_head::ensure_owner(&self.state)?;
7906 let url = self
7907 .state
7908 .merge
7909 .as_ref()
7910 .map(|m| m.detail.clone())
7911 .unwrap_or_default();
7912 let url = url.lines().next().unwrap_or("").trim().to_owned();
7913 if !url.starts_with("http") {
7914 return Ok(());
7915 }
7916 match land::land(&mut self.state, &url).await {
7919 Ok(pr) if self.state.parked => {
7920 let _ = pr;
7924 }
7925 Ok(pr) => {
7926 self.state.status = match pr.state {
7927 land::PrLifecycle::Merged => RunStatus::Merged,
7928 _ => RunStatus::Blocked,
7929 };
7930 if bump::should_release_bump(self.state.status)
7937 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7938 {
7939 self.state
7945 .event("bump", format!("release bump skipped: {e:#}"));
7946 }
7947 if self.state.status == RunStatus::Merged {
7950 crate::followup::after_merge(&mut self.state, &pr.url).await;
7951 }
7952 self.state.save()?;
7953 }
7954 Err(e) => {
7955 self.state.status = RunStatus::Blocked;
7956 self.state.event("land", format!("gave up: {e}"));
7957 self.state.save()?;
7958 }
7959 }
7960 Ok(())
7961 }
7962
7963 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7967 if let Some(existing) = self.state.seats.get(key)
7968 && existing.agent == agent
7969 {
7970 return existing.clone();
7971 }
7972 let fresh = if self.state.seats.contains_key(key) {
7975 handover_seat(key, agent, self.state.next_seat_seed())
7976 } else {
7977 SeatState::new(key, agent, self.state.seed)
7978 };
7979 self.state.seats.insert(key.to_owned(), fresh.clone());
7980 fresh
7981 }
7982
7983 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7989 match self.state.seats.get(key) {
7990 Some(s) if s.agent != spec.id => {
7991 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7992 }
7993 _ => spec,
7994 }
7995 }
7996
7997 fn view(&self, c: &Candidate) -> CandidateView {
7999 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8000 .unwrap_or_default();
8001 let (patch, _) = blind::sanitize_patch(
8002 &format!("candidate {} patch", c.label),
8003 &raw,
8004 &self.state.config.blind,
8005 );
8006 CandidateView {
8007 label: c.label,
8008 branch: c.branch.clone(),
8009 summary: c.summary.clone(),
8010 stat: c.stat.clone(),
8011 patch,
8012 }
8013 }
8014
8015 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8017 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8018 prompt::judge(
8019 "(see above)",
8020 &views,
8021 self.roles.judges.len(),
8022 base_short,
8023 "en",
8024 )
8025 }
8026
8027 fn vote_prompt_full(
8033 &self,
8034 j: usize,
8035 labels: &[char],
8036 language: &str,
8037 candidates: &[Candidate],
8038 base_short: &str,
8039 ) -> String {
8040 let mut text = format!(
8041 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8042 prompt::final_vote(labels, language),
8043 self.state.instruction,
8044 self.candidate_block(candidates, base_short)
8045 );
8046 if let Some(own) = self
8047 .state
8048 .judgements
8049 .get(j)
8050 .filter(|r| !r.ranking.is_empty())
8051 {
8052 let reasons = own
8053 .reasons
8054 .iter()
8055 .map(|(k, v)| format!("- {k}: {v}"))
8056 .collect::<Vec<_>>()
8057 .join("\n");
8058 text.push_str(&format!(
8059 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8060 own.ranking.iter().collect::<String>(),
8061 if reasons.is_empty() {
8062 ""
8063 } else {
8064 ", because:\n"
8065 }
8066 ));
8067 }
8068 if !self.state.deliberation.is_empty() {
8069 text.push_str("\n# What was argued before this vote\n");
8070 for t in self.transcript(&[], j) {
8071 text.push_str(&format!(
8072 "\n## {}{}\n\n{}\n",
8073 t.who,
8074 if t.is_self { " (you)" } else { "" },
8075 t.body.trim()
8076 ));
8077 }
8078 }
8079 text
8080 }
8081
8082 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8089 let mut turns = Vec::new();
8090 for j in &self.state.judgements {
8091 if j.ranking.is_empty() {
8092 continue;
8093 }
8094 let reasons = j
8095 .reasons
8096 .iter()
8097 .map(|(k, v)| format!("- {k}: {v}"))
8098 .collect::<Vec<_>>()
8099 .join("\n");
8100 turns.push(Turn {
8101 who: format!("Judge {} (opening ranking)", j.judge),
8102 is_self: j.judge == self_idx + 1,
8103 body: format!(
8104 "Ranked {}{}{reasons}",
8105 j.ranking.iter().collect::<String>(),
8106 if reasons.is_empty() {
8107 ""
8108 } else {
8109 ", because:\n"
8110 }
8111 ),
8112 });
8113 }
8114 for t in self
8115 .state
8116 .deliberation
8117 .iter()
8118 .flat_map(|r| r.turns.iter())
8119 .chain(current)
8120 {
8121 turns.push(Turn {
8122 who: format!("Judge {}", t.judge),
8123 is_self: t.judge == self_idx + 1,
8124 body: t.body.clone(),
8125 });
8126 }
8127 turns
8128 }
8129}
8130
8131fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8133 agent::has_session(spec.kind, seat, sessions)
8134}
8135
8136fn next_untried_in_roster<'a>(
8157 roster: &'a [AgentSpec],
8158 start: usize,
8159 tried: &BTreeSet<String>,
8160) -> Option<&'a AgentSpec> {
8161 roster
8162 .get(start + 1..)?
8163 .iter()
8164 .find(|s| !tried.contains(&s.id))
8165}
8166
8167fn pick_successor<'a>(
8188 roster: &'a [AgentSpec],
8189 start: usize,
8190 tried: &BTreeSet<String>,
8191 carried: Option<&BTreeSet<String>>,
8192 others: &BTreeSet<String>,
8193) -> Option<&'a AgentSpec> {
8194 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8195 roster
8196 .get(start + 1..)
8197 .and_then(|tail| {
8198 tail.iter()
8199 .filter(free)
8200 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8201 })
8202 .or_else(|| {
8203 carried
8204 .is_some()
8205 .then(|| roster.iter().find(free))
8206 .flatten()
8207 })
8208 .or_else(|| match carried {
8209 Some(c) => next_for_seat(roster, start, tried, c),
8210 None => next_untried_in_roster(roster, start, tried),
8211 })
8212}
8213
8214fn next_for_seat<'a>(
8224 roster: &'a [AgentSpec],
8225 start: usize,
8226 round_tried: &BTreeSet<String>,
8227 carried_failed: &BTreeSet<String>,
8228) -> Option<&'a AgentSpec> {
8229 roster
8230 .get(start + 1..)?
8231 .iter()
8232 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8233 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8234}
8235
8236fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8242 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8243 return spec;
8244 };
8245 let ok = |id: &str| !h.failed.contains(id);
8246 if let Some(last) = h.last_ok.as_deref()
8247 && ok(last)
8248 && let Some(s) = roster.iter().find(|s| s.id == last)
8249 {
8250 return s.clone();
8251 }
8252 if ok(&spec.id) {
8253 return spec;
8254 }
8255 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8256 roster
8257 .get(from + 1..)
8258 .into_iter()
8259 .flatten()
8260 .chain(roster.iter())
8261 .find(|s| ok(&s.id))
8262 .cloned()
8263 .unwrap_or(spec)
8264}
8265
8266pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8270 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8271}
8272
8273const TIMED_OUT: &str = "timed out";
8276
8277impl FailClass {
8278 fn of(out: &AgentOutcome) -> Option<Self> {
8280 match out {
8281 AgentOutcome::Ok(_) => None,
8282 AgentOutcome::Quota(_) => Some(Self::Quota),
8283 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8284 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8285 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8286 AgentOutcome::ResourceBlocked(_) => None,
8289 }
8290 }
8291
8292 fn stem_word(&self) -> &'static str {
8294 match self {
8295 Self::Quota => "quota",
8296 _ => "handover",
8297 }
8298 }
8299}
8300
8301fn failure_signature(msg: &str) -> String {
8305 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8306 let mut out = Vec::new();
8307 for word in line.split_whitespace() {
8308 if word.contains('/') || word.contains('\\') {
8309 out.push("<path>".to_owned());
8310 continue;
8311 }
8312 let mut w = String::new();
8313 let mut in_digits = false;
8314 for c in word.chars() {
8315 if c.is_ascii_digit() {
8316 if !in_digits {
8317 w.push('#');
8318 }
8319 in_digits = true;
8320 } else {
8321 in_digits = false;
8322 w.push(c);
8323 }
8324 }
8325 out.push(w);
8326 }
8327 out.join(" ").chars().take(120).collect()
8328}
8329
8330fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8337 match cur {
8338 FailClass::Quota | FailClass::Timeout => true,
8339 FailClass::Other(_) => prev != Some(cur),
8340 }
8341}
8342
8343fn fail_reason(out: &AgentOutcome) -> String {
8345 match out {
8346 AgentOutcome::Ok(_) => String::new(),
8347 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8348 AgentOutcome::Dropped(o) => format!(
8349 "the CLI dropped the stream ({})",
8350 o.dropped
8351 .as_ref()
8352 .map(|d| d.why.as_str())
8353 .unwrap_or("it ended without delivering its answer")
8354 ),
8355 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8356 e.lines().next().unwrap_or("").chars().take(160).collect()
8357 }
8358 }
8359}
8360
8361pub(crate) fn record_handover(
8364 state: &mut RunState,
8365 node: &str,
8366 seat: &str,
8367 from: &str,
8368 to: &str,
8369 class: &FailClass,
8370 reason: &str,
8371) {
8372 let message = if *class == FailClass::Quota {
8373 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8374 } else {
8375 format!("{seat}: handed over {from} -> {to} ({reason})")
8376 };
8377 state.event(node, message);
8378 state.handovers.push(Handover {
8379 at: Timestamp::now(),
8380 node: node.to_owned(),
8381 seat: seat.to_owned(),
8382 from: from.to_owned(),
8383 to: to.to_owned(),
8384 reason: reason.to_owned(),
8385 });
8386}
8387
8388fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8402 commands.iter().any(|c| c.exit_code.is_none())
8403}
8404
8405fn verified_noop_claim(
8418 usable: bool,
8419 commands: &[agent::CommandEvidence],
8420 text: &str,
8421) -> Option<String> {
8422 (usable && !has_unconfirmed_command(commands))
8423 .then(|| verdict::verified_noop(text))
8424 .flatten()
8425}
8426
8427fn short(commit: &str) -> String {
8428 commit.chars().take(7).collect()
8429}
8430
8431fn make_executable(path: &Path) -> Result<()> {
8432 #[cfg(unix)]
8433 {
8434 use std::os::unix::fs::PermissionsExt as _;
8435 let mut perms = std::fs::metadata(path)?.permissions();
8436 perms.set_mode(0o755);
8437 std::fs::set_permissions(path, perms)?;
8438 }
8439 #[cfg(not(unix))]
8440 {
8441 let _ = path;
8442 }
8443 Ok(())
8444}
8445
8446struct WaveCtx<'a> {
8453 run: &'a str,
8456 node: &'a str,
8458 prompts: &'a Prompts,
8459 cache: Option<&'a Path>,
8461 round: Option<usize>,
8464 carry_seats: bool,
8468 pauses: Option<(&'a Pause, &'a Pause)>,
8471}
8472
8473async fn run_one(
8475 job: SeatJob,
8476 sem: Arc<Semaphore>,
8477 ctx: &WaveCtx<'_>,
8478 state: &mut RunState,
8479 attempt: usize,
8480) -> (SeatState, AgentOutcome) {
8481 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8482 .await
8483 .pop()
8484 .expect("one job in, one result out");
8485 (seat, out)
8486}
8487
8488async fn wave(
8494 jobs: Vec<SeatJob>,
8495 sem: Arc<Semaphore>,
8496 ctx: &WaveCtx<'_>,
8497 state: &mut RunState,
8498 attempt: usize,
8499) -> Vec<(usize, SeatState, AgentOutcome)> {
8500 let WaveCtx {
8501 run,
8502 node,
8503 prompts,
8504 cache,
8505 round,
8506 carry_seats: _,
8507 pauses,
8508 } = *ctx;
8509 for job in &jobs {
8510 state.seat_started(node, &job.seat.key, job.timeout, attempt);
8511 }
8512 if let Err(e) = state.save() {
8513 tracing::warn!("could not persist in-progress seats: {e:#}");
8518 }
8519 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8535 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8539 let cache_guard = if let Some(cache_dir) = cache {
8540 if jobs_had_a_writer {
8541 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8542 let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8543 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8544 {
8545 Ok(Some(g)) => Some(g),
8546 Ok(None) => {
8549 state.resource_block = Some(crate::run::ResourceBlock {
8550 node: node.to_owned(),
8551 kind: "shared build cache".to_owned(),
8552 detail: "the run is parking".to_owned(),
8553 at: Timestamp::now(),
8554 });
8555 return block_wave(
8556 jobs,
8557 state,
8558 node,
8559 attempt,
8560 "parked while waiting for the shared build cache",
8561 );
8562 }
8563 Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
8566 Err(e) => {
8567 let why = format!("{e:#}");
8571 state.resource_block = Some(crate::run::ResourceBlock {
8572 node: node.to_owned(),
8573 kind: "shared build cache".to_owned(),
8574 detail: why.clone(),
8575 at: Timestamp::now(),
8576 });
8577 state.event(
8578 "resource",
8579 format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
8580 );
8581 return block_wave(jobs, state, node, attempt, &why);
8582 }
8583 }
8584 } else {
8585 None
8586 }
8587 } else {
8588 None
8589 };
8590 state.seats_spawning(node, attempt);
8593 if let Err(e) = state.save() {
8594 tracing::warn!("could not persist the start of a wave: {e:#}");
8595 }
8596 let slot_limit = resource_limit
8600 + jobs
8601 .iter()
8602 .map(|j| j.timeout)
8603 .max()
8604 .unwrap_or_default()
8605 .saturating_mul(jobs.len() as u32);
8606 let mut set = tokio::task::JoinSet::new();
8607 let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
8610 let overlay = prompts.overlay(node);
8611 for (i, mut job) in jobs.into_iter().enumerate() {
8612 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
8613 if cache.is_some() {
8614 job.prompt.push('\n');
8615 job.prompt
8616 .push_str(&prompt::build_cache_note(node, job.allow_write));
8617 }
8618 let sem = Arc::clone(&sem);
8619 let run = run.to_owned();
8620 let node = node.to_owned();
8621 let attachments = if node == "implement" {
8624 state.attachments.clone()
8625 } else {
8626 Vec::new()
8627 };
8628 let cache = cache
8639 .filter(|_| job.allow_write && cache_guard.is_some())
8640 .map(Path::to_path_buf);
8641 let spawned_tx = spawned_tx.clone();
8642 set.spawn(async move {
8643 let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
8647 Ok(p) => p,
8648 Err(_) => {
8649 return (
8650 i,
8651 job.seat,
8652 AgentOutcome::ResourceBlocked(format!(
8653 "no parallel slot became free within {}s",
8654 slot_limit.as_secs()
8655 )),
8656 );
8657 }
8658 };
8659 spawned_tx.send(job.seat.key.clone()).ok();
8660 let mut seat = job.seat;
8661 let out = agent::invoke(
8662 &job.spec,
8663 &mut seat,
8664 &Invocation {
8665 cwd: &job.cwd,
8666 prompt: &job.prompt,
8667 timeout: job.timeout,
8668 allow_write: job.allow_write,
8669 unsandboxed: false,
8670 sessions: job.sessions,
8671 artifacts: &job.artifacts,
8672 stem: &job.stem,
8673 run: &run,
8674 node: &node,
8675 cache_dir: cache.as_deref(),
8676 attachments: &attachments,
8677 writable: &[],
8678 },
8679 )
8680 .await;
8681 let out = match out {
8682 Ok(o) if o.usable() => AgentOutcome::Ok(o),
8683 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
8684 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
8692 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
8693 Ok(o) => AgentOutcome::Failed(format!(
8694 "exited with {:?} and no usable output",
8695 o.exit_code
8696 )),
8697 Err(e) => AgentOutcome::Failed(e.to_string()),
8698 };
8699 (i, seat, out)
8700 });
8701 }
8702 drop(spawned_tx);
8703 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
8704 loop {
8705 let joined = tokio::select! {
8706 Some(key) = spawned_rx.recv() => {
8707 state.seat_spawned(&key);
8709 if let Err(e) = state.save() {
8710 tracing::warn!("could not persist a seat's start: {e:#}");
8711 }
8712 continue;
8713 }
8714 joined = set.join_next() => joined,
8715 };
8716 let Some(joined) = joined else {
8717 break;
8718 };
8719 let (i, seat, out) = match joined {
8720 Ok(v) => v,
8721 Err(e) => {
8725 tracing::error!("agent task panicked: {e}");
8726 continue;
8727 }
8728 };
8729 state.seat_finished(&seat.key);
8730 if let AgentOutcome::ResourceBlocked(why) = &out
8731 && state.resource_block.is_none()
8732 {
8733 state.resource_block = Some(crate::run::ResourceBlock {
8734 node: node.to_owned(),
8735 kind: "parallel slot".to_owned(),
8736 detail: why.clone(),
8737 at: Timestamp::now(),
8738 });
8739 state.event("resource", format!("{node}: no agent was started - {why}"));
8740 }
8741 record_jobs(state, node, round, &seat.key, &out);
8742 if let Err(e) = state.save() {
8743 tracing::warn!("could not persist a seat's completion: {e:#}");
8744 }
8745 if collected.len() <= i {
8746 collected.resize_with(i + 1, || None);
8747 }
8748 collected[i] = Some((i, seat, out));
8749 }
8750 if state
8756 .active
8757 .values()
8758 .any(|a| a.node == node && a.attempt == attempt)
8759 {
8760 state
8761 .active
8762 .retain(|_, a| !(a.node == node && a.attempt == attempt));
8763 if let Err(e) = state.save() {
8764 tracing::warn!("could not persist the end of a wave: {e:#}");
8765 }
8766 }
8767 if let Some(cache_dir) = cache
8774 && jobs_had_a_writer
8775 {
8776 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
8777 }
8778 if let Some(guard) = cache_guard {
8779 guard.release();
8780 }
8781 collected.into_iter().flatten().collect()
8782}
8783
8784fn record_jobs(
8795 state: &mut RunState,
8796 node: &str,
8797 round: Option<usize>,
8798 seat: &str,
8799 out: &AgentOutcome,
8800) {
8801 let commands: &[agent::CommandEvidence] = match out {
8802 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
8803 AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
8804 };
8805 let checked_at = Timestamp::now();
8806 for c in commands {
8807 state.jobs.push(JobRecord {
8808 node: node.to_owned(),
8809 round,
8810 seat: seat.to_owned(),
8811 id: c.id.clone(),
8812 description: c.description.clone(),
8813 checked_at,
8814 status: match c.exit_code {
8815 Some(0) => JobStatus::Completed,
8816 Some(_) => JobStatus::Failed,
8817 None => JobStatus::Unknown,
8818 },
8819 exit_code: c.exit_code,
8820 result_summary: c.result_summary.clone(),
8821 source: c.source.clone(),
8822 });
8823 }
8824}
8825
8826fn round_is_clean(
8847 blocking: usize,
8848 e2e_ok: bool,
8849 answered: usize,
8850 expected: usize,
8851 quota_missing: usize,
8852 policy: IncompleteReviewPolicy,
8853) -> bool {
8854 if blocking != 0 || !e2e_ok {
8855 return false;
8856 }
8857 if answered == expected || policy == IncompleteReviewPolicy::Warn {
8858 return true;
8859 }
8860 answered > 0 && expected - answered <= quota_missing
8861}
8862
8863fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
8887 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
8888 return Some(RunStatus::Gating);
8889 }
8890 let last = reviews.last()?;
8891 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
8892 if reviews.len() < max_rounds && !stagnant {
8893 return None;
8894 }
8895 if last.incomplete() && last.blocking == 0 {
8896 return Some(RunStatus::Blocked);
8897 }
8898 if last.e2e_status() == E2eStatus::ResourceBlocked {
8899 return None;
8900 }
8901 Some(if last.e2e.iter().all(CommandOutcome::ok) {
8902 RunStatus::Gating
8903 } else {
8904 RunStatus::Blocked
8905 })
8906}
8907
8908pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
8923 if nudged {
8924 (full / 4).max(Duration::from_secs(120)).min(full)
8925 } else {
8926 full
8927 }
8928}
8929
8930#[allow(clippy::too_many_arguments)]
8959async fn ask_json_wave<T>(
8960 jobs: Vec<SeatJob>,
8961 sem: Arc<Semaphore>,
8962 retries: usize,
8963 roster: &[AgentSpec],
8964 ctx: &WaveCtx<'_>,
8965 losses: &mut Vec<QuotaLoss>,
8966 state: &mut RunState,
8967 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8968) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8969where
8970 T: serde::de::DeserializeOwned + Send + 'static,
8971{
8972 ask_wave_with(
8973 jobs,
8974 sem,
8975 retries,
8976 roster,
8977 ctx,
8978 losses,
8979 state,
8980 &|text: &str| {
8981 let v = verdict::extract_json::<T>(text)?;
8982 validate(&v)?;
8983 Ok(v)
8984 },
8985 )
8986 .await
8987}
8988
8989#[allow(clippy::too_many_arguments)]
8998async fn ask_wave_with<T>(
8999 jobs: Vec<SeatJob>,
9000 sem: Arc<Semaphore>,
9001 retries: usize,
9002 roster: &[AgentSpec],
9003 ctx: &WaveCtx<'_>,
9004 losses: &mut Vec<QuotaLoss>,
9005 state: &mut RunState,
9006 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9007) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9008where
9009 T: Send + 'static,
9010{
9011 let n = jobs.len();
9012 let originals: Vec<SeatJob> = jobs;
9013 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9014 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9015 let mut nudges: Vec<usize> = vec![0; n];
9022 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9026 let mut tried: Vec<BTreeSet<String>> = specs
9027 .iter()
9028 .map(|s| BTreeSet::from([s.id.clone()]))
9029 .collect();
9030 let starts: Vec<usize> = specs
9031 .iter()
9032 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9033 .collect();
9034 let carry = ctx.carry_seats && !roster.is_empty();
9035 let carried: Vec<BTreeSet<String>> = originals
9038 .iter()
9039 .map(|j| {
9040 state
9041 .seat_history
9042 .get(&j.seat.key)
9043 .filter(|_| carry)
9044 .map(|h| h.failed.clone())
9045 .unwrap_or_default()
9046 })
9047 .collect();
9048 let mut prev: Vec<Option<FailClass>> = originals
9049 .iter()
9050 .map(|j| {
9051 state
9052 .seat_history
9053 .get(&j.seat.key)
9054 .filter(|_| carry)
9055 .and_then(|h| h.last_fail.clone())
9056 })
9057 .collect();
9058 let next_agent =
9059 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9060 let others: BTreeSet<String> = specs
9061 .iter()
9062 .enumerate()
9063 .filter(|(j, _)| *j != i)
9064 .map(|(_, s)| s.id.clone())
9065 .collect();
9066 pick_successor(
9067 roster,
9068 starts[i],
9069 tried,
9070 carry.then(|| &carried[i]),
9071 &others,
9072 )
9073 .cloned()
9074 };
9075 let mut fresh: Vec<Option<String>> = vec![None; n];
9076 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9077 let mut pending: Vec<usize> = (0..n).collect();
9078
9079 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9084 for round in 0..max_rounds {
9085 if pending.is_empty() {
9086 break;
9087 }
9088 let mut batch = Vec::with_capacity(pending.len());
9089 let mut renudged: Vec<&str> = Vec::new();
9090 for &i in &pending {
9091 let src = &originals[i];
9092 let full: &str = match &src.handover {
9097 Some(h) if specs[i].id != src.spec.id => h,
9098 _ => &src.prompt,
9099 };
9100 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9103 (
9104 full.to_owned(),
9105 src.timeout,
9106 format!("{}-{word}-{}", src.stem, specs[i].id),
9107 )
9108 } else if nudges[i] == 0 {
9109 (full.to_owned(), src.timeout, src.stem.clone())
9110 } else {
9111 renudged.push(src.seat.key.as_str());
9112 let why = done[i]
9113 .as_ref()
9114 .and_then(|r| r.as_ref().err().map(ToString::to_string))
9115 .unwrap_or_else(|| "no parsable answer".to_owned());
9116 let nudge = prompt::nudge(&why);
9117 let nudged = has_context(&specs[i], &seats[i], src.sessions);
9118 let prompt = if nudged {
9119 nudge
9120 } else {
9121 format!("{full}\n\n---\n\n{nudge}")
9122 };
9123 (
9124 prompt,
9125 retry_budget(src.timeout, nudged),
9126 format!("{}-retry{}", src.stem, nudges[i]),
9127 )
9128 };
9129 batch.push(SeatJob {
9130 spec: specs[i].clone(),
9131 seat: seats[i].clone(),
9132 cwd: src.cwd.clone(),
9133 prompt,
9134 timeout,
9135 allow_write: src.allow_write,
9136 sessions: src.sessions,
9137 artifacts: src.artifacts.clone(),
9138 stem,
9139 handover: None,
9140 });
9141 }
9142
9143 if !renudged.is_empty() {
9144 state.event(
9145 ctx.node,
9146 format!("retry {round}: re-asking {}", renudged.join(", ")),
9147 );
9148 }
9149 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9150 let mut still = Vec::new();
9151 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9152 seats[i] = seat;
9153 if let AgentOutcome::ResourceBlocked(why) = &out {
9156 done[i] = Some(Err(anyhow::anyhow!("{why}")));
9157 continue;
9158 }
9159 let class = FailClass::of(&out);
9160 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9163 && nudges[i] < retries
9164 && !roster.is_empty();
9165 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9166 let next = should_hand_over(prev[i].as_ref(), &cur)
9167 .then(|| next_agent(i, &tried[i], &specs))
9168 .flatten();
9169 if carry {
9170 let h = state
9171 .seat_history
9172 .entry(originals[i].seat.key.clone())
9173 .or_default();
9174 h.failed.insert(specs[i].id.clone());
9175 h.last_fail = Some(cur.clone());
9176 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9177 h.last_ok = None;
9178 }
9179 if let Err(e) = state.save() {
9182 tracing::warn!("could not persist a seat's failure history: {e:#}");
9183 }
9184 }
9185 if let Some(next) = next {
9186 record_handover(
9187 state,
9188 ctx.node,
9189 &originals[i].seat.key,
9190 &specs[i].id,
9191 &next.id,
9192 &cur,
9193 &fail_reason(&out),
9194 );
9195 tried[i].insert(next.id.clone());
9196 prev[i] = Some(cur.clone());
9197 seats[i] =
9198 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9199 specs[i] = next;
9200 fresh[i] = Some(cur.stem_word().to_owned());
9201 nudges[i] = 0;
9202 done[i] = Some(Err(anyhow::anyhow!(
9203 "handed over after: {}",
9204 fail_reason(&out)
9205 )));
9206 still.push(i);
9207 continue;
9208 }
9209 }
9210 if carry
9211 && !nudge_first
9212 && let Some(cur) = class.clone()
9213 {
9214 let h = state
9216 .seat_history
9217 .entry(originals[i].seat.key.clone())
9218 .or_default();
9219 h.failed.insert(specs[i].id.clone());
9220 h.last_fail = Some(cur);
9221 }
9222 let parsed = match out {
9223 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9224 AgentOutcome::Quota(o) => {
9225 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9226 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9227 }
9228 AgentOutcome::Dropped(o) => {
9232 let why = o
9233 .dropped
9234 .as_ref()
9235 .map(|d| d.why.as_str())
9236 .unwrap_or("the CLI ended the stream without delivering its answer");
9237 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9238 }
9239 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9240 Err(anyhow::anyhow!(e))
9241 }
9242 };
9243 let quota = class == Some(FailClass::Quota);
9244 let failed = parsed.is_err();
9245 done[i] = Some(parsed);
9246 if carry && !failed {
9247 let h = state
9248 .seat_history
9249 .entry(originals[i].seat.key.clone())
9250 .or_default();
9251 h.failed.remove(&specs[i].id);
9252 h.last_ok = Some(specs[i].id.clone());
9253 h.last_fail = None;
9254 }
9255 let agent_failure = class.is_some()
9263 && !nudge_first
9264 && !roster.is_empty()
9265 && next_agent(i, &tried[i], &specs).is_some();
9266 if failed && !quota && !agent_failure && nudges[i] < retries {
9267 nudges[i] += 1;
9268 still.push(i);
9269 }
9270 }
9271 pending = still;
9272 }
9273
9274 for (i, q) in last_quota.into_iter().enumerate() {
9277 if let Some(reset) = q {
9278 losses.push(QuotaLoss {
9279 seat: originals[i].seat.key.clone(),
9280 node: ctx.node.to_owned(),
9281 at: Timestamp::now(),
9282 reset,
9283 });
9284 }
9285 }
9286
9287 seats
9288 .into_iter()
9289 .zip(done)
9290 .zip(nudges)
9291 .map(|((seat, res), attempts)| {
9292 (
9293 seat,
9294 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9295 attempts,
9296 )
9297 })
9298 .collect()
9299}
9300
9301fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9303 results
9304 .iter()
9305 .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9306}
9307
9308fn block_wave(
9312 jobs: Vec<SeatJob>,
9313 state: &mut RunState,
9314 node: &str,
9315 attempt: usize,
9316 why: &str,
9317) -> Vec<(usize, SeatState, AgentOutcome)> {
9318 state.resource_wait = None;
9319 state
9320 .active
9321 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9322 if let Err(e) = state.save() {
9323 tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9324 }
9325 jobs.into_iter()
9326 .enumerate()
9327 .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9328 .collect()
9329}
9330
9331async fn acquire_cache_lease(
9344 state: &mut RunState,
9345 cache_dir: &Path,
9346 owner: &crate::cache::Owner,
9347 budget: Duration,
9348 context: &str,
9349 cancel: &(dyn Fn() -> bool + Sync),
9350) -> Result<Option<crate::cache::Guard>> {
9351 let home = crate::run::home();
9352 let started = Instant::now();
9353 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9354 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9355 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9356 Err(e) => {
9357 state.event(
9358 "verify",
9359 format!("{context}: could not check the shared build cache: {e:#}"),
9360 );
9361 if let Err(e2) = state.save() {
9362 tracing::warn!("could not persist a cache-check failure: {e2:#}");
9363 }
9364 return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9365 }
9366 };
9367 state.event(
9368 "verify",
9369 format!(
9370 "{context}: waiting for the shared build cache at {} ({})",
9371 cache_dir.display(),
9372 busy.describe()
9373 ),
9374 );
9375 state.resource_wait = Some(crate::run::ResourceWait {
9378 node: context.to_owned(),
9379 kind: "shared build cache".to_owned(),
9380 holder: busy.describe(),
9381 since: Timestamp::now(),
9382 limit_secs: budget.as_secs(),
9383 });
9384 if let Err(e) = state.save() {
9385 tracing::warn!("could not persist a cache wait: {e:#}");
9386 }
9387 let remaining = budget.saturating_sub(started.elapsed());
9388 let waited = crate::cache::wait_for_unless(
9389 &home,
9390 cache_dir,
9391 owner,
9392 remaining,
9393 Duration::from_secs(5),
9394 cancel,
9395 )
9396 .await;
9397 state.resource_wait = None;
9398 match waited {
9399 Ok(g) => Ok(g),
9400 Err(e) => {
9401 state.event("verify", format!("{context}: {e:#}"));
9402 if let Err(e2) = state.save() {
9403 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9404 }
9405 Err(e)
9406 }
9407 }
9408}
9409
9410#[derive(Debug)]
9415struct CacheCheckError(String);
9416
9417impl std::fmt::Display for CacheCheckError {
9418 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9419 f.write_str(&self.0)
9420 }
9421}
9422
9423impl std::error::Error for CacheCheckError {}
9424
9425#[allow(clippy::too_many_arguments)]
9446async fn with_cache_lease<'s, F, Fut>(
9447 state: &'s mut RunState,
9448 cache_dir: Option<&Path>,
9449 node: &str,
9450 seat: &str,
9451 worktree: &Path,
9452 head: &str,
9453 budget: Duration,
9454 context: &str,
9455 body: F,
9456) -> (Vec<CommandOutcome>, bool)
9457where
9458 F: FnOnce(&'s mut RunState, Duration) -> Fut,
9459 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9460{
9461 let Some(cache_dir) = cache_dir else {
9462 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9463 return (outcomes, retried);
9464 };
9465 let home = crate::run::home();
9466 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9467 let started = Instant::now();
9468 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9469 .await
9470 .and_then(|g| {
9471 g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9472 }) {
9473 Ok(g) => g,
9474 Err(e) => {
9475 return (
9476 vec![CommandOutcome {
9477 command: "(waiting for the shared build cache)".to_owned(),
9478 code: None,
9479 output_tail: e.to_string(),
9480 duration_ms: started.elapsed().as_millis() as u64,
9481 resource_blocked: true,
9482 }],
9483 false,
9484 );
9485 }
9486 };
9487 let identity = crate::cache::Identity::new(worktree, head);
9488 if let Err(e) = crate::cache::ensure_fresh(
9489 &home,
9490 cache_dir,
9491 &identity,
9492 &state.config.verify.cargo_manifests(),
9493 ) {
9494 state.event(
9502 "verify",
9503 format!(
9504 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9505 worktree.display(),
9506 short(head)
9507 ),
9508 );
9509 guard.release();
9510 return (
9511 vec![CommandOutcome {
9512 command: "(confirming the shared build cache is fresh)".to_owned(),
9513 code: None,
9514 output_tail: e.to_string(),
9515 duration_ms: started.elapsed().as_millis() as u64,
9516 resource_blocked: true,
9517 }],
9518 false,
9519 );
9520 }
9521 let remaining = budget.saturating_sub(started.elapsed());
9522 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9523 if !timed_out_pids.is_empty() {
9528 wait_for_timed_out_children_to_die(&timed_out_pids).await;
9529 }
9530 guard.release();
9531 (outcomes, retried)
9532}
9533
9534async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9546 wait_for_pids_with(
9547 pids,
9548 crate::proc::pid_alive,
9549 LEASE_RELEASE_POLL,
9550 LEASE_RELEASE_MAX_WAIT,
9551 )
9552 .await;
9553}
9554
9555async fn wait_for_pids_with<F: Fn(u32) -> bool>(
9561 pids: &[u32],
9562 alive: F,
9563 poll: Duration,
9564 max_wait: Duration,
9565) {
9566 let deadline = Instant::now() + max_wait;
9567 loop {
9568 if pids.iter().all(|&pid| !alive(pid)) {
9569 return;
9570 }
9571 if Instant::now() >= deadline {
9572 return;
9573 }
9574 tokio::time::sleep(poll).await;
9575 }
9576}
9577
9578fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
9585 outcomes.iter().any(|o| o.resource_blocked)
9586}
9587
9588enum GateFix {
9590 Retry,
9592 Stop,
9595 Defer,
9598}
9599
9600fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
9608 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
9609 red.peek().is_some()
9610 && red.all(|o| {
9611 !o.resource_blocked
9612 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
9613 && !o.output_tail.trim().is_empty()
9614 })
9615}
9616
9617fn e2e_outcome_label(o: &CommandOutcome) -> String {
9621 if o.ok() {
9622 return "pass".to_owned();
9623 }
9624 let reason = if o.build_failed() {
9625 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
9626 } else {
9627 format!("FAIL ({:?})", o.code)
9628 };
9629 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
9630}
9631
9632async fn run_e2e_with_retry(
9640 state: &mut RunState,
9641 shell: &[String],
9642 commands: &[String],
9643 worktree: &Path,
9644 timeout: Duration,
9645 context: &str,
9646) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
9647 let (mut e2e, mut timed_out_pids) = run_commands(
9648 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
9649 )
9650 .await;
9651 for o in &e2e {
9652 state.event(
9653 "verify",
9654 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
9655 );
9656 }
9657 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
9661 if verify_retried {
9662 state.event(
9663 "verify",
9664 format!(
9665 "{context}: verify could not build/link, not a test result — retrying once \
9666 before concluding"
9667 ),
9668 );
9669 let retried = run_commands(
9670 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
9671 )
9672 .await;
9673 e2e = retried.0;
9674 timed_out_pids.extend(retried.1);
9677 for o in &e2e {
9678 state.event(
9679 "verify",
9680 format!(
9681 "{context}: retry `{}` -> {}",
9682 o.command,
9683 e2e_outcome_label(o)
9684 ),
9685 );
9686 }
9687 }
9688 (e2e, verify_retried, timed_out_pids)
9689}
9690
9691#[allow(clippy::too_many_arguments)]
9707async fn run_commands(
9708 state: &mut RunState,
9709 node: &str,
9710 task: &str,
9711 attempt: usize,
9712 shell: &[String],
9713 commands: &[String],
9714 cwd: &Path,
9715 timeout: Duration,
9716) -> (Vec<CommandOutcome>, Vec<u32>) {
9717 if commands.is_empty() {
9718 return (Vec::new(), Vec::new());
9723 }
9724 let mut out = Vec::new();
9725 let mut timed_out_pids = Vec::new();
9726 let total = commands.len();
9727 for (idx, command) in commands.iter().enumerate() {
9728 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
9729 if let Err(e) = state.save() {
9730 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
9731 }
9732 let started = Instant::now();
9733 let mut cmd = tokio::process::Command::new(&shell[0]);
9734 cmd.quiet();
9735 cmd.args(&shell[1..])
9736 .arg(command)
9737 .current_dir(cwd)
9738 .stdin(std::process::Stdio::null())
9739 .stdout(std::process::Stdio::piped())
9740 .stderr(std::process::Stdio::piped())
9741 .kill_on_drop(true);
9742 let spawned = cmd.spawn();
9743 let (code, body) = match spawned {
9744 Ok(child) => {
9745 let pid = child.id();
9750 match tokio::time::timeout(timeout, child.wait_with_output()).await {
9751 Ok(Ok(o)) => {
9752 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
9753 body.push_str(&String::from_utf8_lossy(&o.stderr));
9754 (o.status.code(), body)
9755 }
9756 Ok(Err(e)) => (None, format!("failed to run: {e}")),
9757 Err(_) => {
9758 if let Some(pid) = pid {
9759 timed_out_pids.push(pid);
9760 }
9761 (None, format!("timed out after {}s", timeout.as_secs()))
9762 }
9763 }
9764 }
9765 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
9766 };
9767 out.push(CommandOutcome {
9768 command: command.clone(),
9769 code,
9770 output_tail: tail(&body, OUTPUT_TAIL),
9771 duration_ms: started.elapsed().as_millis() as u64,
9772 resource_blocked: false,
9773 });
9774 }
9775 state.task_finished(task);
9776 if let Err(e) = state.save() {
9777 tracing::warn!("could not persist the end of {task}: {e:#}");
9778 }
9779 (out, timed_out_pids)
9780}
9781
9782fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
9794 let repo = repo.display();
9795 match style {
9796 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
9797 MergeStyle::Squash => {
9798 let subject = message
9801 .lines()
9802 .next()
9803 .unwrap_or(branch)
9804 .replace(['\\', '"', '$', '`'], "");
9805 format!(
9806 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
9807 )
9808 }
9809 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
9810 }
9811}
9812
9813const PR_TITLE_MAX: usize = 240;
9824
9825fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
9836 let line = queue::first_line(text)?;
9837 let chars: Vec<char> = line.chars().collect();
9838 let (kept, mark) = if chars.len() <= max {
9839 (line.to_owned(), "")
9840 } else if let Some(end) = sentence_end(&chars, max) {
9841 (chars[..end].iter().collect::<String>(), "")
9842 } else {
9843 let room = max.saturating_sub(3);
9844 let cut = if chars[room].is_whitespace() {
9847 room
9848 } else {
9849 chars[..room]
9850 .iter()
9851 .rposition(|c| c.is_whitespace())
9852 .unwrap_or(room)
9853 };
9854 let head: String = chars[..cut].iter().collect();
9855 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
9856 (head.to_owned(), "...")
9857 };
9858 if kept.is_empty() {
9859 return None;
9860 }
9861 if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
9862 return None;
9863 }
9864 if !ascii_only && !kept.chars().any(char::is_alphabetic) {
9865 return None;
9866 }
9867 Some(format!("{kept}{mark}"))
9868}
9869
9870fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
9874 const MIN: usize = 20;
9875 for i in MIN..max.min(chars.len()) {
9876 if !matches!(chars[i], '.' | '!' | '?') {
9877 continue;
9878 }
9879 let Some(&next) = chars.get(i + 1) else {
9880 continue;
9881 };
9882 if !next.is_whitespace() {
9883 continue;
9884 }
9885 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
9886 if after.is_some_and(|c| c.is_ascii_lowercase()) {
9887 continue;
9888 }
9889 let word: String = chars[..i]
9890 .iter()
9891 .rev()
9892 .take_while(|c| !c.is_whitespace())
9893 .collect::<Vec<_>>()
9894 .into_iter()
9895 .rev()
9896 .collect();
9897 let word = word.to_ascii_lowercase();
9898 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
9899 continue;
9900 }
9901 let end = chars[..i]
9902 .iter()
9903 .rposition(|c| !c.is_whitespace())
9904 .map_or(i, |p| p + 1);
9905 return Some(end);
9906 }
9907 None
9908}
9909
9910struct PrMessage {
9915 title: String,
9916 body: String,
9917}
9918
9919impl PrMessage {
9920 fn commit_message(&self) -> String {
9924 format!("{}\n\n{}", self.title, self.body)
9925 }
9926}
9927
9928fn title_marker(line: &str) -> Option<&str> {
9930 let line = line.trim();
9931 let head = line.get(..6)?;
9932 head.eq_ignore_ascii_case("title:")
9933 .then(|| line[6..].trim())
9934}
9935
9936fn summary_title(summary: &str) -> Option<String> {
9941 let first = summary.lines().find(|l| !l.trim().is_empty())?;
9942 let raw = title_marker(first)?;
9943 if raw.is_empty() {
9944 return None;
9945 }
9946 let title = queue::title_from(raw, PR_TITLE_MAX);
9947 let lower = title.to_ascii_lowercase();
9948 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9949 return None;
9950 }
9951 Some(title)
9952}
9953
9954const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
9956
9957const EXISTING_BRANCH: &str = "(existing branch)";
9959
9960fn is_review_run(state: &RunState) -> bool {
9964 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
9965}
9966
9967fn review_title(state: &RunState) -> Option<String> {
9972 english_subject(
9973 state.reviewed_commits.as_ref()?.first()?,
9974 state.config.graph.github_english(),
9975 )
9976}
9977
9978fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
9981 let raw = raw.trim();
9982 if raw.is_empty() {
9983 return None;
9984 }
9985 if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
9986 return None;
9987 }
9988 if !ascii_only && !raw.chars().any(char::is_alphabetic) {
9989 return None;
9990 }
9991 let title = queue::title_from(raw, PR_TITLE_MAX);
9992 let lower = title.to_ascii_lowercase();
9993 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9994 return None;
9995 }
9996 Some(title)
9997}
9998
9999#[derive(Debug, Clone, PartialEq, Eq)]
10002struct BranchFacts {
10003 commits: Vec<(String, String)>,
10005 stat: String,
10007}
10008
10009const STAT_FILE_LINES: usize = 25;
10011const COMMITS_MAX_CHARS: usize = 20_000;
10013
10014async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10017 let commits = git::commit_log(repo, base, branch).await.ok()?;
10018 if commits.is_empty() {
10019 return None;
10020 }
10021 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10022 let lines: Vec<&str> = stat.lines().collect();
10023 let stat = if lines.len() > STAT_FILE_LINES + 1 {
10024 let omitted = lines.len() - 1 - STAT_FILE_LINES;
10025 let more = format!(" ... {omitted} more file(s)");
10026 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10027 kept.push(&more);
10028 kept.push(lines[lines.len() - 1]);
10029 kept.join("\n")
10030 } else {
10031 lines.join("\n")
10032 };
10033 Some(BranchFacts { commits, stat })
10034}
10035
10036fn markdown_safe(text: &str) -> String {
10039 text.replace("</details>", "</details>")
10040 .replace("\x60\x60\x60", "~~~")
10041}
10042
10043fn neutral_title(state: &RunState, winner: char) -> String {
10044 format!(
10045 "chore: land candidate {} of run {}",
10046 winner.to_ascii_uppercase(),
10047 state.id
10048 )
10049}
10050
10051pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10057 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10058 ""
10059 } else {
10060 pr_title
10061 }
10062}
10063
10064pub fn landing_subject_source(state: &RunState) -> String {
10068 if is_review_run(state) {
10069 let winner = state.candidates.first().map_or('A', |c| c.label);
10070 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10071 }
10072 state.instruction.clone()
10073}
10074
10075fn summary_without_title(summary: &str) -> String {
10078 let mut lines = summary.trim().lines().peekable();
10079 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10080 lines.next();
10081 }
10082 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10083}
10084
10085#[cfg(test)]
10097fn pr_message(state: &RunState, winner: char) -> PrMessage {
10098 pr_message_with(state, winner, None)
10099}
10100
10101#[cfg(test)]
10104fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10105 let raw = pr_message_raw(
10106 state,
10107 winner,
10108 facts,
10109 crate::pr_outcome::FinalHead::Unreported,
10110 ChangeNote::NotAsked,
10111 );
10112 let id = crate::scrub::Identity::current();
10113 PrMessage {
10114 title: crate::scrub::scrub(&raw.title, &id),
10115 body: crate::scrub::scrub(&raw.body, &id),
10116 }
10117}
10118
10119#[derive(Debug, Clone, Copy)]
10121enum ChangeNote<'a> {
10122 NotAsked,
10124 Summary(&'a crate::run::ChangeSummary),
10126 Unavailable(&'a str),
10128}
10129
10130fn pr_message_raw(
10131 state: &RunState,
10132 winner: char,
10133 facts: Option<&BranchFacts>,
10134 head: crate::pr_outcome::FinalHead<'_>,
10135 change: ChangeNote<'_>,
10136) -> PrMessage {
10137 if !matches!(change, ChangeNote::NotAsked) {
10138 let (title, mut body) = match change {
10139 ChangeNote::Summary(c) => (
10140 c.title.clone(),
10141 format!("## Summary\n\n{}\n\n", c.body.trim()),
10142 ),
10143 ChangeNote::Unavailable(why) => (
10144 neutral_title(state, winner),
10145 format!(
10146 "## 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"
10147 ),
10148 ),
10149 ChangeNote::NotAsked => unreachable!(),
10150 };
10151 body.push_str(&public_pr_evidence(state, head));
10152 body.push_str(&format!(
10153 "\n---\nmagi:run/{} magi:candidate-{}\n",
10154 state.id,
10155 winner.to_ascii_lowercase()
10156 ));
10157 return PrMessage { title, body };
10158 }
10159 pr_preview(state, winner, facts, head)
10160}
10161
10162fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10165 let mut body = crate::pr_outcome::review_rounds(state);
10166 body.push_str(&crate::pr_outcome::final_verification(state, head));
10167 let open = state.last_round_findings();
10168 if !open.is_empty() {
10169 body.push_str("## Open review findings\n\n");
10170 for f in open {
10171 body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10172 }
10173 body.push('\n');
10174 }
10175 if let Some(c) = state.change_summary.as_ref()
10176 && head == crate::pr_outcome::FinalHead::At(&c.head)
10177 {
10178 body.push_str(&format!(
10179 "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10180 c.base, c.head
10181 ));
10182 }
10183 body
10184}
10185
10186fn pr_preview(
10188 state: &RunState,
10189 winner: char,
10190 facts: Option<&BranchFacts>,
10191 head: crate::pr_outcome::FinalHead<'_>,
10192) -> PrMessage {
10193 let summary = state
10194 .candidates
10195 .iter()
10196 .find(|c| c.label == winner)
10197 .map(|c| c.summary.as_str())
10198 .unwrap_or_default();
10199 let review = is_review_run(state);
10202 let title = if review {
10203 facts
10204 .and_then(|f| {
10205 english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10206 })
10207 .or_else(|| review_title(state))
10208 .or_else(|| {
10209 state
10210 .candidates
10211 .iter()
10212 .find(|c| c.label == winner)
10213 .filter(|c| !c.branch.starts_with("magi/"))
10214 .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10215 })
10216 .unwrap_or_else(|| neutral_title(state, winner))
10217 } else {
10218 summary_title(summary).unwrap_or_else(|| {
10219 english_title(
10220 &state.instruction,
10221 PR_TITLE_MAX,
10222 state.config.graph.github_english(),
10223 )
10224 .unwrap_or_else(|| neutral_title(state, winner))
10225 })
10226 };
10227
10228 let mut body = String::new();
10229 let what = summary_without_title(summary);
10230 if !what.is_empty() {
10231 body.push_str("## Summary\n\n");
10232 body.push_str(&what);
10233 body.push_str("\n\n");
10234 }
10235
10236 body.push_str(&crate::pr_outcome::review_rounds(state));
10238 body.push_str(&crate::pr_outcome::final_verification(state, head));
10239 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10240
10241 let open = state.open_findings();
10242 if !open.is_empty() {
10243 body.push_str("## Open review findings\n\n");
10244 for f in &open {
10245 body.push_str(&format!(
10246 "- `{}` [{:?}] {}\n",
10247 f.id, f.severity, "Context retained in local review records."
10248 ));
10249 }
10250 body.push('\n');
10251 }
10252
10253 if let Some(fix) = fix
10254 && !fix.rejected.is_empty()
10255 {
10256 body.push_str("## Declined by the fixer\n\n");
10257 for r in &fix.rejected {
10258 body.push_str(&format!(
10259 "- `{}`: {}\n",
10260 r.id, "Reason retained in local review records."
10261 ));
10262 }
10263 body.push('\n');
10264 }
10265
10266 if review {
10267 body.push_str("## Commits under review\n\n");
10269 if let Some(facts) = facts {
10270 let mut left = COMMITS_MAX_CHARS;
10271 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10272 let mut entry = format!("- {}\n", markdown_safe(subject));
10273 for l in markdown_safe(text).lines() {
10274 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10275 }
10276 if left == 0 {
10277 body.push_str(&format!(
10278 "- ... {} more commit(s)\n",
10279 facts.commits.len() - i
10280 ));
10281 break;
10282 }
10283 if entry.len() > left {
10284 let mut end = left;
10287 while !entry.is_char_boundary(end) {
10288 end -= 1;
10289 }
10290 entry.truncate(end);
10291 entry.push_str("\n ... (truncated)\n");
10292 left = 0;
10293 } else {
10294 left -= entry.len();
10295 }
10296 body.push_str(&entry);
10297 }
10298 if !facts.stat.trim().is_empty() {
10299 body.push_str(&format!(
10300 "\n## Diff stat\n\n```\n{}\n```\n",
10301 markdown_safe(facts.stat.trim())
10302 ));
10303 }
10304 } else {
10305 match &state.reviewed_commits {
10306 Some(subjects) => {
10307 for s in subjects {
10308 body.push_str(&format!("- {}\n", s.trim()));
10309 }
10310 }
10311 None => {
10312 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10315 body.push_str(rest.trim());
10316 body.push('\n');
10317 }
10318 }
10319 }
10320 } else {
10321 let task = state.instruction.trim();
10322 let task = if task.is_empty() {
10323 "(empty task)"
10324 } else {
10325 task
10326 };
10327 body.push_str(&format!(
10328 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10329 task.replace("</details>", "</details>")
10330 ));
10331 }
10332
10333 body.push_str(&format!(
10334 "\n---\nmagi:run/{} magi:candidate-{}\n",
10335 state.id,
10336 winner.to_ascii_lowercase()
10337 ));
10338
10339 PrMessage { title, body }
10340}
10341
10342fn seeded_instruction(state: &RunState) -> String {
10346 match refs::describe(&state.seeds) {
10347 Some(facts) => format!(
10348 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10349 Candidates start from the unmerged branch named above, when there \
10350 is one, and carry any unmerged commit named by sha as a \
10351 cherry-pick. Check that this is what the task meant before \
10352 building on it.",
10353 state.instruction
10354 ),
10355 None => state.instruction.clone(),
10356 }
10357}
10358
10359async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10363 let base = &state.base_branch;
10364 let mut against = base.clone();
10365 if mode == MergeMode::Pr {
10366 let remote = &state.config.merge.remote;
10370 let tracking = format!("{remote}/{base}");
10371 let fetched = git::fetch(repo, remote, base).await;
10372 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10373 against = tracking;
10374 } else {
10375 return false;
10376 }
10377 }
10378 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10379}
10380
10381fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10384 let mut detail = format!(
10385 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10386 nothing to open a pull request for"
10387 );
10388 match refs::describe(&state.seeds) {
10389 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10390 None => detail.push_str(
10391 "\nThe task names no existing branch or commit; if it means to land work \
10392 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10393 ),
10394 }
10395 detail
10396}
10397
10398#[derive(Debug, PartialEq, Eq)]
10401enum PrPlan {
10402 Create,
10403 Adopt { url: String, title: String },
10404 Stop(String),
10405}
10406
10407fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10410 match found {
10411 Ok(land::OpenPr::None) => PrPlan::Create,
10412 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10413 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10414 "several open pull requests exist for this branch, not picking one: {}",
10415 urls.join(" ")
10416 )),
10417 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10418 }
10419}
10420
10421pub(crate) async fn gh_pr_create(
10423 cwd: &Path,
10424 base: &str,
10425 head: &str,
10426 title: &str,
10427 body: &str,
10428) -> Result<String> {
10429 let out = tokio::process::Command::new("gh")
10430 .args([
10431 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10432 ])
10433 .current_dir(cwd)
10434 .quiet()
10435 .stdin(std::process::Stdio::null())
10436 .output()
10437 .await
10438 .context("spawn gh")?;
10439 if out.status.success() {
10440 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10441 } else {
10442 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10443 }
10444}
10445
10446pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10455 let repo = state.repo.clone();
10456 let root = state.worktree_root();
10457 let winner = state.tally.as_ref().map(|t| t.winner);
10458 let mut removed = Vec::new();
10459
10460 for i in 0..state.candidates.len() {
10461 let c = state.candidates[i].clone();
10462 let is_winner = Some(c.label) == winner;
10463 if is_winner && !drop_winner {
10464 continue;
10465 }
10466 if c.worktree.exists() {
10467 git::worktree_remove(&repo, &c.worktree).await.ok();
10468 removed.push(c.worktree.to_string_lossy().into_owned());
10469 }
10470 let handed_over = state.released_branches.contains(&c.branch);
10473 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10474 git::branch_delete(&repo, &c.branch).await.ok();
10475 removed.push(c.branch.clone());
10476 }
10477 state.candidates[i].folded = true;
10478 }
10479
10480 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10481 let path = name.path();
10482 let keep = !drop_winner
10483 && winner.is_some_and(|w| {
10484 path.file_name()
10485 .is_some_and(|n| n == format!("cand-{w}").as_str())
10486 });
10487 if keep {
10488 continue;
10489 }
10490 git::worktree_remove(&repo, &path).await.ok();
10491 removed.push(path.to_string_lossy().into_owned());
10492 }
10493
10494 remove_if_empty(&root);
10503
10504 if state.enabled_worktree_config && drop_winner {
10505 git::release_worktree_config(&repo).await.ok();
10509 state.enabled_worktree_config = false;
10510 }
10511 state.save_under(home)?;
10512 Ok(removed)
10513}
10514
10515fn remove_if_empty(dir: &Path) {
10526 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10527 std::fs::remove_dir(dir).ok();
10528 }
10529}
10530
10531pub fn worst_open(state: &RunState) -> Option<Severity> {
10533 state
10534 .reviews
10535 .last()?
10536 .reviews
10537 .iter()
10538 .flat_map(|r| r.findings.iter())
10539 .map(|f| f.severity)
10540 .max()
10541}
10542
10543async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10547 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10548 git::acquire_worktree_config(repo).await?;
10549 state.enabled_worktree_config = true;
10550 }
10551 Ok(())
10552}
10553
10554#[cfg(test)]
10555mod tests {
10556 #[test]
10557 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
10558 let leaked = vec!["chore(deps): update a crate".to_owned()];
10559 let own = "fix(daemon): apply a chosen action";
10560 assert!(should_retitle("", own, &leaked));
10561 assert!(should_retitle(
10562 &format!("{REVIEW_PROMPT_OPENING} `x`"),
10563 own,
10564 &leaked
10565 ));
10566 assert!(should_retitle(
10567 "chore: land candidate A of run 1",
10568 own,
10569 &leaked
10570 ));
10571 assert!(should_retitle(
10572 "magi: candidate A (uncommitted work)",
10573 own,
10574 &leaked
10575 ));
10576 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
10577 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
10578 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
10579 }
10580
10581 #[test]
10582 fn pr_merge_plan_creates_adopts_or_stops() {
10583 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
10584 assert_eq!(
10585 pr_merge_plan(Ok(land::OpenPr::One {
10586 url: "u".into(),
10587 title: "t".into()
10588 })),
10589 PrPlan::Adopt {
10590 url: "u".into(),
10591 title: "t".into()
10592 }
10593 );
10594 let PrPlan::Stop(many) =
10595 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
10596 else {
10597 panic!("many must stop");
10598 };
10599 assert!(many.contains('a') && many.contains('b'));
10600 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
10601 panic!("a failed lookup must stop");
10602 };
10603 assert!(err.contains("bad token"));
10604 }
10605
10606 use super::*;
10607 use crate::run::GateStatus;
10608 use std::collections::BTreeMap;
10609 use std::time::Duration;
10610
10611 fn conductor() -> AgentSpec {
10612 AgentSpec {
10613 id: "conductor".to_owned(),
10614 kind: crate::config::AgentKind::Command,
10615 model: None,
10616 command: vec!["true".to_owned()],
10617 extra_args: Vec::new(),
10618 env: BTreeMap::new(),
10619 prompt_delivery: None,
10620 }
10621 }
10622
10623 fn spec(id: &str) -> AgentSpec {
10624 AgentSpec {
10625 id: id.to_owned(),
10626 kind: crate::config::AgentKind::Command,
10627 model: None,
10628 command: vec!["true".to_owned()],
10629 extra_args: Vec::new(),
10630 env: BTreeMap::new(),
10631 prompt_delivery: None,
10632 }
10633 }
10634
10635 fn ids(xs: &[&str]) -> BTreeSet<String> {
10636 xs.iter().map(|s| (*s).to_owned()).collect()
10637 }
10638
10639 #[test]
10640 fn pick_successor_skips_an_agent_another_seat_holds() {
10641 let roster = [spec("a"), spec("b"), spec("c")];
10643 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10644 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10645 }
10646
10647 #[test]
10648 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
10649 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
10651 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10652 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10653 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
10654 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
10655 }
10656
10657 #[test]
10658 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
10659 let roster = [spec("a"), spec("b")];
10660 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10661 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10662 let carried = ids(&["b"]);
10663 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
10664 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10665 }
10666
10667 #[test]
10668 fn pick_successor_returns_none_without_any_untried_successor() {
10669 let roster = [spec("a"), spec("b")];
10670 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
10671 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
10672 let carried = ids(&["a"]);
10673 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
10674 }
10675
10676 #[test]
10677 fn pick_successor_rescue_avoids_another_seats_occupant() {
10678 let roster = [spec("a"), spec("b"), spec("c")];
10680 let carried = ids(&["b", "c"]);
10681 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
10682 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10683 }
10684
10685 #[test]
10686 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
10687 let roster = [spec("a"), spec("b"), spec("c")];
10688 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
10689 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10690 }
10691
10692 #[test]
10693 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
10694 let roster = [spec("a"), spec("b"), spec("c")];
10695 let failed = ids(&["a", "b", "c"]);
10696 let mut tried = ids(&["b"]);
10698 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10699 assert_eq!(first.id, "a");
10700 tried.insert(first.id.clone());
10701 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10702 assert_eq!(second.id, "c");
10703 tried.insert(second.id.clone());
10704 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
10705 }
10706
10707 #[test]
10708 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
10709 let roster = [spec("a"), spec("b")];
10710 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
10711 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10712 }
10713
10714 #[test]
10715 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
10716 let roster = [spec("a"), spec("b"), spec("c")];
10717 let h = SeatHistory {
10718 failed: ids(&["a"]),
10719 last_ok: Some("b".to_owned()),
10720 last_fail: None,
10721 };
10722 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
10723 let gone = SeatHistory {
10725 last_ok: Some("zzz".to_owned()),
10726 ..h.clone()
10727 };
10728 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
10729 let failed = SeatHistory {
10730 failed: ids(&["a", "b"]),
10731 last_ok: Some("b".to_owned()),
10732 last_fail: None,
10733 };
10734 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
10735 }
10736
10737 #[test]
10738 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
10739 let first = SeatState::new("review-1", "alpha", 7);
10740 let next = handover_seat("review-1", "gamma", 7);
10741 assert_ne!(first.claude_session, next.claude_session);
10742 }
10743
10744 #[test]
10745 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
10746 let mut state = state_with_summary("x", "y");
10747 let a = handover_seat("review-1", "beta", state.next_seat_seed());
10748 let b = handover_seat("review-1", "beta", state.next_seat_seed());
10749 assert_ne!(a.claude_session, b.claude_session);
10750 }
10751
10752 #[test]
10753 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
10754 let roster = [spec("a"), spec("b")];
10755 let h = SeatHistory {
10756 failed: ids(&["a", "b"]),
10757 ..SeatHistory::default()
10758 };
10759 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
10760 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
10761 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
10762 }
10763
10764 #[test]
10771 fn failure_signature_ignores_numbers_and_paths() {
10772 assert_eq!(
10773 failure_signature("exited with Some(2) and no usable output"),
10774 failure_signature("exited with Some(137) and no usable output")
10775 );
10776 assert_eq!(
10777 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
10778 failure_signature("cannot open /var/x.txt: denied")
10779 );
10780 assert_ne!(failure_signature("boom"), failure_signature("bang"));
10781 }
10782
10783 #[test]
10784 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
10785 let other = FailClass::Other("x".into());
10786 assert!(should_hand_over(None, &FailClass::Quota));
10787 assert!(should_hand_over(Some(&other), &FailClass::Quota));
10788 assert!(should_hand_over(
10789 Some(&FailClass::Timeout),
10790 &FailClass::Timeout
10791 ));
10792 assert!(should_hand_over(None, &other));
10793 assert!(!should_hand_over(Some(&other), &other));
10794 assert!(should_hand_over(
10795 Some(&other),
10796 &FailClass::Other("y".into())
10797 ));
10798 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
10800 assert!(should_hand_over(Some(&FailClass::Quota), &other));
10801 }
10802
10803 #[test]
10804 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
10805 let a = SeatState::new("judge-1", "alpha", 7);
10806 let b = handover_seat("judge-1", "beta", 7);
10807 assert_ne!(a.claude_session, b.claude_session);
10808 assert_eq!(b.turns, 0);
10809 }
10810
10811 #[test]
10812 fn a_timeout_is_classified_apart_from_other_failures() {
10813 assert_eq!(
10814 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
10815 Some(FailClass::Timeout)
10816 );
10817 assert!(matches!(
10818 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
10819 Some(FailClass::Other(_))
10820 ));
10821 }
10822
10823 #[test]
10824 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
10825 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10826 let tried = BTreeSet::from(["beta".to_owned()]);
10827 let next = next_untried_in_roster(&roster, 1, &tried);
10830 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
10831 }
10832
10833 #[test]
10834 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
10835 let roster = vec![spec("alpha"), spec("beta")];
10836 let tried = BTreeSet::from(["beta".to_owned()]);
10837 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10841 }
10842
10843 #[test]
10844 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
10845 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10846 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
10847 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10851 }
10852
10853 #[test]
10854 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
10855 let roster = vec![spec("a"), spec("a"), spec("b")];
10856 let tried = BTreeSet::from(["a".to_owned()]);
10857 let next = next_untried_in_roster(&roster, 0, &tried);
10858 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10859 }
10860
10861 #[test]
10862 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
10863 let roster = vec![spec("a"), spec("b")];
10864 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
10865 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
10866 }
10867
10868 #[test]
10869 fn remove_if_empty_only_ever_takes_a_bare_directory() {
10870 let dir = tempfile::tempdir().unwrap();
10871 let bay = dir.path().join("ffff");
10872
10873 remove_if_empty(&bay);
10875 assert!(!bay.exists());
10876
10877 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
10880 remove_if_empty(&bay);
10881 assert!(bay.exists(), "non-empty directory must survive");
10882
10883 std::fs::remove_dir(bay.join("cand-A")).unwrap();
10885 remove_if_empty(&bay);
10886 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
10887 }
10888
10889 #[test]
10898 fn a_full_panel_that_found_nothing_is_clean() {
10899 assert!(round_is_clean(
10900 0,
10901 true,
10902 2,
10903 2,
10904 0,
10905 IncompleteReviewPolicy::Block
10906 ));
10907 }
10908
10909 #[test]
10910 fn a_missing_seat_is_never_clean_under_the_default_policy() {
10911 assert!(!round_is_clean(
10912 0,
10913 true,
10914 1,
10915 2,
10916 0,
10917 IncompleteReviewPolicy::Block
10918 ));
10919 }
10920
10921 #[test]
10922 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
10923 assert!(!round_is_clean(
10924 1,
10925 true,
10926 1,
10927 2,
10928 0,
10929 IncompleteReviewPolicy::Warn
10930 ));
10931 }
10932
10933 #[test]
10934 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
10935 assert!(round_is_clean(
10936 0,
10937 true,
10938 1,
10939 2,
10940 0,
10941 IncompleteReviewPolicy::Warn
10942 ));
10943 }
10944
10945 #[test]
10946 fn a_full_panel_with_an_open_finding_is_not_clean() {
10947 assert!(!round_is_clean(
10948 1,
10949 true,
10950 2,
10951 2,
10952 0,
10953 IncompleteReviewPolicy::Block
10954 ));
10955 }
10956
10957 #[test]
10958 fn a_full_panel_with_a_red_e2e_is_not_clean() {
10959 assert!(!round_is_clean(
10960 0,
10961 false,
10962 2,
10963 2,
10964 0,
10965 IncompleteReviewPolicy::Block
10966 ));
10967 }
10968
10969 #[test]
10976 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
10977 assert!(round_is_clean(
10980 0,
10981 true,
10982 1,
10983 2,
10984 1,
10985 IncompleteReviewPolicy::Block
10986 ));
10987 }
10988
10989 #[test]
10990 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
10991 assert!(!round_is_clean(
10994 0,
10995 true,
10996 1,
10997 2,
10998 0,
10999 IncompleteReviewPolicy::Block
11000 ));
11001 }
11002
11003 #[test]
11004 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11005 assert!(!round_is_clean(
11006 1,
11007 true,
11008 1,
11009 2,
11010 1,
11011 IncompleteReviewPolicy::Block
11012 ));
11013 assert!(!round_is_clean(
11014 0,
11015 false,
11016 1,
11017 2,
11018 1,
11019 IncompleteReviewPolicy::Block
11020 ));
11021 }
11022
11023 #[test]
11024 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11025 assert!(!round_is_clean(
11029 0,
11030 true,
11031 0,
11032 2,
11033 2,
11034 IncompleteReviewPolicy::Block
11035 ));
11036 }
11037
11038 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11039 CommandOutcome {
11040 command: "test".to_owned(),
11041 code,
11042 output_tail: String::new(),
11043 duration_ms: 0,
11044 resource_blocked,
11045 }
11046 }
11047
11048 #[test]
11049 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11050 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11051 assert!(
11052 !verify_inconclusive(&[outcome(Some(1), false)]),
11053 "an ordinary failure is still evidence about the patch"
11054 );
11055 assert!(verify_inconclusive(&[outcome(None, true)]));
11056 assert!(
11057 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11058 "one inconclusive outcome taints the whole batch"
11059 );
11060 assert!(!verify_inconclusive(&[]));
11061 }
11062
11063 #[tokio::test]
11064 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11065 let calls = std::sync::atomic::AtomicUsize::new(0);
11069 let started = Instant::now();
11070 wait_for_pids_with(
11071 &[123],
11072 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11073 Duration::from_millis(5),
11074 Duration::from_secs(5),
11075 )
11076 .await;
11077 assert!(
11078 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11079 "must keep checking rather than deciding on the first answer"
11080 );
11081 assert!(
11082 started.elapsed() < Duration::from_secs(1),
11083 "must return the moment it is confirmed dead, not wait out the ceiling"
11084 );
11085 }
11086
11087 #[tokio::test]
11088 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11089 let started = Instant::now();
11090 wait_for_pids_with(
11091 &[123],
11092 |_| true, Duration::from_millis(5),
11094 Duration::from_millis(30),
11095 )
11096 .await;
11097 let elapsed = started.elapsed();
11098 assert!(
11099 elapsed >= Duration::from_millis(30),
11100 "must not give up before its own ceiling: {elapsed:?}"
11101 );
11102 assert!(
11103 elapsed < Duration::from_secs(1),
11104 "must not wait past its own ceiling either: {elapsed:?}"
11105 );
11106 }
11107
11108 #[tokio::test]
11109 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11110 let started = Instant::now();
11111 wait_for_pids_with(
11112 &[],
11113 |_| true,
11114 Duration::from_secs(5),
11115 Duration::from_secs(5),
11116 )
11117 .await;
11118 assert!(
11119 started.elapsed() < Duration::from_millis(200),
11120 "an empty pid list has nothing to confirm"
11121 );
11122 }
11123
11124 fn review_round(
11130 clean: bool,
11131 blocking: usize,
11132 answered: usize,
11133 expected: usize,
11134 progressed: bool,
11135 e2e_ok: bool,
11136 ) -> ReviewRound {
11137 ReviewRound {
11138 round: 1,
11139 head: "h".to_owned(),
11140 verified_head: None,
11141 verified_at: None,
11142 reviews: Vec::new(),
11143 e2e: vec![CommandOutcome {
11144 command: "test".to_owned(),
11145 code: Some(if e2e_ok { 0 } else { 1 }),
11146 output_tail: String::new(),
11147 duration_ms: 0,
11148 resource_blocked: false,
11149 }],
11150 verify_retried: false,
11151 e2e_deferred: false,
11152 e2e_defer_reason: None,
11153 fix: None,
11154 blocking,
11155 answered,
11156 expected,
11157 clean,
11158 progressed,
11159 vote_split: false,
11160 reconsideration: Vec::new(),
11161 verdict: None,
11162 }
11163 }
11164
11165 #[test]
11166 fn review_conclusion_is_none_when_nothing_has_run() {
11167 assert_eq!(review_conclusion(&[], 3), None);
11168 }
11169
11170 #[test]
11171 fn review_conclusion_is_none_while_rounds_remain() {
11172 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11173 assert_eq!(review_conclusion(&rounds, 3), None);
11174 }
11175
11176 #[test]
11177 fn review_conclusion_is_gating_once_a_round_is_clean() {
11178 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11179 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11180 }
11181
11182 #[test]
11183 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11184 let rounds = vec![
11185 review_round(false, 1, 2, 2, true, true),
11186 review_round(false, 1, 2, 2, true, true),
11187 ];
11188 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11189 }
11190
11191 #[test]
11192 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11193 let rounds = vec![
11194 review_round(false, 1, 2, 2, true, true),
11195 review_round(false, 1, 2, 2, true, false),
11196 ];
11197 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11198 }
11199
11200 #[test]
11201 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11202 let mut blocked = review_round(false, 1, 2, 2, true, false);
11209 blocked.e2e[0].resource_blocked = true;
11210 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11211 assert_eq!(review_conclusion(&rounds, 2), None);
11212 }
11213
11214 #[test]
11215 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11216 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11218 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11219 }
11220
11221 #[test]
11222 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11223 let rounds = vec![
11224 review_round(false, 1, 2, 2, false, true),
11225 review_round(false, 1, 2, 2, false, true),
11226 ];
11227 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11228 }
11229
11230 fn secs(n: u64) -> Duration {
11231 Duration::from_secs(n)
11232 }
11233
11234 fn init_repo(dir: &Path) {
11237 let run = |args: &[&str]| {
11238 let out = std::process::Command::new("git")
11239 .args(args)
11240 .current_dir(dir)
11241 .quiet()
11242 .output()
11243 .expect("spawn git");
11244 assert!(
11245 out.status.success(),
11246 "git {args:?} failed: {}",
11247 String::from_utf8_lossy(&out.stderr)
11248 );
11249 };
11250 run(&["init", "-b", "main"]);
11251 run(&["config", "user.name", "magi test"]);
11252 run(&["config", "user.email", "magi@example.com"]);
11253 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11254 run(&["add", "-A"]);
11255 run(&["commit", "-m", "init"]);
11256 }
11257
11258 fn ask_test_home() {
11266 crate::run::pin_test_home();
11267 }
11268
11269 fn runner_at(status: RunStatus) -> Runner {
11272 let mut state = RunState::new(
11273 PathBuf::from("/nonexistent/repo"),
11274 "main".to_owned(),
11275 "deadbeef".to_owned(),
11276 "task".to_owned(),
11277 Config::default(),
11278 );
11279 state.status = status;
11280 Runner {
11281 state,
11282 roles: ResolvedRoles {
11283 implementers: Vec::new(),
11284 judges: Vec::new(),
11285 reviewers: Vec::new(),
11286 fixer: None,
11287 conductor: conductor(),
11288 implementer_roster: Vec::new(),
11289 judge_roster: Vec::new(),
11290 reviewer_roster: Vec::new(),
11291 },
11292 sem: Arc::new(Semaphore::new(1)),
11293 pause: Pause::new(),
11294 interrupt: Pause::new(),
11295 }
11296 }
11297
11298 #[test]
11302 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
11303 crate::run::pin_test_home();
11304 let mut runner = runner_at(RunStatus::Implementing);
11305 let interrupt = Pause::new();
11306 runner.watch_interrupt(interrupt.clone());
11307
11308 interrupt.park_because("task a1b2 asked to run first");
11309
11310 assert!(runner.park_here().expect("park_here"));
11311 assert!(runner.state.parked);
11312 let last = runner.state.events.last().expect("a park event");
11313 assert_eq!(last.node, "park");
11314 assert!(
11315 last.message.contains("task a1b2 asked to run first"),
11316 "expected the interrupt reason in {:?}",
11317 last.message
11318 );
11319 }
11320
11321 #[test]
11324 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
11325 crate::run::pin_test_home();
11326 let mut runner = runner_at(RunStatus::Implementing);
11327 runner.state.begin_pass();
11328 let shutdown = Pause::new();
11329 runner.on_pause(shutdown.clone());
11330 let upgrade = ParkCause::Upgrade {
11331 from: "0.121.0".to_owned(),
11332 to: Some("0.121.1".to_owned()),
11333 };
11334 shutdown.park_for(upgrade.clone());
11335 shutdown.park_for(ParkCause::Operator);
11336
11337 assert!(runner.park_here().expect("park_here"));
11338 assert_eq!(
11339 runner.state.passes[0].end,
11340 Some(crate::run::PassEnd::Parked { cause: upgrade })
11341 );
11342 let last = runner.state.events.last().expect("a park event");
11343 assert!(last.message.contains("0.121.0"), "{}", last.message);
11344 }
11345
11346 #[test]
11354 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
11355 crate::run::pin_test_home();
11356 let mut runner = runner_at(RunStatus::Implementing);
11357 let shutdown = Pause::new();
11358 runner.on_pause(shutdown.clone());
11359 let interrupt = Pause::new();
11360 runner.watch_interrupt(interrupt.clone());
11361
11362 assert!(!runner.park_here().expect("park_here"));
11364 assert!(!runner.state.parked);
11365
11366 interrupt.park_because("test");
11368 assert!(!shutdown.parked());
11369 assert!(runner.park_here().expect("park_here"));
11370 }
11371
11372 #[tokio::test]
11386 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
11387 crate::run::pin_test_home();
11388 let mut runner = runner_at(RunStatus::Implementing);
11389 let interrupt = Pause::new();
11390 runner.watch_interrupt(interrupt.clone());
11391
11392 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
11393 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
11394
11395 let node = async move {
11399 started_tx.send(()).expect("send started");
11400 finish_rx.await.expect("recv finish");
11401 "node finished"
11402 };
11403
11404 let interrupter = async move {
11405 started_rx.await.expect("recv started");
11406 interrupt.park_because("higher-priority task waiting");
11408 tokio::task::yield_now().await;
11412 finish_tx.send(()).expect("send finish");
11413 };
11414
11415 let (node_result, ()) = tokio::join!(node, interrupter);
11416 assert_eq!(
11417 node_result, "node finished",
11418 "the in-flight call ran to completion"
11419 );
11420
11421 assert!(runner.park_here().expect("park_here"));
11424 assert!(runner.state.parked);
11425 }
11426
11427 #[test]
11433 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
11434 crate::run::pin_test_home();
11435 let mut runner = runner_at(RunStatus::Judging);
11436 runner.state.config.agents = vec![conductor()];
11440 runner.state.candidates = vec![Candidate {
11441 index: 0,
11442 label: 'A',
11443 agent: "alpha".to_owned(),
11444 branch: "magi/x/A".to_owned(),
11445 worktree: PathBuf::from("/nonexistent/worktree"),
11446 summary: "did the thing".to_owned(),
11447 stat: "1 file changed".to_owned(),
11448 files: 1,
11449 commits: 1,
11450 empty: false,
11451 failed: None,
11452 verified_noop: None,
11453 duration_ms: 1234,
11454 folded: false,
11455 }];
11456 let run_id = runner.state.id.clone();
11457
11458 let interrupt = Pause::new();
11459 runner.watch_interrupt(interrupt.clone());
11460 interrupt.park_because("task c3d4 asked to run first");
11461 assert!(runner.park_here().expect("park_here"));
11462
11463 let resumed = Runner::resume(&run_id).expect("resume");
11464 assert_eq!(resumed.state.candidates.len(), 1);
11465 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
11466 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
11467 assert_eq!(resumed.state.status, runner.state.status);
11468 assert!(
11469 resumed.state.parked,
11470 "still parked until `execute` actually walks the graph again"
11471 );
11472 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
11473 }
11474
11475 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
11477 let mut q = ask::Question::new(
11478 run.to_owned(),
11479 "implement".to_owned(),
11480 "impl-A".to_owned(),
11481 "Which storage backend should the cache use?".to_owned(),
11482 String::new(),
11483 vec!["SQLite".to_owned(), "Redis".to_owned()],
11484 );
11485 store.put(&mut q).unwrap();
11486 q
11487 }
11488
11489 #[test]
11490 fn a_failed_runs_open_question_is_abandoned() {
11491 ask_test_home();
11492 let store = ask::Questions::open();
11493 let mut runner = runner_at(RunStatus::Failed);
11494 let run = runner.state.id.clone();
11495 let q = ask_open_question(&store, &run);
11496
11497 runner.settle_questions();
11498
11499 let back = store.get(&q.id).unwrap();
11500 assert!(
11501 !back.status.open(),
11502 "the seat that asked died with the run; nobody is left to read an answer"
11503 );
11504 assert!(
11505 back.detail.contains(&run) && back.detail.contains("failed"),
11506 "the reason names what the run became, not just that it is gone: {}",
11507 back.detail
11508 );
11509 }
11510
11511 #[test]
11512 fn a_merged_runs_open_question_is_abandoned_too() {
11513 ask_test_home();
11514 let store = ask::Questions::open();
11515 for status in [RunStatus::Merged, RunStatus::Ready] {
11518 let mut runner = runner_at(status);
11519 let run = runner.state.id.clone();
11520 let q = ask_open_question(&store, &run);
11521
11522 runner.settle_questions();
11523
11524 let back = store.get(&q.id).unwrap();
11525 assert!(
11526 !back.status.open(),
11527 "{status:?} run's question must not outlive the run"
11528 );
11529 }
11530 }
11531
11532 #[test]
11533 fn a_still_resumable_runs_open_question_is_left_alone() {
11534 ask_test_home();
11535 let store = ask::Questions::open();
11536 for status in [RunStatus::Blocked, RunStatus::Stalled] {
11542 let mut runner = runner_at(status);
11543 let run = runner.state.id.clone();
11544 let q = ask_open_question(&store, &run);
11545
11546 runner.settle_questions();
11547
11548 let back = store.get(&q.id).unwrap();
11549 assert!(
11550 back.status.open(),
11551 "{status:?} is still alive; the question must still be waiting"
11552 );
11553 }
11554 }
11555
11556 #[test]
11557 fn settle_questions_never_touches_an_already_answered_question() {
11558 ask_test_home();
11559 let store = ask::Questions::open();
11560 let mut runner = runner_at(RunStatus::Failed);
11561 let run = runner.state.id.clone();
11562 let mut q = ask_open_question(&store, &run);
11563 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
11564 .unwrap();
11565 store.put(&mut q).unwrap();
11566
11567 runner.settle_questions();
11572 runner.settle_questions();
11573
11574 let back = store.get(&q.id).unwrap();
11575 assert_eq!(
11576 back.status,
11577 ask::QuestionStatus::Answered,
11578 "a real answer is a decision on record, never overwritten by a sweep"
11579 );
11580 }
11581
11582 #[tokio::test]
11593 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
11594 crate::run::pin_test_home();
11595 let tmp = tempfile::tempdir().expect("tempdir");
11596 let repo = tmp.path().join("repo");
11597 std::fs::create_dir_all(&repo).unwrap();
11598 init_repo(&repo);
11599
11600 let mut config = Config::default();
11601 config.graph.worktree_root = Some(tmp.path().join("wt"));
11602
11603 let mut state = RunState::new(
11604 repo.clone(),
11605 "main".to_owned(),
11606 "deadbeef".to_owned(),
11607 "task".to_owned(),
11608 config,
11609 );
11610 let root = state.worktree_root();
11611 let wt_a = root.join("cand-A");
11612 let wt_b = root.join("cand-B");
11613 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
11614 .await
11615 .expect("worktree A");
11616 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
11617 .await
11618 .expect("worktree B");
11619
11620 state.candidates = vec![
11621 Candidate {
11622 index: 0,
11623 label: 'A',
11624 agent: "alpha".to_owned(),
11625 branch: "magi/x/A".to_owned(),
11626 worktree: wt_a.clone(),
11627 summary: String::new(),
11628 stat: String::new(),
11629 files: 0,
11630 commits: 0,
11631 empty: false,
11632 failed: None,
11633 verified_noop: None,
11634 duration_ms: 0,
11635 folded: false,
11636 },
11637 Candidate {
11638 index: 1,
11639 label: 'B',
11640 agent: "beta".to_owned(),
11641 branch: "magi/x/B".to_owned(),
11642 worktree: wt_b.clone(),
11643 summary: String::new(),
11644 stat: String::new(),
11645 files: 0,
11646 commits: 0,
11647 empty: false,
11648 failed: None,
11649 verified_noop: None,
11650 duration_ms: 0,
11651 folded: false,
11652 },
11653 ];
11654 state.tally = Some(Tally {
11655 first_choice: BTreeMap::from([('A', 1)]),
11656 borda: BTreeMap::new(),
11657 winner: 'A',
11658 rankings: 1,
11659 unanimous_initial: true,
11660 deliberated: false,
11661 changed_votes: 0,
11662 unanimous_final: true,
11663 tie_break: None,
11664 judges: 1,
11665 present: 1,
11666 quorum: 1,
11667 met_quorum: true,
11668 uncontested: None,
11669 });
11670 state.status = RunStatus::Ready;
11671
11672 fold_run(&mut state, false, &crate::run::home())
11673 .await
11674 .expect("fold_run");
11675
11676 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
11677 assert!(
11678 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11679 "the unmerged winner's branch survives"
11680 );
11681 assert!(
11682 !state.candidates[0].folded,
11683 "the winner is not marked folded"
11684 );
11685
11686 assert!(!wt_b.exists(), "the loser's worktree is removed");
11687 assert!(
11688 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
11689 "the loser's branch is removed"
11690 );
11691 assert!(state.candidates[1].folded, "the loser is marked folded");
11692 }
11693
11694 #[tokio::test]
11697 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
11698 let tmp = tempfile::tempdir().expect("tempdir");
11699 let repo = tmp.path().join("repo");
11700 std::fs::create_dir_all(&repo).unwrap();
11701 init_repo(&repo);
11702 let home = tmp.path().join("home");
11703
11704 let mut config = Config::default();
11705 config.graph.worktree_root = Some(tmp.path().join("wt"));
11706 let mut state = RunState::new(
11707 repo.clone(),
11708 "main".to_owned(),
11709 "deadbeef".to_owned(),
11710 "task".to_owned(),
11711 config,
11712 );
11713 git::git(&repo, &["branch", "magi/x/A", "main"])
11715 .await
11716 .expect("branch");
11717 state.candidates = vec![Candidate {
11718 index: 0,
11719 label: 'A',
11720 agent: "alpha".to_owned(),
11721 branch: "magi/x/A".to_owned(),
11722 worktree: state.worktree_root().join("cand-A"),
11723 summary: String::new(),
11724 stat: String::new(),
11725 files: 0,
11726 commits: 0,
11727 empty: false,
11728 failed: None,
11729 verified_noop: None,
11730 duration_ms: 0,
11731 folded: true,
11732 }];
11733 state.released_to = Some("20260901-000000-new1".to_owned());
11734 state.released_branches = vec!["magi/x/A".to_owned()];
11735
11736 fold_run(&mut state, true, &home).await.expect("fold_run");
11737
11738 assert!(
11739 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11740 "the handed-over branch survives a fold"
11741 );
11742 }
11743
11744 #[tokio::test]
11748 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
11749 let tmp = tempfile::tempdir().expect("tempdir");
11750 let repo = tmp.path().join("repo");
11751 std::fs::create_dir_all(&repo).unwrap();
11752 init_repo(&repo);
11753 let run = |args: &[&str]| {
11754 let out = std::process::Command::new("git")
11755 .quiet()
11756 .args(args)
11757 .current_dir(&repo)
11758 .output()
11759 .expect("spawn git");
11760 assert!(out.status.success(), "git {args:?}");
11761 };
11762 run(&["branch", "magi/x/A"]);
11763 run(&["checkout", "-q", "-b", "magi/x/B"]);
11764 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
11765 run(&["add", "-A"]);
11766 run(&["commit", "-q", "-m", "work"]);
11767 run(&["checkout", "-q", "main"]);
11768
11769 let probe = RunState::new(
11773 repo.clone(),
11774 "main".to_owned(),
11775 "deadbeef".to_owned(),
11776 "task".to_owned(),
11777 Config::default(),
11778 );
11779 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
11780 let bare = tmp.path().join("origin.git");
11781 let out = std::process::Command::new("git")
11782 .quiet()
11783 .args(["init", "-q", "--bare"])
11784 .arg(&bare)
11785 .output()
11786 .expect("spawn git");
11787 assert!(out.status.success(), "git init --bare");
11788 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
11789 run(&["push", "-q", "origin", "main"]);
11790
11791 let mut state = RunState::new(
11792 repo.clone(),
11793 "main".to_owned(),
11794 "deadbeef".to_owned(),
11795 "task".to_owned(),
11796 Config::default(),
11797 );
11798 state.seeds = vec![refs::Seed {
11799 token: "magi/27b2/A".to_owned(),
11800 kind: refs::SeedKind::Unresolved,
11801 sha: String::new(),
11802 branch: true,
11803 detail: "no branch or commit named magi/27b2/A".to_owned(),
11804 }];
11805
11806 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
11807 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
11808 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
11809 let detail = empty_candidate_detail(&state, "main");
11810 assert!(detail.starts_with("empty candidate"), "{detail}");
11811 assert!(detail.contains("magi/27b2/A"), "{detail}");
11812 }
11813
11814 #[tokio::test]
11823 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
11824 let tmp = tempfile::tempdir().expect("tempdir");
11825 let repo = tmp.path().join("repo");
11826 std::fs::create_dir_all(&repo).unwrap();
11827 init_repo(&repo);
11828
11829 let mut config = Config::default();
11830 config.merge.mode = MergeMode::Local;
11831
11832 let mut state = RunState::new(
11833 repo.clone(),
11834 "main".to_owned(),
11835 "deadbeef".to_owned(),
11836 "task".to_owned(),
11837 config,
11838 );
11839 state.candidates = vec![Candidate {
11840 index: 0,
11841 label: 'A',
11842 agent: "alpha".to_owned(),
11843 branch: "does-not-exist".to_owned(),
11844 worktree: repo.clone(),
11845 summary: String::new(),
11846 stat: String::new(),
11847 files: 0,
11848 commits: 0,
11849 empty: false,
11850 failed: None,
11851 verified_noop: None,
11852 duration_ms: 0,
11853 folded: false,
11854 }];
11855 state.tally = Some(Tally {
11856 first_choice: BTreeMap::from([('A', 1)]),
11857 borda: BTreeMap::new(),
11858 winner: 'A',
11859 rankings: 1,
11860 unanimous_initial: true,
11861 deliberated: false,
11862 changed_votes: 0,
11863 unanimous_final: true,
11864 tie_break: None,
11865 judges: 0,
11866 present: 0,
11867 quorum: 0,
11868 met_quorum: true,
11869 uncontested: Some("only candidate A produced a change".to_owned()),
11870 });
11871 state.reviews = vec![ReviewRound {
11872 round: 1,
11873 head: "deadbeef".to_owned(),
11874 verified_head: None,
11875 verified_at: None,
11876 reviews: Vec::new(),
11877 e2e: Vec::new(),
11878 fix: None,
11879 blocking: 0,
11880 answered: 0,
11881 expected: 0,
11882 clean: true,
11883 verify_retried: false,
11884 e2e_deferred: false,
11885 e2e_defer_reason: None,
11886 progressed: false,
11887 vote_split: false,
11888 reconsideration: Vec::new(),
11889 verdict: None,
11890 }];
11891 state.gate = vec![CommandOutcome {
11892 command: "test".to_owned(),
11893 code: Some(0),
11894 output_tail: String::new(),
11895 duration_ms: 0,
11896 resource_blocked: false,
11897 }];
11898 state.gate_ran = true;
11899 state.status = RunStatus::Ready;
11904 state.merge = Some(MergeOutcome {
11905 mode: MergeMode::Local,
11906 ok: false,
11907 detail: "already concluded".to_owned(),
11908 empty: false,
11909 });
11910
11911 let mut runner = Runner {
11912 state,
11913 roles: ResolvedRoles {
11914 implementers: Vec::new(),
11915 judges: Vec::new(),
11916 reviewers: Vec::new(),
11917 fixer: None,
11918 conductor: conductor(),
11919 implementer_roster: Vec::new(),
11920 judge_roster: Vec::new(),
11921 reviewer_roster: Vec::new(),
11922 },
11923 sem: Arc::new(Semaphore::new(1)),
11924 pause: Pause::new(),
11925 interrupt: Pause::new(),
11926 };
11927
11928 runner.merge().await.expect("merge");
11929
11930 assert_eq!(
11931 runner.state.status,
11932 RunStatus::Ready,
11933 "a concluded run's status must not change on reentry"
11934 );
11935 assert_eq!(
11936 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11937 Some("already concluded"),
11938 "merge must not run again once the node already recorded an outcome"
11939 );
11940 }
11941
11942 #[tokio::test]
11951 async fn merge_refuses_a_gate_that_has_not_actually_run() {
11952 let tmp = tempfile::tempdir().expect("tempdir");
11953 let repo = tmp.path().join("repo");
11954 std::fs::create_dir_all(&repo).unwrap();
11955 init_repo(&repo);
11956
11957 let mut config = Config::default();
11958 config.merge.mode = MergeMode::Local;
11959
11960 let mut state = RunState::new(
11961 repo.clone(),
11962 "main".to_owned(),
11963 "deadbeef".to_owned(),
11964 "task".to_owned(),
11965 config,
11966 );
11967 state.candidates = vec![Candidate {
11968 index: 0,
11969 label: 'A',
11970 agent: "alpha".to_owned(),
11971 branch: "does-not-exist".to_owned(),
11972 worktree: repo.clone(),
11973 summary: String::new(),
11974 stat: String::new(),
11975 files: 0,
11976 commits: 0,
11977 empty: false,
11978 failed: None,
11979 verified_noop: None,
11980 duration_ms: 0,
11981 folded: false,
11982 }];
11983 state.tally = Some(Tally {
11984 first_choice: BTreeMap::from([('A', 1)]),
11985 borda: BTreeMap::new(),
11986 winner: 'A',
11987 rankings: 1,
11988 unanimous_initial: true,
11989 deliberated: false,
11990 changed_votes: 0,
11991 unanimous_final: true,
11992 tie_break: None,
11993 judges: 0,
11994 present: 0,
11995 quorum: 0,
11996 met_quorum: true,
11997 uncontested: Some("only candidate A produced a change".to_owned()),
11998 });
11999 state.reviews = vec![ReviewRound {
12000 round: 1,
12001 head: "deadbeef".to_owned(),
12002 verified_head: None,
12003 verified_at: None,
12004 reviews: Vec::new(),
12005 e2e: Vec::new(),
12006 fix: None,
12007 blocking: 0,
12008 answered: 0,
12009 expected: 0,
12010 clean: true,
12011 verify_retried: false,
12012 e2e_deferred: false,
12013 e2e_defer_reason: None,
12014 progressed: false,
12015 vote_split: false,
12016 reconsideration: Vec::new(),
12017 verdict: None,
12018 }];
12019 state.gate = Vec::new();
12021 state.gate_ran = false;
12022 state.status = RunStatus::Gating;
12023
12024 let mut runner = Runner {
12025 state,
12026 roles: ResolvedRoles {
12027 implementers: Vec::new(),
12028 judges: Vec::new(),
12029 reviewers: Vec::new(),
12030 fixer: None,
12031 conductor: conductor(),
12032 implementer_roster: Vec::new(),
12033 judge_roster: Vec::new(),
12034 reviewer_roster: Vec::new(),
12035 },
12036 sem: Arc::new(Semaphore::new(1)),
12037 pause: Pause::new(),
12038 interrupt: Pause::new(),
12039 };
12040
12041 runner.merge().await.expect("merge");
12042
12043 assert!(
12044 runner.state.merge.is_none(),
12045 "an empty gate must never be read as a passing one: {:?}",
12046 runner.state.merge
12047 );
12048 }
12049
12050 #[tokio::test]
12057 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12058 let tmp = tempfile::tempdir().expect("tempdir");
12059 let repo = tmp.path().join("repo");
12060 std::fs::create_dir_all(&repo).unwrap();
12061 init_repo(&repo);
12062
12063 let config = Config::default();
12065
12066 let mut state = RunState::new(
12067 repo.clone(),
12068 "main".to_owned(),
12069 "deadbeef".to_owned(),
12070 "task".to_owned(),
12071 config,
12072 );
12073 state.candidates = vec![Candidate {
12074 index: 0,
12075 label: 'A',
12076 agent: "alpha".to_owned(),
12077 branch: "does-not-exist".to_owned(),
12078 worktree: repo.clone(),
12079 summary: String::new(),
12080 stat: String::new(),
12081 files: 0,
12082 commits: 0,
12083 empty: false,
12084 failed: None,
12085 verified_noop: None,
12086 duration_ms: 0,
12087 folded: false,
12088 }];
12089 state.tally = Some(Tally {
12090 first_choice: BTreeMap::from([('A', 1)]),
12091 borda: BTreeMap::new(),
12092 winner: 'A',
12093 rankings: 1,
12094 unanimous_initial: true,
12095 deliberated: false,
12096 changed_votes: 0,
12097 unanimous_final: true,
12098 tie_break: None,
12099 judges: 0,
12100 present: 0,
12101 quorum: 0,
12102 met_quorum: true,
12103 uncontested: Some("only candidate A produced a change".to_owned()),
12104 });
12105 state.reviews = vec![ReviewRound {
12106 round: 1,
12107 head: "deadbeef".to_owned(),
12108 verified_head: None,
12109 verified_at: None,
12110 reviews: Vec::new(),
12111 e2e: Vec::new(),
12112 fix: None,
12113 blocking: 0,
12114 answered: 0,
12115 expected: 0,
12116 clean: true,
12117 verify_retried: false,
12118 e2e_deferred: false,
12119 e2e_defer_reason: None,
12120 progressed: false,
12121 vote_split: false,
12122 reconsideration: Vec::new(),
12123 verdict: None,
12124 }];
12125
12126 let mut runner = Runner {
12127 state,
12128 roles: ResolvedRoles {
12129 implementers: Vec::new(),
12130 judges: Vec::new(),
12131 reviewers: Vec::new(),
12132 fixer: None,
12133 conductor: conductor(),
12134 implementer_roster: Vec::new(),
12135 judge_roster: Vec::new(),
12136 reviewer_roster: Vec::new(),
12137 },
12138 sem: Arc::new(Semaphore::new(1)),
12139 pause: Pause::new(),
12140 interrupt: Pause::new(),
12141 };
12142
12143 runner.gate().await.expect("gate");
12144 assert!(
12145 runner.state.gate_ran,
12146 "zero configured commands is still a real attempt, not an unrun gate"
12147 );
12148 assert!(runner.state.gate.is_empty());
12149 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12150 assert_ne!(
12151 runner.state.status,
12152 RunStatus::Blocked,
12153 "a gate with nothing to check must not read as failed"
12154 );
12155
12156 runner.merge().await.expect("merge");
12157 assert_eq!(
12158 runner.state.status,
12159 RunStatus::Ready,
12160 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12161 );
12162 }
12163
12164 #[tokio::test]
12175 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12176 crate::run::pin_test_home();
12177 let home = crate::run::home();
12178
12179 let tmp = tempfile::tempdir().expect("tempdir");
12180 let repo = tmp.path().join("repo");
12181 std::fs::create_dir_all(&repo).unwrap();
12182 init_repo(&repo);
12183 let cache_dir = tmp.path().join("target");
12186
12187 let mut config = Config::default();
12188 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12189 config.graph.timeout_verify = Some(2);
12192
12193 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12194 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12195 .expect("no io error acquiring directly")
12196 {
12197 crate::cache::AcquireOutcome::Acquired(g) => g,
12198 crate::cache::AcquireOutcome::Busy(b) => {
12199 panic!("expected the direct acquire to win the lease first: {b:?}")
12200 }
12201 };
12202
12203 let mut state = RunState::new(
12204 repo.clone(),
12205 "main".to_owned(),
12206 "deadbeef".to_owned(),
12207 "task".to_owned(),
12208 config,
12209 );
12210 state.candidates = vec![Candidate {
12211 index: 0,
12212 label: 'A',
12213 agent: "alpha".to_owned(),
12214 branch: "does-not-exist".to_owned(),
12215 worktree: repo.clone(),
12216 summary: String::new(),
12217 stat: String::new(),
12218 files: 0,
12219 commits: 0,
12220 empty: false,
12221 failed: None,
12222 verified_noop: None,
12223 duration_ms: 0,
12224 folded: false,
12225 }];
12226 state.tally = Some(Tally {
12227 first_choice: BTreeMap::from([('A', 1)]),
12228 borda: BTreeMap::new(),
12229 winner: 'A',
12230 rankings: 1,
12231 unanimous_initial: true,
12232 deliberated: false,
12233 changed_votes: 0,
12234 unanimous_final: true,
12235 tie_break: None,
12236 judges: 0,
12237 present: 0,
12238 quorum: 0,
12239 met_quorum: true,
12240 uncontested: Some("only candidate A produced a change".to_owned()),
12241 });
12242 state.reviews = vec![ReviewRound {
12243 round: 1,
12244 head: "deadbeef".to_owned(),
12245 verified_head: None,
12246 verified_at: None,
12247 reviews: Vec::new(),
12248 e2e: Vec::new(),
12249 fix: None,
12250 blocking: 0,
12251 answered: 0,
12252 expected: 0,
12253 clean: true,
12254 verify_retried: false,
12255 e2e_deferred: false,
12256 e2e_defer_reason: None,
12257 progressed: false,
12258 vote_split: false,
12259 reconsideration: Vec::new(),
12260 verdict: None,
12261 }];
12262
12263 let mut runner = Runner {
12264 state,
12265 roles: ResolvedRoles {
12266 implementers: Vec::new(),
12267 judges: Vec::new(),
12268 reviewers: Vec::new(),
12269 fixer: None,
12270 conductor: conductor(),
12271 implementer_roster: Vec::new(),
12272 judge_roster: Vec::new(),
12273 reviewer_roster: Vec::new(),
12274 },
12275 sem: Arc::new(Semaphore::new(1)),
12276 pause: Pause::new(),
12277 interrupt: Pause::new(),
12278 };
12279
12280 let started = std::time::Instant::now();
12281 runner.gate().await.expect("gate");
12282 assert!(
12283 started.elapsed() < Duration::from_secs(1),
12284 "a gate with nothing to run must never wait on a lease it never needed"
12285 );
12286 assert!(
12287 runner.state.gate_ran,
12288 "zero commands is still a real, immediate attempt"
12289 );
12290 assert!(runner.state.gate.is_empty());
12291 assert_ne!(
12292 runner.state.status,
12293 RunStatus::Blocked,
12294 "must not read as resource-blocked on a lease it never asked for"
12295 );
12296 }
12297
12298 #[tokio::test]
12308 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
12309 crate::run::pin_test_home();
12310
12311 let tmp = tempfile::tempdir().expect("tempdir");
12312 let repo = tmp.path().join("repo");
12313 std::fs::create_dir_all(&repo).unwrap();
12314 init_repo(&repo);
12315
12316 let mut config = Config::default();
12317 config.verify.gate = vec![
12318 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
12319 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
12320 .to_owned(),
12321 ];
12322
12323 let mut state = RunState::new(
12324 repo.clone(),
12325 "main".to_owned(),
12326 "deadbeef".to_owned(),
12327 "task".to_owned(),
12328 config,
12329 );
12330 let run_id = state.id.clone();
12331 state.candidates = vec![Candidate {
12332 index: 0,
12333 label: 'A',
12334 agent: "alpha".to_owned(),
12335 branch: "does-not-exist".to_owned(),
12336 worktree: repo.clone(),
12337 summary: String::new(),
12338 stat: String::new(),
12339 files: 0,
12340 commits: 0,
12341 empty: false,
12342 failed: None,
12343 verified_noop: None,
12344 duration_ms: 0,
12345 folded: false,
12346 }];
12347 state.tally = Some(Tally {
12348 first_choice: BTreeMap::from([('A', 1)]),
12349 borda: BTreeMap::new(),
12350 winner: 'A',
12351 rankings: 1,
12352 unanimous_initial: true,
12353 deliberated: false,
12354 changed_votes: 0,
12355 unanimous_final: true,
12356 tie_break: None,
12357 judges: 0,
12358 present: 0,
12359 quorum: 0,
12360 met_quorum: true,
12361 uncontested: Some("only candidate A produced a change".to_owned()),
12362 });
12363 state.reviews = vec![ReviewRound {
12364 round: 1,
12365 head: "deadbeef".to_owned(),
12366 verified_head: None,
12367 verified_at: None,
12368 reviews: Vec::new(),
12369 e2e: Vec::new(),
12370 fix: None,
12371 blocking: 0,
12372 answered: 0,
12373 expected: 0,
12374 clean: true,
12375 verify_retried: false,
12376 e2e_deferred: false,
12377 e2e_defer_reason: None,
12378 progressed: false,
12379 vote_split: false,
12380 reconsideration: Vec::new(),
12381 verdict: None,
12382 }];
12383
12384 let mut runner = Runner {
12385 state,
12386 roles: ResolvedRoles {
12387 implementers: Vec::new(),
12388 judges: Vec::new(),
12389 reviewers: Vec::new(),
12390 fixer: None,
12391 conductor: conductor(),
12392 implementer_roster: Vec::new(),
12393 judge_roster: Vec::new(),
12394 reviewer_roster: Vec::new(),
12395 },
12396 sem: Arc::new(Semaphore::new(1)),
12397 pause: Pause::new(),
12398 interrupt: Pause::new(),
12399 };
12400
12401 let started_marker = repo.join("started.marker");
12402 let release_marker = repo.join("release.marker");
12403 let poller = tokio::spawn(async move {
12404 for _ in 0..100 {
12409 if started_marker.exists()
12410 && let Ok(s) = crate::run::RunState::load(&run_id)
12411 && let Some(a) = s.active.get("gate")
12412 {
12413 std::fs::write(&release_marker, b"go").expect("release marker");
12414 return Some(a.clone());
12415 }
12416 tokio::time::sleep(Duration::from_millis(50)).await;
12417 }
12418 None
12419 });
12420
12421 runner.gate().await.expect("gate");
12422 let captured = poller.await.expect("poller task");
12423 let captured = captured.expect(
12424 "the poller never saw a `gate` task entry in run.json while the command was \
12425 still blocked on its own release marker",
12426 );
12427
12428 assert_eq!(captured.task.as_deref(), Some("gate"));
12429 assert_eq!(captured.node, "gate");
12430 assert_eq!(captured.index, Some(1));
12431 assert_eq!(captured.total, Some(1));
12432 assert!(
12433 captured
12434 .command
12435 .as_deref()
12436 .is_some_and(|c| c.contains("started.marker")),
12437 "{captured:?}"
12438 );
12439
12440 assert!(
12441 runner.state.active.is_empty(),
12442 "the entry must be cleared once the command actually finished: {:?}",
12443 runner.state.active
12444 );
12445 assert!(runner.state.gate_ran);
12446 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
12447 }
12448
12449 #[tokio::test]
12452 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
12453 use crate::verdict::{Finding, ReviewVote, Severity};
12454 crate::run::pin_test_home();
12455 let tmp = tempfile::tempdir().expect("tempdir");
12456 let repo = tmp.path().join("repo");
12457 std::fs::create_dir_all(&repo).unwrap();
12458 init_repo(&repo);
12459
12460 for (severity, vote, expect) in [
12461 (Severity::Major, ReviewVote::Reject, true),
12462 (Severity::Minor, ReviewVote::Reject, false),
12463 (Severity::Major, ReviewVote::Approve, false),
12464 ] {
12465 let mut round = review_round(false, 1, 1, 1, false, true);
12466 round.reviews = vec![ReviewRecord {
12467 reviewer: 1,
12468 agent: "alpha".to_owned(),
12469 summary: String::new(),
12470 findings: vec![Finding {
12471 id: "R1-1-1".to_owned(),
12472 severity,
12473 file: None,
12474 line: None,
12475 title: "t".to_owned(),
12476 detail: String::new(),
12477 }],
12478 vote: Some(vote),
12479 failed: None,
12480 duration_ms: 0,
12481 attempts: 0,
12482 }];
12483 let mut state = RunState::new(
12484 repo.clone(),
12485 "main".to_owned(),
12486 "deadbeef".to_owned(),
12487 "task".to_owned(),
12488 Config::default(),
12489 );
12490 state.reviews = vec![round];
12491 let mut runner = Runner {
12492 state,
12493 roles: ResolvedRoles {
12494 implementers: Vec::new(),
12495 judges: Vec::new(),
12496 reviewers: Vec::new(),
12497 fixer: None,
12498 conductor: conductor(),
12499 implementer_roster: Vec::new(),
12500 judge_roster: Vec::new(),
12501 reviewer_roster: Vec::new(),
12502 },
12503 sem: Arc::new(Semaphore::new(1)),
12504 pause: Pause::new(),
12505 interrupt: Pause::new(),
12506 };
12507 let shell = runner.state.config.shell();
12508 runner
12509 .stop_reviewing("round budget spent", &shell, &repo)
12510 .await
12511 .expect("stop_reviewing");
12512 assert_eq!(runner.state.status, RunStatus::Gating);
12513 assert_eq!(
12514 runner.state.contested_handoff.is_some(),
12515 expect,
12516 "{severity:?} + {vote:?}"
12517 );
12518 }
12519 }
12520
12521 #[tokio::test]
12534 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
12535 crate::run::pin_test_home();
12536 let home = crate::run::home();
12537
12538 let tmp = tempfile::tempdir().expect("tempdir");
12539 let repo = tmp.path().join("repo");
12540 std::fs::create_dir_all(&repo).unwrap();
12541 init_repo(&repo);
12542 let head = crate::git::rev_parse(&repo, "HEAD")
12543 .await
12544 .expect("rev-parse");
12545 let cache_dir = tmp.path().join("target");
12548
12549 let mut config = Config::default();
12550 config.verify.e2e = vec![format!(
12551 "CARGO_TARGET_DIR='{}' test -f README.md",
12552 cache_dir.display()
12553 )];
12554 config.graph.review_rounds = 1;
12555 config.graph.timeout_verify = Some(2);
12558
12559 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12560 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12561 .expect("no io error acquiring directly")
12562 {
12563 crate::cache::AcquireOutcome::Acquired(g) => g,
12564 crate::cache::AcquireOutcome::Busy(b) => {
12565 panic!("expected the direct acquire to win the lease first: {b:?}")
12566 }
12567 };
12568
12569 let mut state = RunState::new(
12570 repo.clone(),
12571 "main".to_owned(),
12572 head.clone(),
12573 "task".to_owned(),
12574 config,
12575 );
12576 state.candidates = vec![Candidate {
12577 index: 0,
12578 label: 'A',
12579 agent: "alpha".to_owned(),
12580 branch: "does-not-exist".to_owned(),
12581 worktree: repo.clone(),
12582 summary: String::new(),
12583 stat: String::new(),
12584 files: 0,
12585 commits: 0,
12586 empty: false,
12587 failed: None,
12588 verified_noop: None,
12589 duration_ms: 0,
12590 folded: false,
12591 }];
12592 state.tally = Some(Tally {
12593 first_choice: BTreeMap::from([('A', 1)]),
12594 borda: BTreeMap::new(),
12595 winner: 'A',
12596 rankings: 1,
12597 unanimous_initial: true,
12598 deliberated: false,
12599 changed_votes: 0,
12600 unanimous_final: true,
12601 tie_break: None,
12602 judges: 0,
12603 present: 0,
12604 quorum: 0,
12605 met_quorum: true,
12606 uncontested: Some("only candidate A produced a change".to_owned()),
12607 });
12608 state.reviews = vec![ReviewRound {
12612 round: 1,
12613 head: head.clone(),
12614 verified_head: None,
12615 verified_at: None,
12616 reviews: Vec::new(),
12617 e2e: Vec::new(),
12618 fix: None,
12619 blocking: 1,
12620 answered: 1,
12621 expected: 1,
12622 clean: false,
12623 verify_retried: false,
12624 e2e_deferred: true,
12625 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
12626 progressed: false,
12627 vote_split: false,
12628 reconsideration: Vec::new(),
12629 verdict: None,
12630 }];
12631
12632 let mut runner = Runner {
12633 state,
12634 roles: ResolvedRoles {
12635 implementers: Vec::new(),
12636 judges: Vec::new(),
12637 reviewers: Vec::new(),
12638 fixer: None,
12639 conductor: conductor(),
12640 implementer_roster: Vec::new(),
12641 judge_roster: Vec::new(),
12642 reviewer_roster: Vec::new(),
12643 },
12644 sem: Arc::new(Semaphore::new(1)),
12645 pause: Pause::new(),
12646 interrupt: Pause::new(),
12647 };
12648
12649 let shell = runner.state.config.shell();
12650 runner
12651 .stop_reviewing("round budget spent", &shell, &repo)
12652 .await
12653 .expect("stop_reviewing");
12654
12655 let last = runner.state.reviews.last().expect("round record");
12656 assert_eq!(
12657 last.e2e_status(),
12658 E2eStatus::ResourceBlocked,
12659 "the shared cache is still held; the attempt must read as blocked, not deferred or \
12660 failed: {last:?}"
12661 );
12662 assert_eq!(
12663 last.verified_head.as_deref(),
12664 Some(head.as_str()),
12665 "which commit this attempt targeted is known even though nothing finished checking \
12666 it"
12667 );
12668 let first_attempt_at = last
12669 .verified_at
12670 .expect("when this attempt ran is known too");
12671 assert_ne!(
12672 runner.state.status,
12673 RunStatus::Blocked,
12674 "contention is evidence about the machine, not the patch — it must not settle the \
12675 run as blocked: {:?}",
12676 runner.state.status
12677 );
12678 assert!(
12679 !runner
12680 .state
12681 .events
12682 .iter()
12683 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
12684 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
12685 runner.state.events
12686 );
12687
12688 runner
12693 .stop_reviewing("round budget spent", &shell, &repo)
12694 .await
12695 .expect("stop_reviewing retry");
12696 assert_eq!(
12697 runner.state.reviews.len(),
12698 1,
12699 "no new round was started: {:?}",
12700 runner.state.reviews
12701 );
12702 let last = runner.state.reviews.last().expect("round record");
12703 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
12704 assert!(
12705 last.verified_at.expect("still known") > first_attempt_at,
12706 "a second reentry must be a fresh attempt, not a stale copy of the first"
12707 );
12708 assert_ne!(runner.state.status, RunStatus::Blocked);
12709
12710 held.release();
12711 }
12712
12713 #[tokio::test]
12725 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
12726 crate::run::pin_test_home();
12727 let home = crate::run::home();
12728
12729 let tmp = tempfile::tempdir().expect("tempdir");
12730 let repo = tmp.path().join("repo");
12731 std::fs::create_dir_all(&repo).unwrap();
12732 init_repo(&repo);
12733 let head = crate::git::rev_parse(&repo, "HEAD")
12734 .await
12735 .expect("rev-parse");
12736 let cache_dir = tmp.path().join("target");
12737
12738 let mut config = Config::default();
12739 config.verify.e2e = vec![format!(
12740 "CARGO_TARGET_DIR='{}' test -f README.md",
12741 cache_dir.display()
12742 )];
12743 config.graph.review_rounds = 1;
12744 config.graph.timeout_verify = Some(2);
12745
12746 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12747 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12748 .expect("no io error acquiring directly")
12749 {
12750 crate::cache::AcquireOutcome::Acquired(g) => g,
12751 crate::cache::AcquireOutcome::Busy(b) => {
12752 panic!("expected the direct acquire to win the lease first: {b:?}")
12753 }
12754 };
12755
12756 let mut state = RunState::new(
12757 repo.clone(),
12758 "main".to_owned(),
12759 head.clone(),
12760 "task".to_owned(),
12761 config,
12762 );
12763 state.candidates = vec![Candidate {
12764 index: 0,
12765 label: 'A',
12766 agent: "alpha".to_owned(),
12767 branch: "does-not-exist".to_owned(),
12768 worktree: repo.clone(),
12769 summary: String::new(),
12770 stat: String::new(),
12771 files: 0,
12772 commits: 0,
12773 empty: false,
12774 failed: None,
12775 verified_noop: None,
12776 duration_ms: 0,
12777 folded: false,
12778 }];
12779 state.tally = Some(Tally {
12780 first_choice: BTreeMap::from([('A', 1)]),
12781 borda: BTreeMap::new(),
12782 winner: 'A',
12783 rankings: 1,
12784 unanimous_initial: true,
12785 deliberated: false,
12786 changed_votes: 0,
12787 unanimous_final: true,
12788 tie_break: None,
12789 judges: 0,
12790 present: 0,
12791 quorum: 0,
12792 met_quorum: true,
12793 uncontested: Some("only candidate A produced a change".to_owned()),
12794 });
12795 state.reviews = vec![ReviewRound {
12799 round: 1,
12800 head: head.clone(),
12801 verified_head: Some(head.clone()),
12802 verified_at: Some(jiff::Timestamp::now()),
12803 reviews: Vec::new(),
12804 e2e: vec![CommandOutcome {
12805 command: format!(
12806 "CARGO_TARGET_DIR='{}' test -f README.md",
12807 cache_dir.display()
12808 ),
12809 code: None,
12810 output_tail: "waiting for the shared build cache".to_owned(),
12811 duration_ms: 0,
12812 resource_blocked: true,
12813 }],
12814 fix: None,
12815 blocking: 1,
12816 answered: 1,
12817 expected: 1,
12818 clean: false,
12819 verify_retried: false,
12820 e2e_deferred: false,
12821 e2e_defer_reason: None,
12822 progressed: false,
12823 vote_split: false,
12824 reconsideration: Vec::new(),
12825 verdict: None,
12826 }];
12827
12828 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
12829 let mut runner = Runner {
12830 state,
12831 roles: ResolvedRoles {
12832 implementers: Vec::new(),
12833 judges: Vec::new(),
12834 reviewers: Vec::new(),
12835 fixer: None,
12836 conductor: conductor(),
12837 implementer_roster: Vec::new(),
12838 judge_roster: Vec::new(),
12839 reviewer_roster: Vec::new(),
12840 },
12841 sem: Arc::new(Semaphore::new(1)),
12842 pause: Pause::new(),
12843 interrupt: Pause::new(),
12844 };
12845
12846 runner.review_loop().await.expect("review_loop");
12851
12852 assert_eq!(
12853 runner.state.reviews.len(),
12854 1,
12855 "no new round was started on top of the unresolved one: {:?}",
12856 runner.state.reviews
12857 );
12858 let last = &runner.state.reviews[0];
12859 assert_eq!(
12860 last.e2e_status(),
12861 E2eStatus::ResourceBlocked,
12862 "still contended: {last:?}"
12863 );
12864 assert!(
12865 last.verified_at.expect("still known") > first_attempt_at,
12866 "review_loop must have actually retried the check, not left it exactly as found"
12867 );
12868 assert_ne!(
12869 runner.state.status,
12870 RunStatus::Blocked,
12871 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
12872 runner.state.status
12873 );
12874
12875 held.release();
12876 }
12877
12878 #[tokio::test]
12879 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
12880 crate::run::pin_test_home();
12881 let tmp = tempfile::tempdir().expect("tempdir");
12882 let repo = tmp.path().join("repo");
12883 std::fs::create_dir_all(&repo).unwrap();
12884 init_repo(&repo);
12885
12886 let mut config = Config::default();
12887 config.merge.mode = MergeMode::Pr;
12888 config.graph.land = true;
12889 config.graph.land_approval = false;
12890
12891 let mut state = RunState::new(
12892 repo.clone(),
12893 "main".to_owned(),
12894 "deadbeef".to_owned(),
12895 "task".to_owned(),
12896 config,
12897 );
12898 state.candidates = vec![Candidate {
12899 index: 0,
12900 label: 'A',
12901 agent: "alpha".to_owned(),
12902 branch: "does-not-exist".to_owned(),
12903 worktree: repo.clone(),
12904 summary: String::new(),
12905 stat: String::new(),
12906 files: 0,
12907 commits: 0,
12908 empty: false,
12909 failed: None,
12910 verified_noop: None,
12911 duration_ms: 0,
12912 folded: false,
12913 }];
12914 state.tally = Some(Tally {
12915 first_choice: BTreeMap::from([('A', 1)]),
12916 borda: BTreeMap::new(),
12917 winner: 'A',
12918 rankings: 1,
12919 unanimous_initial: true,
12920 deliberated: false,
12921 changed_votes: 0,
12922 unanimous_final: true,
12923 tie_break: None,
12924 judges: 0,
12925 present: 0,
12926 quorum: 0,
12927 met_quorum: true,
12928 uncontested: Some("only candidate A produced a change".to_owned()),
12929 });
12930 state.reviews = vec![ReviewRound {
12931 round: 1,
12932 head: "deadbeef".to_owned(),
12933 verified_head: None,
12934 verified_at: None,
12935 reviews: Vec::new(),
12936 e2e: Vec::new(),
12937 fix: None,
12938 blocking: 0,
12939 answered: 0,
12940 expected: 0,
12941 clean: true,
12942 verify_retried: false,
12943 e2e_deferred: false,
12944 e2e_defer_reason: None,
12945 progressed: false,
12946 vote_split: false,
12947 reconsideration: Vec::new(),
12948 verdict: None,
12949 }];
12950 state.gate = vec![CommandOutcome {
12951 command: "test".to_owned(),
12952 code: Some(0),
12953 output_tail: String::new(),
12954 duration_ms: 0,
12955 resource_blocked: false,
12956 }];
12957 state.gate_ran = true;
12958 state.status = RunStatus::Landing;
12962 state.merge = Some(MergeOutcome {
12963 mode: MergeMode::Pr,
12964 ok: true,
12965 detail: "https://example.invalid/x/y/pull/1".to_owned(),
12966 empty: false,
12967 });
12968
12969 ask_test_home();
12973 state.save().expect("save landing run");
12976 let store = ask::Questions::open();
12977 let q = ask_open_question(&store, &state.id);
12978
12979 let mut runner = Runner {
12980 state,
12981 roles: ResolvedRoles {
12982 implementers: Vec::new(),
12983 judges: Vec::new(),
12984 reviewers: Vec::new(),
12985 fixer: None,
12986 conductor: conductor(),
12987 implementer_roster: Vec::new(),
12988 judge_roster: Vec::new(),
12989 reviewer_roster: Vec::new(),
12990 },
12991 sem: Arc::new(Semaphore::new(1)),
12992 pause: Pause::new(),
12993 interrupt: Pause::new(),
12994 };
12995
12996 runner.execute().await.expect("execute");
13001
13002 assert_eq!(
13003 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13004 Some("https://example.invalid/x/y/pull/1"),
13005 "reentry must not push again or open a second pull request over the \
13006 one `land` is already watching"
13007 );
13008 assert_ne!(
13009 runner.state.status,
13010 RunStatus::Landing,
13011 "land could not actually reach the fake pull request, so it must \
13012 have given up rather than left the run silently parked forever"
13013 );
13014 assert_eq!(runner.state.status, RunStatus::Blocked);
13018 assert!(
13019 store.get(&q.id).unwrap().status.open(),
13020 "Blocked is still alive; settle_questions must have been a no-op here"
13021 );
13022 }
13023
13024 fn state_with_round(round: ReviewRound) -> RunState {
13025 let mut s = RunState::new(
13026 PathBuf::from("/repo"),
13027 "main".to_owned(),
13028 "abc1234".to_owned(),
13029 "add retries".to_owned(),
13030 Config::default(),
13031 );
13032 s.reviews = vec![round];
13033 s
13034 }
13035
13036 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13037 crate::verdict::Finding {
13038 id: id.to_owned(),
13039 severity,
13040 file: None,
13041 line: None,
13042 title: title.to_owned(),
13043 detail: String::new(),
13044 }
13045 }
13046
13047 #[test]
13048 fn pr_body_names_open_findings_and_declined_ones() {
13049 let round = ReviewRound {
13050 round: 2,
13051 head: "deadbee".to_owned(),
13052 verified_head: None,
13053 verified_at: None,
13054 reviews: vec![ReviewRecord {
13055 attempts: 0,
13056 reviewer: 1,
13057 agent: "alpha".to_owned(),
13058 summary: String::new(),
13059 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13060 vote: None,
13061 failed: None,
13062 duration_ms: 0,
13063 }],
13064 e2e: vec![CommandOutcome {
13065 command: "cargo test".to_owned(),
13066 code: Some(0),
13067 output_tail: String::new(),
13068 duration_ms: 0,
13069 resource_blocked: false,
13070 }],
13071 verify_retried: false,
13072 e2e_deferred: false,
13073 e2e_defer_reason: None,
13074 fix: Some(FixRecord {
13075 agent: "alpha".to_owned(),
13076 addressed: Vec::new(),
13077 rejected: vec![crate::verdict::Rejection {
13078 id: "R1-1-1".to_owned(),
13079 why: "not reachable from any caller".to_owned(),
13080 }],
13081 notes: String::new(),
13082 committed: true,
13083 failed: None,
13084 duration_ms: 0,
13085 continuation: None,
13086 }),
13087 blocking: 0,
13088 answered: 1,
13089 expected: 1,
13090 clean: false,
13091 progressed: true,
13092 vote_split: false,
13093 reconsideration: Vec::new(),
13094 verdict: None,
13095 };
13096 let state = state_with_round(round);
13097 let body = pr_message(&state, 'A').body;
13098
13099 assert!(body.contains("add retries"), "the task must still be there");
13100 assert!(body.contains("R2-1-1"), "{body}");
13101 assert!(!body.contains("unused import"), "{body}");
13102 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13103 assert!(
13104 !body.contains("not reachable from any caller"),
13105 "the reason it was declined: {body}"
13106 );
13107 }
13108
13109 #[test]
13110 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13111 let round = ReviewRound {
13112 round: 1,
13113 head: "deadbee".to_owned(),
13114 verified_head: None,
13115 verified_at: None,
13116 reviews: vec![ReviewRecord {
13117 attempts: 0,
13118 reviewer: 1,
13119 agent: "alpha".to_owned(),
13120 summary: String::new(),
13121 findings: Vec::new(),
13122 vote: None,
13123 failed: None,
13124 duration_ms: 0,
13125 }],
13126 e2e: Vec::new(),
13127 verify_retried: false,
13128 e2e_deferred: false,
13129 e2e_defer_reason: None,
13130 fix: None,
13131 blocking: 0,
13132 answered: 1,
13133 expected: 1,
13134 clean: true,
13135 progressed: false,
13136 vote_split: false,
13137 reconsideration: Vec::new(),
13138 verdict: None,
13139 };
13140 let state = state_with_round(round);
13141 let body = pr_message(&state, 'A').body;
13142 assert!(!body.contains("Open review findings"), "{body}");
13143 assert!(!body.contains("Declined"), "{body}");
13144 }
13145
13146 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13147 let mut state = RunState::new(
13148 PathBuf::from("/repo"),
13149 "main".to_owned(),
13150 "abc1234".to_owned(),
13151 instruction.to_owned(),
13152 Config::default(),
13153 );
13154 state.candidates.push(Candidate {
13155 index: 0,
13156 label: 'A',
13157 agent: "alpha".to_owned(),
13158 branch: "magi/x/A".to_owned(),
13159 worktree: PathBuf::from("/wt"),
13160 summary: summary.to_owned(),
13161 stat: String::new(),
13162 files: 1,
13163 commits: 1,
13164 empty: false,
13165 failed: None,
13166 verified_noop: None,
13167 folded: false,
13168 duration_ms: 0,
13169 });
13170 state
13171 }
13172
13173 fn review_state(subjects: &[&str]) -> RunState {
13174 let mut state = state_with_summary(
13175 "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",
13176 "",
13177 );
13178 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13179 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13180 state
13181 }
13182
13183 #[tokio::test]
13186 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13187 ask_test_home();
13188 let tmp = tempfile::tempdir().unwrap();
13189 let repo = tmp.path().join("repo");
13190 std::fs::create_dir_all(&repo).unwrap();
13191 init_repo(&repo);
13192 let origin = tmp.path().join("origin.git");
13193 let g = |dir: &Path, args: &[&str]| {
13194 let out = std::process::Command::new("git")
13195 .args(args)
13196 .current_dir(dir)
13197 .quiet()
13198 .output()
13199 .expect("spawn git");
13200 assert!(
13201 out.status.success(),
13202 "git {args:?}: {}",
13203 String::from_utf8_lossy(&out.stderr)
13204 );
13205 };
13206 g(
13207 tmp.path(),
13208 &[
13209 "clone",
13210 "--bare",
13211 "-q",
13212 repo.to_str().unwrap(),
13213 origin.to_str().unwrap(),
13214 ],
13215 );
13216 g(
13217 &repo,
13218 &["remote", "add", "origin", origin.to_str().unwrap()],
13219 );
13220 let c1 = git::rev_parse(&repo, "main").await.unwrap();
13221
13222 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
13224 g(&repo, &["add", "-A"]);
13225 g(
13226 &repo,
13227 &["commit", "-q", "-m", "chore(deps): update a crate"],
13228 );
13229 g(&repo, &["push", "-q", "origin", "main"]);
13230 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
13231 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13232 g(&repo, &["add", "-A"]);
13233 g(
13234 &repo,
13235 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
13236 );
13237 g(&repo, &["checkout", "-q", "main"]);
13238
13239 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
13240 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
13241 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
13243 assert_eq!(fallback, c1);
13244
13245 let mut state = review_state(&[
13246 "chore(deps): update a crate",
13247 "fix(daemon): apply a chosen action",
13248 ]);
13249 state.repo = repo.clone();
13250 state.base_branch = "main".to_owned();
13251 state.base_commit = c1;
13252 refresh_reviewed_commits(&mut state, "feat/own").await;
13253 assert_eq!(
13254 state.reviewed_commits,
13255 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
13256 );
13257 assert_eq!(
13258 review_title(&state).as_deref(),
13259 Some("fix(daemon): apply a chosen action")
13260 );
13261 assert_eq!(
13262 leaked_subjects(&state, "feat/own").await,
13263 Some(vec!["chore(deps): update a crate".to_owned()])
13264 );
13265 state.config.merge.remote = "nowhere".to_owned();
13268 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
13269 }
13270
13271 struct SummaryFixture {
13275 _tmp: tempfile::TempDir,
13276 repo: PathBuf,
13277 reply: PathBuf,
13278 runner: Runner,
13279 }
13280
13281 fn summary_fixture() -> SummaryFixture {
13282 ask_test_home();
13283 let tmp = tempfile::tempdir().unwrap();
13284 let repo = tmp.path().join("repo");
13285 std::fs::create_dir_all(&repo).unwrap();
13286 init_repo(&repo);
13287 let origin = tmp.path().join("origin.git");
13288 let g = |args: &[&str]| {
13289 let out = std::process::Command::new("git")
13290 .args(args)
13291 .current_dir(&repo)
13292 .quiet()
13293 .output()
13294 .expect("spawn git");
13295 assert!(
13296 out.status.success(),
13297 "git {args:?}: {}",
13298 String::from_utf8_lossy(&out.stderr)
13299 );
13300 };
13301 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
13302 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
13303 g(&["push", "-q", "origin", "main"]);
13304 g(&["checkout", "-q", "-b", "feat/own"]);
13305 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13306 g(&["add", "-A"]);
13307 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
13308 let reply = tmp.path().join("reply.txt");
13309 std::fs::write(
13310 &reply,
13311 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
13312 )
13313 .unwrap();
13314 let mut runner = runner_at(RunStatus::Gating);
13315 let mut state = review_state(&["feat(own): add the own file"]);
13316 state.repo = repo.clone();
13317 state.base_branch = "main".to_owned();
13318 state.candidates[0].branch = "feat/own".to_owned();
13319 state.candidates[0].worktree = repo.clone();
13320 let mut author = spec("alpha");
13321 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
13322 author
13323 .env
13324 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
13325 state.config.agents = vec![author];
13326 runner.state = state;
13327 SummaryFixture {
13328 _tmp: tmp,
13329 repo,
13330 reply,
13331 runner,
13332 }
13333 }
13334
13335 async fn posted(f: &mut SummaryFixture) -> PrMessage {
13336 let winner = f.runner.state.candidates[0].clone();
13337 f.runner
13338 .guarded_pr_message(&winner, None, true)
13339 .await
13340 .unwrap()
13341 .expect("an English description never parks")
13342 }
13343
13344 fn clean_round_with_minor_findings() -> ReviewRound {
13345 let mut round = passing_round(2, None);
13346 round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
13347 round.reviews = vec![ReviewRecord {
13348 attempts: 0,
13349 reviewer: 1,
13350 agent: "alpha".into(),
13351 summary: "内部レビュー".into(),
13352 findings: vec![
13353 finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
13354 finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
13355 ],
13356 vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
13357 failed: None,
13358 duration_ms: 0,
13359 }];
13360 round
13361 }
13362
13363 #[tokio::test]
13364 async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
13365 for review_only in [false, true] {
13366 let mut f = summary_fixture();
13367 if !review_only {
13368 f.runner.state.candidates[0].agent = "alpha".into();
13369 }
13370 f.runner.state.reviews = vec![clean_round_with_minor_findings()];
13371 assert!(f.runner.state.reviews[0].clean);
13372 assert!(f.runner.state.reviews[0].fix.is_none());
13373 assert!(f.runner.state.open_findings().is_empty());
13374 assert_eq!(
13375 crate::pr_outcome::required_finding_ids(&f.runner.state),
13376 ["R2-1-1", "R2-1-2"]
13377 );
13378 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13379 let evidence = crate::pr_outcome::summary_evidence(
13380 &f.runner.state,
13381 crate::pr_outcome::FinalHead::At(&head),
13382 );
13383 assert!(
13384 evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
13385 );
13386 let omitted = posted(&mut f).await;
13388 assert!(omitted.body.contains("No description"));
13389 assert!(omitted.body.contains("`R2-1-1` [Minor]"));
13390 assert!(omitted.body.contains("`R2-1-2` [Minor]"));
13391 assert!(f.runner.state.change_summary.is_none());
13392 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();
13393 let described = posted(&mut f).await;
13394 assert!(described.body.contains("improve documentation for callers"));
13395 assert!(described.body.contains("remaining edge case"));
13396 assert!(described.body.contains("## Open review findings"));
13397 assert!(!described.body.contains("未修正"));
13398 }
13399 }
13400
13401 async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
13402 let mut f = summary_fixture();
13403 if !review_only {
13404 f.runner.state.candidates[0].agent = "alpha".into();
13405 }
13406 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13407 let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
13408 fixed
13409 .fix
13410 .as_mut()
13411 .unwrap()
13412 .rejected
13413 .push(crate::verdict::Rejection {
13414 id: "R1-1-1".into(),
13415 why: "呼び出されないコードのため辞退".into(),
13416 });
13417 let mut clean = clean_round_with_minor_findings();
13418 clean.verified_head = Some(head.clone());
13419 f.runner.state.reviews = vec![fixed, clean];
13420 ok_gate(&mut f.runner.state, Some(&head));
13421 std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
13422 f.runner.state.config.agents.push(scripted("beta", reply));
13423 f.runner.state.config.roles.language_judge =
13424 Some(crate::config::AgentChoice::One("beta".into()));
13425 f
13426 }
13427
13428 #[tokio::test]
13429 async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
13430 for review_only in [false, true] {
13431 let mut f = rewrite_fixture_with_findings(review_only,
13432 "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;
13433 let m = posted(&mut f).await;
13434 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13435 let expected =
13436 public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
13437 assert!(m.body.contains(&expected));
13438 assert!(m.body.contains("## Review rounds"));
13439 assert!(
13440 m.body
13441 .contains("Gate (`verify.gate`): passed on the final commit")
13442 );
13443 assert!(
13444 m.body
13445 .contains("Tests (`verify.e2e`): passed on the final commit")
13446 );
13447 let c = f.runner.state.change_summary.as_ref().unwrap();
13448 assert!(
13449 m.body
13450 .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
13451 );
13452 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13453 assert!(crate::pr_outcome::covers_required_findings(
13454 &f.runner.state,
13455 &m.body
13456 ));
13457 assert!(m.body.contains("documentation remains incomplete"));
13458 assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
13459 assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
13460 assert!(f.runner.state.github_text.is_none());
13461 }
13462 }
13463
13464 #[tokio::test]
13465 async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
13466 for review_only in [false, true] {
13467 for reply in [
13468 "TITLE: fix: public description\nA short English description.",
13469 "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
13470 ] {
13471 let mut f = rewrite_fixture_with_findings(review_only, reply).await;
13472 let winner = f.runner.state.candidates[0].clone();
13473 let posted = f
13474 .runner
13475 .guarded_pr_message(&winner, None, true)
13476 .await
13477 .unwrap();
13478 assert!(posted.is_none());
13479 assert!(f.runner.state.parked);
13480 let withheld = f.runner.state.github_text.as_ref().unwrap();
13481 assert!(
13482 withheld.rewrite.is_none(),
13483 "an incomplete rewrite must not be selectable"
13484 );
13485 let q = ask::Questions::open()
13486 .get(withheld.question.as_ref().unwrap())
13487 .unwrap();
13488 assert!(
13489 !q.choices
13490 .contains(&crate::github_text::USE_REWRITE.to_owned())
13491 );
13492 assert!(
13493 q.detail.contains("R1-1-1")
13494 && q.detail.contains("R2-1-1")
13495 && q.detail.contains("R2-1-2")
13496 );
13497 }
13498 }
13499 }
13500
13501 #[tokio::test]
13502 async fn japanese_reports_stay_local_in_both_public_run_modes() {
13503 for review_only in [false, true] {
13504 let mut f = summary_fixture();
13505 if !review_only {
13506 f.runner.state.candidates[0].agent = "alpha".into();
13507 }
13508 f.runner.state.instruction = "内部タスクの説明".into();
13509 f.runner.state.candidates[0].summary = "内部の候補報告".into();
13510 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13511 f.runner.state.reviews = vec![
13512 round_with_notes(1, Some("コミットできません。テストは実行できません。")),
13513 round_with_notes(2, Some("二回目の内部修正報告です。")),
13514 passing_round(3, Some(&head)),
13515 ];
13516 f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
13517 ok_gate(&mut f.runner.state, Some(&head));
13518 let captured = f.repo.join("public-prompt.txt");
13519 let author = &mut f.runner.state.config.agents[0];
13520 author.command = vec![
13521 "sh".into(),
13522 "-c".into(),
13523 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
13524 ];
13525 author.env.insert(
13526 "PROMPT_FILE".into(),
13527 captured.to_string_lossy().into_owned(),
13528 );
13529 let m = posted(&mut f).await;
13530 assert!(m.body.contains("Adds `own.txt`"));
13531 assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
13532 assert!(m.body.contains("R1-1-1"));
13533 assert!(
13534 m.body
13535 .contains("Gate (`verify.gate`): passed on the final commit")
13536 );
13537 assert!(
13538 m.body
13539 .contains("Tests (`verify.e2e`): passed on the final commit")
13540 );
13541 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13542 assert!(!m.body.contains("内部") && !m.body.contains("できません"));
13543 let prompt = std::fs::read_to_string(captured).unwrap();
13544 assert!(prompt.contains(&format!(
13545 "git diff {} {head}",
13546 f.runner.state.change_summary.as_ref().unwrap().base
13547 )));
13548 assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
13549 assert_eq!(
13550 f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
13551 "二回目の内部修正報告です。"
13552 );
13553 }
13554 }
13555
13556 #[tokio::test]
13557 async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
13558 for review_only in [false, true] {
13559 let mut f = summary_fixture();
13560 if !review_only {
13561 f.runner.state.candidates[0].agent = "alpha".into();
13562 }
13563 f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
13564 f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
13565 std::fs::write(&f.reply, "no usable description").unwrap();
13566 let failed = posted(&mut f).await;
13567 assert!(
13568 failed
13569 .body
13570 .contains("No description of what this change does could be generated")
13571 );
13572 assert!(failed.body.is_ascii());
13573 assert!(f.runner.state.github_text.is_none());
13574 assert!(f.runner.state.change_summary.is_none());
13575 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13576 let evidence = crate::pr_outcome::summary_evidence(
13577 &f.runner.state,
13578 crate::pr_outcome::FinalHead::At(&head),
13579 );
13580 let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
13581 assert!(
13582 crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
13583 .exists()
13584 );
13585 f.runner.state =
13586 serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
13587 std::fs::write(
13588 &f.reply,
13589 "TITLE: feat(own): add the own file\nAdds the committed own file.",
13590 )
13591 .unwrap();
13592 let retried = posted(&mut f).await;
13593 assert!(retried.body.contains("Adds the committed own file."));
13594 assert_eq!(
13595 f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
13596 "日本語の修正報告"
13597 );
13598 }
13599 }
13600
13601 #[tokio::test]
13602 async fn same_head_verification_and_findings_invalidate_public_prose() {
13603 let mut f = summary_fixture();
13604 posted(&mut f).await;
13605 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13606 let original = f
13607 .runner
13608 .state
13609 .change_summary
13610 .as_ref()
13611 .unwrap()
13612 .evidence
13613 .clone();
13614 ok_gate(&mut f.runner.state, Some(&head));
13615 std::fs::write(
13616 &f.reply,
13617 "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
13618 )
13619 .unwrap();
13620 let verified = posted(&mut f).await;
13621 assert!(verified.body.contains("now verified"));
13622 assert_ne!(
13623 f.runner.state.change_summary.as_ref().unwrap().evidence,
13624 original
13625 );
13626 let mut round = round_with_notes(1, Some("内部報告"));
13627 round.reviews.push(ReviewRecord {
13628 attempts: 0,
13629 reviewer: 1,
13630 agent: "alpha".into(),
13631 summary: "内部レビュー".into(),
13632 findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
13633 vote: None,
13634 failed: None,
13635 duration_ms: 0,
13636 });
13637 round
13638 .fix
13639 .as_mut()
13640 .unwrap()
13641 .rejected
13642 .push(crate::verdict::Rejection {
13643 id: "R1-1-1".into(),
13644 why: "呼び出し元から到達できない".into(),
13645 });
13646 f.runner.state.reviews = vec![round];
13647 let omitted = posted(&mut f).await;
13649 assert!(omitted.body.contains("No description"));
13650 assert!(omitted.body.contains("R1-1-1"));
13651 assert!(!omitted.body.contains("到達"));
13652 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();
13653 let described = posted(&mut f).await;
13654 assert!(described.body.contains("unreachable from callers"));
13655 assert!(described.body.contains("R1-1-1"));
13656 assert!(!described.body.contains("到達"));
13657 let evidence = f
13658 .runner
13659 .state
13660 .change_summary
13661 .as_ref()
13662 .unwrap()
13663 .evidence
13664 .clone();
13665 f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
13666 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
13667 assert!(posted(&mut f).await.body.contains("rationale updated"));
13668 assert_ne!(
13669 f.runner.state.change_summary.as_ref().unwrap().evidence,
13670 evidence
13671 );
13672 f.runner
13674 .state
13675 .change_summary
13676 .as_mut()
13677 .unwrap()
13678 .evidence
13679 .clear();
13680 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
13681 assert!(posted(&mut f).await.body.contains("current specification"));
13682 }
13683
13684 #[tokio::test]
13685 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
13686 let mut f = summary_fixture();
13687 let m = posted(&mut f).await;
13688 assert_eq!(m.title, "feat(own): add the own file");
13689 assert!(
13690 m.body
13691 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
13692 "{}",
13693 m.body
13694 );
13695 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
13696 assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
13697 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13698 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13699 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
13700 let kept = f.runner.state.change_summary.as_ref().unwrap();
13701 assert_eq!(
13702 (kept.head.as_str(), kept.base.as_str()),
13703 (head.as_str(), base.as_str())
13704 );
13705 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
13706 }
13707
13708 #[tokio::test]
13709 async fn review_only_summary_receives_the_configured_github_language() {
13710 let mut f = summary_fixture();
13711 f.runner.state.config.graph.github_language = "ja".into();
13712 let captured = f.repo.join("summary-prompt.txt");
13713 let author = &mut f.runner.state.config.agents[0];
13714 author.command = vec![
13715 "sh".into(),
13716 "-c".into(),
13717 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
13718 ];
13719 author.env.insert(
13720 "PROMPT_FILE".into(),
13721 captured.to_string_lossy().into_owned(),
13722 );
13723 std::fs::write(
13724 &f.reply,
13725 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
13726 )
13727 .unwrap();
13728 let m = posted(&mut f).await;
13729 let prompt = std::fs::read_to_string(captured).unwrap();
13730 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
13731 assert!(!prompt.contains("in English"));
13732 assert_eq!(m.title, "feat(own): ファイルを追加");
13733 assert!(m.body.contains("own.txt を追加します。"));
13734 }
13735
13736 #[tokio::test]
13737 async fn review_fixes_come_after_the_whole_change_summary() {
13738 let mut f = summary_fixture();
13739 f.runner.state.reviews = vec![
13740 round_with_notes(1, Some("handled the PATH case")),
13741 round_with_notes(2, None),
13742 ];
13743 let m = posted(&mut f).await;
13744 let summary = m.body.find("## Summary").expect("summary");
13745 let fixes = m.body.find("## Review rounds").expect("review rounds");
13746 assert!(summary < fixes, "{}", m.body);
13747 assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
13748 assert!(m.body.contains("## Final verification"), "{}", m.body);
13749 }
13750
13751 #[tokio::test]
13752 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
13753 let mut f = summary_fixture();
13754 let first = posted(&mut f).await;
13755 let first_head = f.runner.state.change_summary.clone().unwrap().head;
13756 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
13758 let again = posted(&mut f).await;
13759 assert_eq!((again.title, again.body), (first.title, first.body));
13760
13761 let g = |args: &[&str]| {
13762 let out = std::process::Command::new("git")
13763 .args(args)
13764 .current_dir(&f.repo)
13765 .quiet()
13766 .output()
13767 .expect("spawn git");
13768 assert!(out.status.success(), "git {args:?}");
13769 };
13770 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
13771 g(&["add", "-A"]);
13772 g(&["commit", "-q", "-m", "fix(own): handle more"]);
13773 let second = posted(&mut f).await;
13774 let kept = f.runner.state.change_summary.clone().unwrap();
13775 assert_ne!(kept.head, first_head);
13776 assert!(second.body.contains("Not read yet."), "{}", second.body);
13777 assert!(!second.body.contains("so the first summary is read"));
13778 assert_eq!(second.title, "feat(own): other");
13779 }
13780
13781 #[tokio::test]
13782 async fn an_unavailable_summary_is_stated_and_never_invented() {
13783 let mut f = summary_fixture();
13784 std::fs::write(&f.reply, "no title line here").unwrap();
13785 let m = posted(&mut f).await;
13786 assert!(
13787 m.body
13788 .contains("No description of what this change does could be generated"),
13789 "{}",
13790 m.body
13791 );
13792 assert!(m.body.contains("internal reports remain in local records"));
13793 assert!(f.runner.state.change_summary.is_none());
13794 assert!(!m.body.contains("## Commits under review"), "{}", m.body);
13795 let winner = f.runner.state.candidates[0].clone();
13797 let preview = f
13798 .runner
13799 .guarded_pr_message(&winner, None, false)
13800 .await
13801 .unwrap()
13802 .unwrap();
13803 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
13804 }
13805
13806 #[test]
13807 fn pr_message_review_single_commit_uses_its_subject() {
13808 let state = review_state(&["feat(nats): per-role user"]);
13809 let m = pr_message(&state, 'A');
13810 assert_eq!(m.title, "feat(nats): per-role user");
13811 assert!(!m.body.contains("Review the work already"), "{}", m.body);
13812 assert!(m.body.contains("## Commits under review"), "{}", m.body);
13813 }
13814
13815 #[test]
13816 fn pr_message_review_multi_commit_takes_the_oldest() {
13817 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
13818 let m = pr_message(&state, 'A');
13819 assert_eq!(m.title, "feat: the change");
13820 for s in ["feat: the change", "fix: typo", "fix: again"] {
13821 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
13822 }
13823 }
13824
13825 #[test]
13826 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
13827 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
13828 let mut state = review_state(&[first, "fix: later fixup"]);
13829 state.candidates[0].branch = "機能/ログイン".to_owned();
13830 let m = pr_message(&state, 'A');
13831 assert!(
13832 m.title.starts_with("chore: land candidate A of run"),
13833 "{}",
13834 m.title
13835 );
13836 }
13837 }
13838
13839 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
13840 BranchFacts {
13841 commits: commits
13842 .iter()
13843 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
13844 .collect(),
13845 stat: stat.to_owned(),
13846 }
13847 }
13848
13849 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
13850 let mut r = review_round(true, 0, 1, 1, true, true);
13851 r.round = round;
13852 r.fix = notes.map(|n| FixRecord {
13853 agent: "fixer".to_owned(),
13854 addressed: Vec::new(),
13855 rejected: Vec::new(),
13856 notes: n.to_owned(),
13857 committed: true,
13858 failed: None,
13859 duration_ms: 0,
13860 continuation: None,
13861 });
13862 r
13863 }
13864
13865 #[test]
13866 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
13867 let state = review_state(&["ignored"]);
13868 let f = facts(
13869 &[
13870 (
13871 "fix(login): resolve PATH on macOS",
13872 "Login shells skip rc files.",
13873 ),
13874 ("fix: address review", ""),
13875 ],
13876 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
13877 );
13878 let m = pr_message_with(&state, 'A', Some(&f));
13879 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
13880 assert!(
13881 m.body
13882 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
13883 "{}",
13884 m.body
13885 );
13886 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
13887 assert!(m.body.contains("## Diff stat"), "{}", m.body);
13888 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
13889 for banned in [
13890 "Review the work already",
13891 "no task statement",
13892 "Original task",
13893 ] {
13894 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
13895 }
13896 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
13897 }
13898
13899 #[test]
13900 fn pr_message_review_truncates_a_huge_first_commit_body() {
13901 let state = review_state(&["ignored"]);
13902 let f = facts(
13903 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
13904 "s",
13905 );
13906 let m = pr_message_with(&state, 'A', Some(&f));
13907 assert!(m.body.len() < 30_000, "{}", m.body.len());
13908 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
13909 assert!(
13910 m.body.contains("- ... 1 more commit(s)"),
13911 "{}",
13912 m.body.len()
13913 );
13914 assert!(m.body.ends_with("magi:candidate-a\n"));
13915 }
13916
13917 #[test]
13918 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
13919 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
13920 assert_eq!(m.title, "feat: x");
13921 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
13922 assert!(!m.body.contains("Diff stat"), "{}", m.body);
13923 assert!(!m.body.contains("no task statement"), "{}", m.body);
13924 }
13925
13926 #[test]
13927 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
13928 let mut state = review_state(&["日本語の件名"]);
13929 state.candidates[0].branch = "fix/macos-login-path".to_owned();
13930 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
13931 state.candidates[0].branch = "機能/ログイン".to_owned();
13932 assert!(
13933 pr_message(&state, 'A')
13934 .title
13935 .starts_with("chore: land candidate A")
13936 );
13937 }
13938
13939 #[test]
13940 fn pr_message_review_fixes_survive_a_clean_final_round() {
13941 let mut state = review_state(&["feat: x"]);
13942 state.reviews = vec![
13943 round_with_notes(1, Some("handled the PATH case")),
13944 round_with_notes(2, None),
13945 ];
13946 let body = pr_message(&state, 'A').body;
13947 assert!(
13948 body.contains("## Review rounds") && !body.contains("handled the PATH case"),
13949 "{body}"
13950 );
13951 assert!(body.contains("### Round 1"), "{body}");
13952
13953 state.reviews = vec![
13954 round_with_notes(1, Some("first fix")),
13955 round_with_notes(2, Some("")),
13956 round_with_notes(3, Some("second fix")),
13957 round_with_notes(4, None),
13958 ];
13959 let body = pr_message(&state, 'A').body;
13960 assert!(
13961 body.contains("### Round 1") && !body.contains("first fix"),
13962 "{body}"
13963 );
13964 assert!(
13965 body.contains("### Round 3") && !body.contains("second fix"),
13966 "{body}"
13967 );
13968 assert!(body.contains("### Round 2"), "{body}");
13969 }
13970
13971 const FINAL: &str = "abcdef1234567890";
13972
13973 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
13974 let mut r = round_with_notes(round, None);
13975 r.verified_head = verified.map(str::to_owned);
13976 r
13977 }
13978
13979 fn ok_gate(state: &mut RunState, head: Option<&str>) {
13980 state.gate = vec![CommandOutcome {
13981 command: "gate".to_owned(),
13982 code: Some(0),
13983 output_tail: String::new(),
13984 duration_ms: 0,
13985 resource_blocked: false,
13986 }];
13987 state.gate_ran = true;
13988 state.gate_head = head.map(str::to_owned);
13989 }
13990
13991 fn body_at(state: &RunState) -> String {
13992 pr_message_raw(
13993 state,
13994 'A',
13995 None,
13996 crate::pr_outcome::FinalHead::At(FINAL),
13997 ChangeNote::NotAsked,
13998 )
13999 .body
14000 }
14001
14002 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14003
14004 #[test]
14005 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14006 let mut state = review_state(&["feat: x"]);
14007 state.reviews = vec![
14008 round_with_notes(1, Some(SANDBOXED)),
14009 passing_round(2, Some(FINAL)),
14010 ];
14011 ok_gate(&mut state, Some(FINAL));
14012 let body = body_at(&state);
14013 assert!(!body.contains("Handled the PATH case."), "{body}");
14014 assert!(!body.contains("could not commit"), "{body}");
14015 assert!(!body.contains("could not run"), "{body}");
14016 assert!(
14017 body.contains("The fix is committed on the branch"),
14018 "{body}"
14019 );
14020 assert!(!body.contains("sandbox"), "{body}");
14021 assert!(
14022 body.contains("Tests (`verify.e2e`): passed on the final commit"),
14023 "{body}"
14024 );
14025 assert!(
14026 body.contains("Gate (`verify.gate`): passed on the final commit"),
14027 "{body}"
14028 );
14029 assert!(
14031 state.reviews[0]
14032 .fix
14033 .as_ref()
14034 .unwrap()
14035 .notes
14036 .contains("could not commit")
14037 );
14038 }
14039
14040 #[test]
14041 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14042 let mut state = review_state(&["feat: x"]);
14043 let mut r1 = round_with_notes(1, Some(SANDBOXED));
14044 r1.fix.as_mut().unwrap().committed = false;
14045 let mut r2 = passing_round(2, Some(FINAL));
14046 r2.e2e[0].code = Some(1);
14047 state.reviews = vec![r1, r2];
14048 let body = body_at(&state);
14049 assert!(!body.contains("could not commit"), "{body}");
14050 assert!(!body.contains("could not run"), "{body}");
14051 assert!(body.contains("This round produced no commit"), "{body}");
14052 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14053 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14054 assert!(!body.contains("passed"), "{body}");
14055 }
14056
14057 #[test]
14058 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14059 let mut state = review_state(&["feat: x"]);
14060 state.reviews = vec![
14061 round_with_notes(1, Some(SANDBOXED)),
14062 passing_round(2, Some("0123456789abcdef")),
14063 ];
14064 ok_gate(&mut state, Some("0123456789abcdef"));
14065 let body = body_at(&state);
14066 assert!(!body.contains("HTTP tests could not run"), "{body}");
14067 assert!(!body.contains("could not commit"), "{body}");
14069 assert!(
14070 body.contains("passed on an earlier commit (`0123456`)"),
14071 "{body}"
14072 );
14073 assert!(!body.contains("passed on the final commit"), "{body}");
14074
14075 ok_gate(&mut state, None);
14077 let body = body_at(&state);
14078 assert!(
14079 body.contains("passed on a commit that was not recorded"),
14080 "{body}"
14081 );
14082 }
14083
14084 #[test]
14085 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14086 let state = state_with_summary(
14087 "add retries to the client",
14088 "TITLE: feat: retries\n\nDid it.",
14089 );
14090 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14091 assert_eq!(m.title, "feat: retries");
14092 assert!(
14093 m.body.contains("<summary>Original task</summary>"),
14094 "{}",
14095 m.body
14096 );
14097 assert!(!m.body.contains("Commits under review"), "{}", m.body);
14098 assert!(
14099 m.body
14100 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14101 "{}",
14102 m.body
14103 );
14104 }
14105
14106 #[test]
14107 fn pr_message_review_bounds_a_long_english_subject() {
14108 let long = format!("feat: {}", "word ".repeat(100));
14109 let m = pr_message(&review_state(&[&long]), 'A');
14110 assert!(m.title.starts_with("feat: word"), "{}", m.title);
14111 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14112 }
14113
14114 #[test]
14115 fn pr_message_implementation_run_is_unchanged_by_review_support() {
14116 let state = state_with_summary("add retries\n\ndetails", "- did some things");
14117 let m = pr_message(&state, 'A');
14118 assert_eq!(m.title, "add retries");
14119 assert!(m.body.contains("<summary>Original task</summary>"));
14120 assert!(!m.body.contains("Commits under review"));
14121 assert_eq!(landing_subject_source(&state), state.instruction);
14122 }
14123
14124 #[test]
14125 fn review_run_squash_subject_is_the_change_not_the_prompt() {
14126 let state = review_state(&["feat: the change", "fix: typo"]);
14127 let source = landing_subject_source(&state);
14128 assert_eq!(land::merge_subject("", &source), "feat: the change");
14129 assert_eq!(
14130 land::merge_subject("magi: candidate A (uncommitted work)", &source),
14131 "feat: the change"
14132 );
14133 assert_eq!(
14135 land::merge_subject("feat: renamed by hand", &source),
14136 "feat: renamed by hand"
14137 );
14138 let blank = review_state(&["日本語"]);
14139 assert!(
14140 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14141 );
14142 }
14143
14144 #[test]
14145 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
14146 let state = review_state(&["feat: the change"]);
14147 let source = landing_subject_source(&state);
14148 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
14149 assert_eq!(
14150 land::merge_subject(landing_title(&state, old), &source),
14151 "feat: the change"
14152 );
14153 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
14154 let task = state_with_summary("add retries", "");
14155 assert_eq!(landing_title(&task, old), old);
14156 }
14157
14158 #[test]
14159 fn pr_message_describes_the_change_not_the_task() {
14160 let state = state_with_summary(
14161 "今回やってほしいこと: results projector を直す",
14162 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
14163 );
14164 let m = pr_message(&state, 'A');
14165 assert_eq!(m.title, "fix(web): batch the runs list reads");
14166 assert!(
14167 m.body.starts_with("## Summary\n\n- reads run.json once"),
14168 "{}",
14169 m.body
14170 );
14171 assert!(!m.body.contains("TITLE:"), "{}", m.body);
14172 let task_at = m.body.find("今回やってほしいこと").unwrap();
14173 let details_at = m.body.find("<details>").unwrap();
14174 assert!(
14175 details_at < task_at,
14176 "the task lives inside <details>: {}",
14177 m.body
14178 );
14179 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
14180 assert!(m.body.contains("magi:candidate-a"));
14181 }
14182
14183 #[test]
14184 fn pr_message_falls_back_to_the_task_without_a_title_line() {
14185 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
14186 let m = pr_message(&state, 'A');
14187 assert_eq!(m.title, "add retries");
14188 assert!(
14189 m.body.contains("## Summary\n\n- did some things"),
14190 "{}",
14191 m.body
14192 );
14193
14194 let none = RunState::new(
14195 PathBuf::from("/repo"),
14196 "main".to_owned(),
14197 "abc1234".to_owned(),
14198 "add retries".to_owned(),
14199 Config::default(),
14200 );
14201 let m = pr_message(&none, 'A');
14202 assert_eq!(m.title, "add retries");
14203 assert!(!m.body.contains("## Summary"), "{}", m.body);
14204 }
14205
14206 #[test]
14207 fn pr_message_refuses_the_candidate_commit_subject() {
14208 for bad in [
14209 "TITLE: magi: candidate A (uncommitted work)",
14210 "TITLE: chore: stuff (uncommitted work)",
14211 "TITLE: ",
14212 ] {
14213 let state = state_with_summary("add retries", bad);
14214 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
14215 }
14216 }
14217
14218 #[test]
14219 fn pr_message_bounds_a_very_long_task_and_title() {
14220 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
14221 let state = state_with_summary(&long, "- nothing");
14222 let m = pr_message(&state, 'A');
14223 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14224 assert!(!m.title.contains('\n'));
14225
14226 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
14227 let m = pr_message(&state, 'A');
14228 assert!(m.title.starts_with("feat: "));
14229 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14230 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
14231 }
14232
14233 fn long_title_of(instruction: &str) -> String {
14234 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
14235 }
14236
14237 #[test]
14238 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
14239 let first = "Make the landing path keep a readable title for long tasks";
14240 let line = format!(
14241 "{first}. {}",
14242 "And then keep going with more words ".repeat(20)
14243 );
14244 let t = long_title_of(&line);
14245 assert_eq!(t, first);
14246 assert!(!t.starts_with("chore: land"));
14247 }
14248
14249 #[test]
14250 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
14251 let line = "word ".repeat(200);
14252 let t = long_title_of(&line);
14253 assert!(t.ends_with("word..."), "{t}");
14254 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
14255 }
14256
14257 #[test]
14258 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
14259 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
14260 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
14261 }
14262 }
14263
14264 #[test]
14265 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
14266 let mut state = state_with_summary("再試行を追加する", "- nothing");
14267 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
14268 state.config.graph.github_language = "ja".into();
14269 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
14270 }
14271
14272 #[test]
14273 fn pr_message_title_limit_is_exact() {
14274 let at = "a".repeat(PR_TITLE_MAX);
14275 assert_eq!(long_title_of(&at), at);
14276 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
14277 assert!(over.ends_with("..."), "{over}");
14278 assert_eq!(over.chars().count(), PR_TITLE_MAX);
14279 }
14280
14281 #[test]
14282 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
14283 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
14284 let t = long_title_of(&line);
14285 assert!(t.ends_with("..."), "{t}");
14286 assert!(t.is_ascii(), "{t}");
14287 }
14288
14289 #[test]
14290 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
14291 let line = format!(
14292 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
14293 "plus more filler words ".repeat(20)
14294 );
14295 let t = long_title_of(&line);
14296 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
14297 }
14298
14299 #[test]
14300 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
14301 let line = format!("\n\n# {}", "title words ".repeat(40));
14302 let state = state_with_summary(&line, "- nothing");
14303 let m = pr_message(&state, 'A');
14304 assert!(m.title.starts_with("title words"), "{}", m.title);
14305 assert_eq!(
14306 land::merge_subject(&m.title, &landing_subject_source(&state)),
14307 m.title
14308 );
14309 assert_eq!(
14311 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
14312 "feat: renamed by hand"
14313 );
14314 }
14315
14316 #[tokio::test]
14317 async fn github_text_rewrite_is_bounded_and_falls_back() {
14318 crate::run::pin_test_home();
14319 for (reply, accepted) in [
14320 (
14321 "TITLE: fix: retries\nAdd retries for failed requests.",
14322 true,
14323 ),
14324 (
14325 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
14326 false,
14327 ),
14328 (
14329 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
14330 true,
14331 ),
14332 (
14333 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
14334 true,
14335 ),
14336 (
14337 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
14338 true,
14339 ),
14340 (
14341 "TITLE: fix: retries\n日本語の説明です。 token=secret",
14342 false,
14343 ),
14344 ("TITLE: fix: retries\n", false),
14345 ] {
14346 let dir = tempfile::tempdir().unwrap();
14347 let mut runner = runner_at(RunStatus::Gating);
14348 runner.state = state_with_summary(
14349 "add retries",
14350 "TITLE: fix: retries\n日本語の説明を書きます。",
14351 );
14352 runner.state.repo = dir.path().to_owned();
14353 runner.state.candidates[0].worktree = dir.path().to_owned();
14354 let mut author = spec("alpha");
14355 author.command = vec![
14356 "sh".into(),
14357 "-c".into(),
14358 "printf '%s' \"$REWRITE_REPLY\"".into(),
14359 ];
14360 author.env.insert("REWRITE_REPLY".into(), reply.into());
14361 runner.state.config.agents = vec![author];
14362 seed_public_summary(&mut runner);
14363 let winner = runner.state.candidates[0].clone();
14364 let first = runner
14365 .guarded_pr_message(&winner, None, true)
14366 .await
14367 .unwrap();
14368 let message = if accepted {
14369 first.expect("a passing rewrite never asks")
14370 } else {
14371 assert!(first.is_none());
14373 assert!(runner.state.parked);
14374 let store = ask::Questions::open();
14375 let asked: Vec<_> = store
14376 .list()
14377 .into_iter()
14378 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14379 .collect();
14380 assert_eq!(asked.len(), 1);
14381 assert!(!asked[0].detail.contains("secret"));
14382 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
14383 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
14384 assert_eq!(
14385 asked[0]
14386 .choices
14387 .contains(&crate::github_text::USE_REWRITE.to_owned()),
14388 rewrite.is_some()
14389 );
14390 store
14393 .update(&asked[0].id, |q| {
14394 q.abandon("test");
14395 Ok(())
14396 })
14397 .unwrap();
14398 let again = runner
14399 .guarded_pr_message(&winner, None, true)
14400 .await
14401 .unwrap()
14402 .expect("an abandoned question falls back");
14403 let n = store
14404 .list()
14405 .into_iter()
14406 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14407 .count();
14408 assert_eq!(n, 1, "asked once per rejected text");
14409 again
14410 };
14411 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
14412 assert!(!message.body.contains("secret"));
14413 assert!(!message.body.contains("prefix"));
14414 assert!(!message.title.contains("secret"));
14415 assert_eq!(
14416 message.body.contains("Add retries for failed requests."),
14417 accepted
14418 );
14419 assert_eq!(
14420 runner.state.seats["github-text-rewrite"].turns, 1,
14421 "only one rewrite invocation"
14422 );
14423 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
14424 if accepted {
14425 assert!(runner.state.github_text.is_none());
14426 assert!(!runner.state.parked);
14427 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
14428 assert!(message.body.contains("## Final verification"));
14429 assert!(message.body.contains("Evidence: final diff"));
14430 assert!(
14431 message
14432 .body
14433 .contains(&format!("magi:run/{}", runner.state.id))
14434 );
14435 }
14436 if !accepted {
14437 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14438 assert!(
14439 message
14440 .body
14441 .contains(&format!("magi:run/{}", runner.state.id))
14442 );
14443 }
14444 }
14445 }
14446
14447 fn scripted(id: &str, reply: &str) -> AgentSpec {
14448 let mut a = spec(id);
14449 a.command = vec![
14450 "sh".into(),
14451 "-c".into(),
14452 "printf '%s' \"$REWRITE_REPLY\"".into(),
14453 ];
14454 a.env.insert("REWRITE_REPLY".into(), reply.into());
14455 a
14456 }
14457
14458 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
14459 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
14460
14461 fn seed_public_summary(runner: &mut Runner) {
14463 let repo = &runner.state.repo;
14464 let g = |args: &[&str]| {
14465 let out = std::process::Command::new("git")
14466 .args(args)
14467 .current_dir(repo)
14468 .quiet()
14469 .output()
14470 .unwrap();
14471 assert!(out.status.success(), "git {args:?}");
14472 String::from_utf8_lossy(&out.stdout).trim().to_owned()
14473 };
14474 g(&["init", "-q", "-b", "main"]);
14475 g(&[
14476 "-c",
14477 "user.name=Test",
14478 "-c",
14479 "user.email=test@example.invalid",
14480 "commit",
14481 "-q",
14482 "--allow-empty",
14483 "-m",
14484 "feat: fixture",
14485 ]);
14486 let head = g(&["rev-parse", "HEAD"]);
14487 g(&["update-ref", "refs/remotes/origin/main", &head]);
14488 let evidence = crate::pr_outcome::summary_evidence(
14489 &runner.state,
14490 crate::pr_outcome::FinalHead::At(&head),
14491 );
14492 let generated = &runner.state.candidates[0].summary;
14493 runner.state.change_summary = Some(crate::run::ChangeSummary {
14494 base: head.clone(),
14495 head,
14496 evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
14497 title: summary_title(generated).unwrap(),
14498 body: summary_without_title(generated),
14499 });
14500 }
14501
14502 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
14503 let mut runner = runner_at(RunStatus::Gating);
14504 runner.state = state_with_summary("add retries", JA_SUMMARY);
14505 if review_only {
14506 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14507 }
14508 runner.state.repo = dir.to_owned();
14509 runner.state.candidates[0].worktree = dir.to_owned();
14510 runner.state.config.agents = agents;
14511 seed_public_summary(&mut runner);
14512 runner
14513 }
14514
14515 fn gate_questions(runner: &Runner) -> usize {
14516 ask::Questions::open()
14517 .list()
14518 .into_iter()
14519 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14520 .count()
14521 }
14522
14523 #[tokio::test]
14524 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
14525 crate::run::pin_test_home();
14526 let dir = tempfile::tempdir().unwrap();
14527 let mut runner = rewrite_runner(
14528 dir.path(),
14529 true,
14530 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14531 );
14532 runner.state.config.roles.language_judge =
14533 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
14534 let winner = runner.state.candidates[0].clone();
14535 let msg = runner
14536 .guarded_pr_message(&winner, None, true)
14537 .await
14538 .unwrap()
14539 .expect("an English rewrite posts without asking");
14540 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
14541 assert!(msg.body.contains("<details>kept</details>"));
14542 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
14543 assert_eq!(gate_questions(&runner), 0);
14544 assert!(runner.state.github_text.is_none());
14545 assert!(!runner.state.seats.contains_key("impl-A"));
14546 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14547 }
14548
14549 #[tokio::test]
14550 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
14551 crate::run::pin_test_home();
14552 let dir = tempfile::tempdir().unwrap();
14553 let mut runner = rewrite_runner(
14554 dir.path(),
14555 false,
14556 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14557 );
14558 runner.state.config.roles.language_judge =
14559 Some(crate::config::AgentChoice::One("beta".into()));
14560 runner.state.seats.insert(
14561 "impl-A".into(),
14562 SeatState::new("impl-A", "alpha", runner.state.seed),
14563 );
14564 let winner = runner.state.candidates[0].clone();
14565 let msg = runner
14566 .guarded_pr_message(&winner, None, true)
14567 .await
14568 .unwrap()
14569 .expect("rewritten");
14570 assert!(msg.body.contains("<details>kept</details>"));
14571 assert_eq!(runner.state.seats["impl-A"].turns, 0);
14572 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14573 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
14574 }
14575
14576 #[tokio::test]
14577 async fn rewrite_reuses_a_resumable_implementer_session() {
14578 crate::run::pin_test_home();
14579 let dir = tempfile::tempdir().unwrap();
14580 let mut runner = rewrite_runner(
14581 dir.path(),
14582 false,
14583 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
14584 );
14585 runner.state.config.roles.language_judge =
14586 Some(crate::config::AgentChoice::One("beta".into()));
14587 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
14588 seat.turns = 1;
14589 runner.state.seats.insert("impl-A".into(), seat);
14590 let winner = runner.state.candidates[0].clone();
14591 runner
14592 .guarded_pr_message(&winner, None, true)
14593 .await
14594 .unwrap()
14595 .expect("rewritten");
14596 assert_eq!(runner.state.seats["impl-A"].turns, 2);
14597 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
14598 }
14599
14600 #[tokio::test]
14601 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
14602 crate::run::pin_test_home();
14603 let dir = tempfile::tempdir().unwrap();
14604 let mut broken = spec("bad");
14605 broken.command = vec!["false".into()];
14606 let mut runner = rewrite_runner(
14607 dir.path(),
14608 true,
14609 vec![
14610 broken,
14611 scripted("noformat", "Add retries."),
14612 scripted("good", EN_REWRITE),
14613 ],
14614 );
14615 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
14616 "bad".into(),
14617 "noformat".into(),
14618 "good".into(),
14619 ]));
14620 let winner = runner.state.candidates[0].clone();
14621 let msg = runner
14622 .guarded_pr_message(&winner, None, true)
14623 .await
14624 .unwrap()
14625 .expect("the third agent rewrites");
14626 assert!(msg.body.contains("`src/net.rs`"));
14627 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
14628 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
14629 }
14630
14631 #[tokio::test]
14632 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
14633 crate::run::pin_test_home();
14634 for (agents, role, saved) in [
14636 (
14637 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
14638 Some("beta"),
14639 true,
14640 ),
14641 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
14642 (Vec::new(), None, false),
14643 ] {
14644 let dir = tempfile::tempdir().unwrap();
14645 let mut runner = rewrite_runner(dir.path(), true, agents);
14646 runner.state.config.roles.language_judge =
14647 role.map(|r| crate::config::AgentChoice::One(r.into()));
14648 let winner = runner.state.candidates[0].clone();
14649 let first = runner
14650 .guarded_pr_message(&winner, None, true)
14651 .await
14652 .unwrap();
14653 assert!(first.is_none(), "withheld text parks the run");
14654 assert!(runner.state.parked);
14655 assert_eq!(gate_questions(&runner), 1);
14656 let g = runner.state.github_text.as_ref().unwrap();
14657 assert_eq!(g.rewrite.is_some(), saved);
14658 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
14659 }
14660 }
14661
14662 #[tokio::test]
14663 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
14664 crate::run::pin_test_home();
14665 let dir = tempfile::tempdir().unwrap();
14666 let mut runner = runner_at(RunStatus::Gating);
14667 runner.state =
14668 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
14669 runner.state.repo = dir.path().to_owned();
14670 runner.state.candidates[0].worktree = dir.path().to_owned();
14671 let mut author = spec("alpha");
14672 author.command = vec![
14673 "sh".into(),
14674 "-c".into(),
14675 "printf '%s' \"$REWRITE_REPLY\"".into(),
14676 ];
14677 author.env.insert(
14678 "REWRITE_REPLY".into(),
14679 format!(
14680 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
14681 "English sentence. ".repeat(600)
14682 ),
14683 );
14684 runner.state.config.agents = vec![author];
14685 seed_public_summary(&mut runner);
14686 let winner = runner.state.candidates[0].clone();
14687 assert!(
14688 runner
14689 .guarded_pr_message(&winner, None, true)
14690 .await
14691 .unwrap()
14692 .is_none()
14693 );
14694 let record = runner.state.github_text.clone().unwrap();
14695 let rewrite = record.rewrite.clone().unwrap();
14696 assert_eq!(rewrite.failed, ["body-language"]);
14697 assert!(
14698 rewrite
14699 .body
14700 .ends_with(&format!("magi:run/{}", runner.state.id))
14701 );
14702 let store = ask::Questions::open();
14703 let q = store.get(record.question.as_ref().unwrap()).unwrap();
14704 assert!(
14705 q.choices
14706 .contains(&crate::github_text::USE_REWRITE.to_owned())
14707 );
14708 for label in [
14709 "ORIGINAL",
14710 "FALLBACK",
14711 "REWRITE",
14712 "Rules still failed: body-language",
14713 "truncated",
14714 ] {
14715 assert!(q.detail.contains(label), "{label}");
14716 }
14717 let artifact = std::fs::read_to_string(crate::run::artifact_path(
14718 &runner.state,
14719 &crate::github_text::artifact_name(&record.fingerprint),
14720 ))
14721 .unwrap();
14722 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
14723 runner.state =
14725 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
14726 store
14727 .update(&q.id, |q| {
14728 q.say("まだ日本語")?;
14729 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
14730 })
14731 .unwrap();
14732 assert!(
14733 runner
14734 .guarded_pr_message(&winner, None, true)
14735 .await
14736 .unwrap()
14737 .is_none()
14738 );
14739 let retry = runner.state.github_text.as_ref().unwrap();
14740 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
14741 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
14742 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
14743 assert!(
14744 q.posting_comparison
14745 .as_ref()
14746 .unwrap()
14747 .matches_detail(&q.detail)
14748 );
14749 store
14750 .update(&q.id, |q| {
14751 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14752 })
14753 .unwrap();
14754 let message = runner
14755 .guarded_pr_message(&winner, None, true)
14756 .await
14757 .unwrap()
14758 .unwrap();
14759 assert_eq!(message.title, rewrite.title);
14760 assert_eq!(message.body, rewrite.body);
14761 assert_eq!(
14762 runner.state.github_text.as_ref().unwrap().choice,
14763 Some(GithubTextChoice::Rewrite)
14764 );
14765 let again = runner
14766 .guarded_pr_message(&winner, None, true)
14767 .await
14768 .unwrap()
14769 .unwrap();
14770 assert_eq!(again.title, message.title);
14771 assert_eq!(again.body, message.body);
14772 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14773 assert!(
14774 !runner
14775 .state
14776 .events
14777 .iter()
14778 .any(|e| e.message.contains(&rewrite.title)
14779 || e.message.contains("English sentence."))
14780 );
14781 }
14782
14783 #[tokio::test]
14784 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
14785 crate::run::pin_test_home();
14786 let dir = tempfile::tempdir().unwrap();
14787 let mut runner = runner_at(RunStatus::Gating);
14788 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
14789 runner.state.repo = dir.path().to_owned();
14790 runner.state.candidates[0].worktree = dir.path().to_owned();
14791 seed_public_summary(&mut runner);
14792 let winner = runner.state.candidates[0].clone();
14793 assert!(
14794 runner
14795 .guarded_pr_message(&winner, None, true)
14796 .await
14797 .unwrap()
14798 .is_none()
14799 );
14800 let record = runner.state.github_text.as_ref().unwrap();
14801 assert!(record.rewrite.is_none());
14802 let mut old = serde_json::to_value(record).unwrap();
14804 old.as_object_mut().unwrap().remove("rewrite");
14805 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
14806 assert!(old.rewrite.is_none());
14807 let id = record.question.clone().unwrap();
14808 ask::Questions::open()
14809 .update(&id, |q| {
14810 q.choices.push(crate::github_text::USE_REWRITE.into());
14811 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14812 })
14813 .unwrap();
14814 let message = runner
14815 .guarded_pr_message(&winner, None, true)
14816 .await
14817 .unwrap()
14818 .unwrap();
14819 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14820 assert_eq!(
14821 runner.state.github_text.as_ref().unwrap().choice,
14822 Some(GithubTextChoice::Fallback)
14823 );
14824 }
14825
14826 #[tokio::test]
14827 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
14828 crate::run::pin_test_home();
14829 let dir = tempfile::tempdir().unwrap();
14830 let mut runner = runner_at(RunStatus::Gating);
14831 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
14832 runner.state.repo = dir.path().to_owned();
14833 runner.state.candidates[0].worktree = dir.path().to_owned();
14834 seed_public_summary(&mut runner);
14835 let winner = runner.state.candidates[0].clone();
14836 assert!(
14837 runner
14838 .guarded_pr_message(&winner, None, true)
14839 .await
14840 .unwrap()
14841 .is_none()
14842 );
14843 let store = ask::Questions::open();
14844 let find = |run: &str| {
14845 store
14846 .list()
14847 .into_iter()
14848 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
14849 .unwrap()
14850 };
14851 let run = runner.state.id.clone();
14852 let q = find(&run);
14853 let fp = runner
14854 .state
14855 .github_text
14856 .as_ref()
14857 .unwrap()
14858 .fingerprint
14859 .clone();
14860 let name = crate::github_text::artifact_name(&fp);
14861 let path = crate::run::artifact_path(&runner.state, &name);
14862 assert!(q.detail.contains(&path.display().to_string()));
14863 assert!(q.detail.contains("title-language: title"));
14864 let saved = std::fs::read_to_string(&path).expect("artifact written");
14865 assert!(saved.contains("日本語のタイトル"));
14866 assert!(q.cwd.is_none());
14867 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
14868 let say = |id: &str, text: &str, choice: &str| {
14870 store
14871 .update(id, |q| {
14872 q.thread.push(ask::Turn {
14873 who: ask::Who::Operator,
14874 body: text.to_owned(),
14875 at: jiff::Timestamp::now(),
14876 note: None,
14877 });
14878 q.answer(ask::Answer::Choice(choice.to_owned()))
14879 })
14880 .unwrap();
14881 };
14882 say(&q.id, "まだ日本語", "use my text");
14883 assert!(
14884 runner
14885 .guarded_pr_message(&winner, None, true)
14886 .await
14887 .unwrap()
14888 .is_none()
14889 );
14890 let second = find(&run);
14891 assert_ne!(second.id, q.id);
14892 assert!(second.detail.contains("did not pass"));
14893 assert!(second.detail.contains("日本語のタイトル"));
14896 assert!(!second.detail.contains("まだ日本語"));
14897 assert!(second.detail.contains(&path.display().to_string()));
14898 say(&second.id, "fix: retry failed requests", "use my text");
14899 let message = runner
14900 .guarded_pr_message(&winner, None, true)
14901 .await
14902 .unwrap()
14903 .expect("a vetted replacement is posted");
14904 assert_eq!(message.title, "fix: retry failed requests");
14905 assert_eq!(
14906 runner
14907 .state
14908 .github_text
14909 .as_ref()
14910 .unwrap()
14911 .chosen_title
14912 .as_deref(),
14913 Some("fix: retry failed requests")
14914 );
14915 assert!(runner.state.github_text.as_ref().unwrap().resolved);
14916 let again = runner
14918 .guarded_pr_message(&winner, None, true)
14919 .await
14920 .unwrap()
14921 .unwrap();
14922 assert_eq!(again.title, message.title);
14923 assert_eq!(
14924 store
14925 .list()
14926 .into_iter()
14927 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
14928 .count(),
14929 2
14930 );
14931 }
14932
14933 #[test]
14934 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
14935 let mut state = state_with_summary(
14939 "add retries",
14940 "TITLE: fix(web): batch reads\n- reads run.json once",
14941 );
14942 state.config.graph.language = "ja".to_owned();
14943 let m = pr_message(&state, 'A');
14944 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
14945 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
14946
14947 let task = "今回やってほしいこと: results projector を直す";
14950 let mut state = state_with_summary(task, "- no title line");
14951 state.config.graph.language = "ja".to_owned();
14952 let m = pr_message(&state, 'A');
14953 assert_eq!(
14954 m.title,
14955 format!("chore: land candidate A of run {}", state.id)
14956 );
14957 assert!(
14958 m.body.contains(&format!(
14959 "<summary>Original task</summary>\n\n{task}\n\n</details>"
14960 )),
14961 "{}",
14962 m.body
14963 );
14964 }
14965
14966 #[test]
14967 fn pr_message_scrubs_home_paths_and_addresses() {
14968 let state = state_with_summary(
14969 "fix it in /Users/someone/src/x",
14970 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
14971 );
14972 let m = pr_message(&state, 'A');
14973 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
14974 assert!(!m.body.contains(leak), "{}", m.body);
14975 }
14976 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
14977 }
14978
14979 #[test]
14980 fn pr_message_survives_a_task_that_closes_details() {
14981 let state = state_with_summary("a </details> b", "TITLE: fix: x");
14982 let m = pr_message(&state, 'A');
14983 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
14984 }
14985
14986 #[test]
14987 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
14988 let cmd = manual_merge_command(
14989 MergeStyle::Squash,
14990 Path::new("/repo"),
14991 "b",
14992 "fix: \"quoted\" $(x) `y`\n\nbody",
14993 );
14994 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
14995 }
14996
14997 #[test]
14998 fn manual_merge_command_matches_the_configured_style() {
14999 let repo = Path::new("/repo");
15000 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15001
15002 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15003 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15004
15005 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15006 assert_eq!(
15007 squash,
15008 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15009 \"Merge magi run 0832 (candidate A)\""
15010 );
15011
15012 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15013 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15014 }
15015
15016 #[test]
15017 fn a_nudge_gets_a_quarter_of_the_budget() {
15018 assert_eq!(retry_budget(secs(1200), true), secs(300));
15020 assert_eq!(retry_budget(secs(3600), true), secs(900));
15021 }
15022
15023 #[test]
15024 fn a_resent_prompt_keeps_the_whole_budget() {
15025 assert_eq!(retry_budget(secs(1200), false), secs(1200));
15028 assert_eq!(retry_budget(secs(60), false), secs(60));
15029 }
15030
15031 #[test]
15032 fn the_floor_never_exceeds_the_original_budget() {
15033 assert_eq!(retry_budget(secs(60), true), secs(60));
15037 assert_eq!(retry_budget(secs(480), true), secs(120));
15038 assert_eq!(retry_budget(secs(0), true), secs(0));
15039 }
15040
15041 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15042 agent::CommandEvidence {
15043 id: "item1".to_owned(),
15044 description: "cargo test".to_owned(),
15045 exit_code,
15046 result_summary: String::new(),
15047 source: "codex".to_owned(),
15048 }
15049 }
15050
15051 #[test]
15052 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15053 assert!(!has_unconfirmed_command(&[]));
15057 }
15058
15059 #[test]
15060 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15061 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15065 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15066 assert!(!has_unconfirmed_command(&[
15067 evidence(Some(0)),
15068 evidence(Some(101))
15069 ]));
15070 }
15071
15072 #[test]
15073 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15074 assert!(has_unconfirmed_command(&[
15075 evidence(Some(0)),
15076 evidence(None)
15077 ]));
15078 }
15079
15080 #[test]
15081 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15082 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15083 assert_eq!(
15084 verified_noop_claim(true, &[], text).as_deref(),
15085 Some("already fixed by b32cfc4, on main.")
15086 );
15087 }
15088
15089 #[test]
15090 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15091 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15094 assert!(verified_noop_claim(false, &[], text).is_none());
15095 }
15096
15097 #[test]
15098 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15099 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15100 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15101 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15103 }
15104
15105 #[test]
15106 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15107 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15108 }
15109
15110 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15113 runner.state.candidates = shape
15114 .iter()
15115 .enumerate()
15116 .map(|(i, &(empty, verified))| Candidate {
15117 index: i,
15118 label: (b'A' + i as u8) as char,
15119 agent: "sonnet".to_owned(),
15120 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15121 worktree: PathBuf::from(format!("/wt/{i}")),
15122 summary: String::new(),
15123 stat: String::new(),
15124 files: 0,
15125 commits: 0,
15126 empty,
15127 failed: None,
15128 verified_noop: verified.map(str::to_owned),
15129 duration_ms: 0,
15130 folded: false,
15131 })
15132 .collect();
15133 }
15134
15135 #[test]
15136 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15137 ask_test_home();
15138 let mut runner = runner_at(RunStatus::Implementing);
15139 set_candidates(
15140 &mut runner,
15141 &[
15142 (true, Some("already on main at b32cfc4")),
15143 (true, Some("same fix, see the existing test")),
15144 ],
15145 );
15146
15147 runner
15148 .after_implement()
15149 .expect("a verified no-op is not an error");
15150
15151 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
15152 }
15153
15154 #[test]
15155 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
15156 ask_test_home();
15157 let mut runner = runner_at(RunStatus::Implementing);
15158 set_candidates(
15162 &mut runner,
15163 &[(true, Some("already on main at b32cfc4")), (true, None)],
15164 );
15165
15166 let err = runner
15167 .after_implement()
15168 .expect_err("an unverified empty candidate must still fail the run");
15169
15170 assert!(
15171 err.to_string().contains("no candidate produced a change"),
15172 "{err}"
15173 );
15174 assert_eq!(runner.state.status, RunStatus::Failed);
15175 }
15176
15177 #[test]
15178 fn after_implement_still_fails_an_ordinary_all_empty_run() {
15179 ask_test_home();
15180 let mut runner = runner_at(RunStatus::Implementing);
15181 set_candidates(&mut runner, &[(true, None), (true, None)]);
15182
15183 let err = runner
15184 .after_implement()
15185 .expect_err("no candidate declared anything; this is an ordinary failure");
15186
15187 assert!(
15188 err.to_string().contains("no candidate produced a change"),
15189 "{err}"
15190 );
15191 assert_eq!(runner.state.status, RunStatus::Failed);
15192 }
15193}
15194
15195#[derive(Debug)]
15198struct GraftRestoreFailed(String);
15199
15200impl std::fmt::Display for GraftRestoreFailed {
15201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15202 write!(
15203 f,
15204 "could not restore the winner to its pre-graft commit: {}",
15205 self.0
15206 )
15207 }
15208}
15209
15210impl std::error::Error for GraftRestoreFailed {}
15211
15212#[cfg(test)]
15216mod resource_wait_tests {
15217 use super::*;
15218 use std::collections::BTreeMap;
15219
15220 fn sh(id: &str, script: &str) -> AgentSpec {
15221 AgentSpec {
15222 id: id.to_owned(),
15223 kind: crate::config::AgentKind::Command,
15224 model: None,
15225 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
15226 extra_args: Vec::new(),
15227 env: BTreeMap::new(),
15228 prompt_delivery: None,
15229 }
15230 }
15231
15232 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
15233 SeatJob {
15234 seat: SeatState::new("impl-A", &spec.id, 1),
15235 spec,
15236 cwd: dir.to_path_buf(),
15237 prompt: "do it".to_owned(),
15238 timeout,
15239 allow_write: true,
15240 sessions: false,
15241 artifacts: dir.join("art"),
15242 stem: "impl-A".to_owned(),
15243 handover: None,
15244 }
15245 }
15246
15247 fn state(resource_wait_secs: u64) -> RunState {
15248 let mut config = Config::default();
15249 config.graph.timeout_resource_wait = resource_wait_secs;
15250 RunState::new(
15251 PathBuf::from("/repo"),
15252 "main".to_owned(),
15253 "abc1234".to_owned(),
15254 "task".to_owned(),
15255 config,
15256 )
15257 }
15258
15259 fn hold(cache: &Path) -> crate::cache::Guard {
15260 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
15261 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
15262 crate::cache::AcquireOutcome::Acquired(g) => g,
15263 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
15264 }
15265 }
15266
15267 async fn run(
15268 jobs: Vec<SeatJob>,
15269 sem: Arc<Semaphore>,
15270 cache: Option<&Path>,
15271 st: &mut RunState,
15272 ) -> Vec<(usize, SeatState, AgentOutcome)> {
15273 let prompts = Prompts::default();
15274 let ctx = WaveCtx {
15275 run: "r",
15276 node: "implement",
15277 prompts: &prompts,
15278 cache,
15279 round: None,
15280 carry_seats: false,
15281 pauses: None,
15282 };
15283 wave(jobs, sem, &ctx, st, 0).await
15284 }
15285
15286 #[tokio::test]
15287 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
15288 crate::run::pin_test_home();
15289 let tmp = tempfile::tempdir().unwrap();
15290 let cache = tmp.path().join("target-a");
15291 let guard = hold(&cache);
15292 tokio::spawn(async move {
15294 tokio::time::sleep(Duration::from_millis(1500)).await;
15295 guard.release();
15296 });
15297 let mut st = state(60);
15298 let results = run(
15299 vec![job(
15300 sh("alpha", "echo ok"),
15301 tmp.path(),
15302 Duration::from_secs(1),
15303 )],
15304 Arc::new(Semaphore::new(1)),
15305 Some(&cache),
15306 &mut st,
15307 )
15308 .await;
15309 assert!(
15310 matches!(results[0].2, AgentOutcome::Ok(_)),
15311 "the wait must not be charged to the agent"
15312 );
15313 assert!(st.resource_block.is_none());
15314 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
15315 assert!(st.resource_wait.is_none() && st.active.is_empty());
15316 }
15317
15318 #[tokio::test]
15319 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
15320 crate::run::pin_test_home();
15321 let tmp = tempfile::tempdir().unwrap();
15322 let cache = tmp.path().join("target-b");
15323 let _held = hold(&cache);
15324 let mut st = state(1);
15325 let results = run(
15326 vec![job(
15327 sh("alpha", "echo ok"),
15328 tmp.path(),
15329 Duration::from_secs(1),
15330 )],
15331 Arc::new(Semaphore::new(1)),
15332 Some(&cache),
15333 &mut st,
15334 )
15335 .await;
15336 let (_, seat, out) = &results[0];
15337 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
15338 assert!(wave_blocked(&results));
15339 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
15340 assert_eq!(seat.turns, 0, "nothing was asked");
15341 assert!(
15342 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
15343 "no agent was spawned"
15344 );
15345 assert!(FailClass::of(out).is_none(), "not a failure class");
15346 let block = st.resource_block.as_ref().expect("recorded for the resume");
15347 assert_eq!(block.kind, "shared build cache");
15348 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
15349 assert!(st.active.is_empty());
15350 }
15351
15352 #[tokio::test]
15353 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
15354 crate::run::pin_test_home();
15355 let tmp = tempfile::tempdir().unwrap();
15356 let mut st = state(1);
15357 let results = run(
15358 vec![job(
15359 sh("alpha", "echo ok"),
15360 tmp.path(),
15361 Duration::from_secs(1),
15362 )],
15363 Arc::new(Semaphore::new(0)),
15364 None,
15365 &mut st,
15366 )
15367 .await;
15368 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
15369 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
15370 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
15371 }
15372
15373 #[tokio::test]
15374 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
15375 crate::run::pin_test_home();
15376 let tmp = tempfile::tempdir().unwrap();
15377 let mut st = state(60);
15378 let results = run(
15379 vec![job(
15380 sh("alpha", "sleep 30"),
15381 tmp.path(),
15382 Duration::from_secs(1),
15383 )],
15384 Arc::new(Semaphore::new(1)),
15385 None,
15386 &mut st,
15387 )
15388 .await;
15389 let out = &results[0].2;
15390 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
15391 let class = FailClass::of(out).expect("a failure");
15392 assert_eq!(class, FailClass::Timeout);
15393 assert!(should_hand_over(None, &class), "existing handover policy");
15394 assert!(st.resource_block.is_none());
15395 }
15396}