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 && is_review_run(&self.state) {
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 let note = match (&summarized, &self.state.change_summary) {
6859 (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
6860 (Some(Err(why)), _) => ChangeNote::Unavailable(why),
6861 (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
6862 (None, _) => ChangeNote::NotAsked,
6863 };
6864 let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
6865 if !posting {
6866 let identity = crate::scrub::Identity::current();
6867 return Ok(Some(PrMessage {
6868 title: crate::scrub::scrub(&pr.title, &identity),
6869 body: crate::scrub::scrub(&pr.body, &identity),
6870 }));
6871 }
6872 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6875 if let Some(known) = self
6876 .state
6877 .github_text
6878 .clone()
6879 .filter(|g| g.fingerprint == raw_fp)
6880 {
6881 return self.settle_withheld(winner, &pr, known).await;
6882 }
6883 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6884 let mut violations =
6885 crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
6886 let identity = crate::scrub::Identity::current();
6887 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6888 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6889 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6890 {
6891 violations.push(crate::github_text::Violation::SensitiveData);
6892 }
6893 let has_language = violations
6897 .iter()
6898 .any(|v| *v != crate::github_text::Violation::SensitiveData);
6899 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6900 self.state.event(
6901 "github-text",
6902 if has_language {
6903 format!("description rejected: {violations:?}; requesting one rewrite")
6904 } else {
6905 format!("description rejected: {violations:?}; asking the owner")
6906 },
6907 );
6908 let mut rewritten = false;
6909 let mut rejected_rewrite = None;
6910 if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
6911 {
6912 let output = text;
6913 if let Some(title) = summary_title(&output) {
6914 let raw_body = summary_without_title(&output);
6917 let clean_title = crate::scrub::scrub(&title, &identity);
6918 let mut body = crate::scrub::scrub(&raw_body, &identity);
6919 let redacted = clean_title != title || body != raw_body;
6920 let title = clean_title;
6921 let marker = format!("magi:run/{}", self.state.id);
6922 if !body.trim().is_empty() && !body.contains(&marker) {
6923 body.push_str(&format!("\n\n{marker}"));
6924 }
6925 let rewrite = PrMessage {
6926 title: title.clone(),
6927 body: body.clone(),
6928 };
6929 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6930 let mut failed = crate::github_text::check_for(
6931 &self.state.config.graph,
6932 &title,
6933 &body,
6934 redecision,
6935 );
6936 if (!crate::github_text::shareable(&title)
6937 || !crate::github_text::shareable(&body))
6938 && !failed.contains(&crate::github_text::Violation::SensitiveData)
6939 {
6940 failed.push(crate::github_text::Violation::SensitiveData);
6941 }
6942 if !title.trim().is_empty() && !body.trim().is_empty() && !failed.is_empty() {
6943 rejected_rewrite = Some(crate::run::GithubTextRewrite {
6944 title: title.clone(),
6945 body: body.clone(),
6946 failed: failed
6947 .iter()
6948 .map(|v| crate::github_text::category(*v).to_owned())
6949 .collect(),
6950 });
6951 }
6952 if !title.trim().is_empty() && !body.trim().is_empty() && failed.is_empty() {
6953 decision = redecision;
6954 pr = PrMessage { title, body };
6955 rewritten = true;
6956 self.state
6957 .event("github-text", "description rewrite passed");
6958 if redacted {
6959 self.state.event(
6960 "github-text",
6961 "sensitive data removed from description rewrite",
6962 );
6963 }
6964 }
6965 }
6966 }
6967 if !rewritten {
6968 self.state.event(
6969 "github-text",
6970 "description rewrite unavailable or rejected; asking the owner",
6971 );
6972 if let Some(w) =
6973 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6974 {
6975 self.retire_github_text_question("the withheld text changed");
6978 let record = crate::run::GithubTextAsk {
6979 rewrite: rejected_rewrite,
6980 fingerprint: raw_fp,
6981 title: w.title,
6982 body: w.body,
6983 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6984 question: None,
6985 asks: 0,
6986 resolved: false,
6987 chosen_title: None,
6988 choice: None,
6989 };
6990 return self.settle_withheld(winner, &pr, record).await;
6991 }
6992 }
6993 }
6994 let (title, body) =
6995 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6996 Ok(Some(PrMessage { title, body }))
6997 }
6998
6999 async fn summarize_change(
7010 &mut self,
7011 winner: &Candidate,
7012 head: Option<&str>,
7013 ) -> std::result::Result<(), String> {
7014 let Some(head) = head else {
7015 return Err("the final commit could not be read".to_owned());
7016 };
7017 let remote = self.state.config.merge.remote.clone();
7018 let tracking = format!("{remote}/{}", self.state.base_branch);
7019 if !git::rev_exists(&winner.worktree, &tracking).await {
7020 return Err(format!("the remote base `{tracking}` could not be read"));
7021 }
7022 let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7023 Ok(b) if !b.is_empty() => b,
7024 _ => return Err("the merge base with the remote base could not be read".to_owned()),
7025 };
7026 if self
7027 .state
7028 .change_summary
7029 .as_ref()
7030 .is_some_and(|c| c.head == head && c.base == base)
7031 {
7032 return Ok(());
7033 }
7034 if self.state.change_summary.take().is_some() {
7035 self.state.event(
7036 "pr-summary",
7037 "the final commit or base changed; the earlier change summary was discarded",
7038 );
7039 }
7040 let chain = match agent::pick_chain(
7041 &self.state.config.agents,
7042 self.state.config.roles.synthesizer.as_ref(),
7043 &agent::installed,
7044 "synthesizer",
7045 ) {
7046 Ok(chain) => chain,
7047 Err(e) => {
7048 tracing::warn!("no agent for the change summary: {e:#}");
7049 self.state.event("pr-summary", "no summary agent available");
7050 return Err("no agent was available to summarize it".to_owned());
7051 }
7052 };
7053 let subjects = git::subjects(&winner.worktree, &base, head)
7054 .await
7055 .unwrap_or_default();
7056 let stat = git::diff_stat(&winner.worktree, &base, head)
7057 .await
7058 .unwrap_or_default();
7059 let stat: String = stat.chars().take(6_000).collect();
7060 let prompt = crate::prompt::change_summary_in(
7061 &base,
7062 head,
7063 &subjects,
7064 stat.trim(),
7065 &self.state.config.graph.github_language,
7066 );
7067 let sessions = self.state.config.graph.sessions;
7068 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7069 let prompts = self.state.config.prompts.clone();
7070 let cache = self.state.config.cache_dir();
7071 let run = self.state.id.clone();
7072 for (i, spec) in chain.into_iter().enumerate() {
7073 let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7074 let job = SeatJob {
7075 spec: spec.clone(),
7076 seat,
7077 cwd: winner.worktree.clone(),
7078 prompt: prompt.clone(),
7079 timeout,
7080 allow_write: false,
7081 sessions,
7082 artifacts: agent::artifacts_dir(&self.state.dir()),
7083 stem: if i == 0 {
7084 "change-summary".to_owned()
7085 } else {
7086 format!("change-summary-{}", spec.id)
7087 },
7088 handover: None,
7089 };
7090 let ctx = WaveCtx {
7091 run: &run,
7092 node: "pr-summary",
7093 prompts: &prompts,
7094 cache: cache.as_deref(),
7095 round: None,
7096 carry_seats: false,
7097 pauses: Some((&self.pause, &self.interrupt)),
7098 };
7099 let (seat, outcome) =
7100 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7101 self.state.seats.insert(seat.key.clone(), seat);
7102 let text = match outcome {
7103 AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7104 AgentOutcome::ResourceBlocked(why) => {
7105 if self.state.resource_block.is_none() {
7108 self.state.resource_block = Some(crate::run::ResourceBlock {
7109 node: "pr-summary".to_owned(),
7110 kind: "shared resource".to_owned(),
7111 detail: why,
7112 at: Timestamp::now(),
7113 });
7114 }
7115 return Err("a shared resource was busy".to_owned());
7116 }
7117 _ => continue,
7118 };
7119 let blind = &self.state.config.blind;
7120 let Some(title) = summary_title(&text) else {
7121 continue;
7122 };
7123 let body: String = summary_without_title(&text)
7124 .lines()
7125 .filter(|l| !l.contains("magi:run/"))
7126 .collect::<Vec<_>>()
7127 .join("\n");
7128 let body = blind::sanitize_prose(body.trim(), blind);
7129 let title = blind::sanitize_prose(&title, blind);
7130 if body.trim().is_empty() || title.trim().is_empty() {
7131 continue;
7132 }
7133 if git::rev_parse(&winner.worktree, "HEAD")
7135 .await
7136 .ok()
7137 .as_deref()
7138 != Some(head)
7139 {
7140 return Err("the branch moved while the summary was written".to_owned());
7141 }
7142 self.state.change_summary = Some(crate::run::ChangeSummary {
7143 head: head.to_owned(),
7144 base,
7145 title,
7146 body,
7147 });
7148 self.state.event(
7149 "pr-summary",
7150 format!("change summary written by {} for the final diff", spec.id),
7151 );
7152 if let Err(e) = self.state.save() {
7153 tracing::warn!("could not save the change summary: {e:#}");
7154 }
7155 return Ok(());
7156 }
7157 self.state
7158 .event("pr-summary", "no agent produced a change summary");
7159 Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7160 }
7161
7162 async fn rewrite_pr_text(
7170 &mut self,
7171 winner: &Candidate,
7172 pr: &PrMessage,
7173 violations: &[crate::github_text::Violation],
7174 ) -> Option<String> {
7175 let prompt = format!(
7176 "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 complete Markdown body. Preserve the magi run marker.\n\nTITLE: {}\n{}",
7177 pr.title, pr.body
7178 );
7179 let sessions = self.state.config.graph.sessions;
7180 let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7181 let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7183 let key = format!("impl-{}", winner.label);
7184 if let Some(spec) = self
7185 .state
7186 .config
7187 .agents
7188 .iter()
7189 .find(|a| a.id == winner.agent)
7190 .cloned()
7191 && let Some(existing) = self.state.seats.get(&key)
7192 && existing.agent == spec.id
7193 && agent::has_session(spec.kind, existing, sessions)
7194 {
7195 attempts.push((spec, existing.clone(), true));
7196 } else {
7197 let chain = agent::pick_chain(
7198 &self.state.config.agents,
7199 self.state.config.roles.language_judge.as_ref(),
7200 &agent::installed,
7201 "language_judge",
7202 );
7203 match chain {
7204 Ok(chain) => {
7205 for spec in chain {
7206 let seat = crate::fixer::seat_for(
7207 &mut self.state,
7208 "github-text-rewrite",
7209 &spec.id,
7210 );
7211 attempts.push((spec, seat, false));
7212 }
7213 }
7214 Err(e) => {
7215 tracing::warn!("no agent for the description rewrite: {e:#}");
7216 self.state
7217 .event("github-text", "no rewrite agent available");
7218 return None;
7219 }
7220 }
7221 }
7222 let prompts = self.state.config.prompts.clone();
7223 let cache = self.state.config.cache_dir();
7224 let run = self.state.id.clone();
7225 for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7226 let job = SeatJob {
7227 spec: spec.clone(),
7228 seat,
7229 cwd: winner.worktree.clone(),
7230 prompt: prompt.clone(),
7231 timeout: retry_budget(timeout_full, resumed),
7232 allow_write: false,
7233 sessions,
7234 artifacts: agent::artifacts_dir(&self.state.dir()),
7235 stem: if i == 0 {
7236 "github-text-rewrite".to_owned()
7237 } else {
7238 format!("github-text-rewrite-{}", spec.id)
7239 },
7240 handover: None,
7241 };
7242 let ctx = WaveCtx {
7243 run: &run,
7244 node: "github-text",
7245 prompts: &prompts,
7246 cache: cache.as_deref(),
7247 round: None,
7248 carry_seats: false,
7249 pauses: Some((&self.pause, &self.interrupt)),
7250 };
7251 let (seat, outcome) =
7252 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7253 self.state.seats.insert(seat.key.clone(), seat);
7254 match outcome {
7255 AgentOutcome::Ok(o)
7256 if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7257 {
7258 return Some(o.text);
7259 }
7260 AgentOutcome::ResourceBlocked(_) => return None,
7261 _ => {}
7262 }
7263 }
7264 None
7265 }
7266
7267 fn retire_github_text_question(&mut self, why: &str) {
7269 let Some(id) = self
7270 .state
7271 .github_text
7272 .as_ref()
7273 .and_then(|g| g.question.clone())
7274 else {
7275 return;
7276 };
7277 let _ = ask::Questions::open().update(&id, |q| {
7278 q.abandon(why);
7279 Ok(())
7280 });
7281 }
7282
7283 async fn settle_withheld(
7287 &mut self,
7288 winner: &Candidate,
7289 pr: &PrMessage,
7290 mut g: crate::run::GithubTextAsk,
7291 ) -> Result<Option<PrMessage>> {
7292 use crate::github_text as gt;
7293 let store = ask::Questions::open();
7294 let timeout = self.state.config.graph.answer_timeout;
7295 if !g.resolved {
7296 let mut standing = g
7297 .question
7298 .as_ref()
7299 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7300 if let Some(q) = standing.as_ref()
7301 && q.status.open()
7302 && gt::expired(q, timeout)
7303 {
7304 let why = format!("no answer within {}s of asking", timeout.max(1));
7305 standing = store
7306 .update(&q.id, |q| {
7307 q.abandon(&why);
7308 Ok(())
7309 })
7310 .ok()
7311 .map(|(q, ())| q);
7312 }
7313 let mut ask_again: Option<Option<Vec<String>>> = None;
7314 match standing {
7315 None if g.asks <= 1 => {
7318 g.asks = 0;
7319 ask_again = Some(None);
7320 }
7321 None => {
7322 g.resolved = true;
7323 g.chosen_title = None;
7324 g.choice = Some(GithubTextChoice::Fallback);
7325 }
7326 Some(q) if q.status.open() => {
7327 self.park_on_github_text(&g)?;
7328 return Ok(None);
7329 }
7330 Some(q) => match gt::read_reply(&q) {
7331 gt::Reply::Fallback => {
7332 g.resolved = true;
7333 g.chosen_title = None;
7334 g.choice = Some(GithubTextChoice::Fallback);
7335 }
7336 gt::Reply::Original => {
7337 g.resolved = true;
7338 g.chosen_title = None;
7339 g.choice = Some(GithubTextChoice::Original);
7340 }
7341 gt::Reply::Rewrite => {
7342 g.resolved = true;
7343 g.chosen_title = None;
7344 g.choice = Some(if g.rewrite.is_some() {
7345 GithubTextChoice::Rewrite
7346 } else {
7347 GithubTextChoice::Fallback
7348 });
7349 }
7350 gt::Reply::Title(text) => {
7351 let first = text
7352 .lines()
7353 .map(str::trim)
7354 .find(|l| !l.is_empty())
7355 .unwrap_or("");
7356 let decision = if first.is_empty() {
7357 None
7358 } else {
7359 let candidate = PrMessage {
7360 title: first.to_owned(),
7361 body: String::new(),
7362 };
7363 self.judge_pr_language(&winner.worktree, &candidate).await
7364 };
7365 match gt::vet_title(
7366 &text,
7367 decision,
7368 self.state.config.graph.github_english(),
7369 ) {
7370 Ok(title) => {
7371 g.resolved = true;
7372 g.chosen_title = Some(title);
7373 g.choice = Some(GithubTextChoice::MyTitle);
7374 }
7375 Err(cats) if g.asks < 2 => {
7376 ask_again = Some(Some(
7377 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7378 ))
7379 }
7380 Err(_) => {
7381 g.resolved = true;
7382 g.chosen_title = None;
7383 g.choice = Some(GithubTextChoice::Fallback);
7384 }
7385 }
7386 }
7387 },
7388 }
7389 if let Some(rejected) = ask_again {
7390 let w = gt::Withheld {
7393 title: g.title,
7394 body: g.body,
7395 categories: g.categories.clone(),
7396 };
7397 let artifact = match crate::run::write_artifact(
7400 &self.state,
7401 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
7402 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7403 ) {
7404 Ok(p) => Some(p),
7405 Err(e) => {
7406 tracing::warn!("could not write the withheld-text artifact: {e:#}");
7407 None
7408 }
7409 };
7410 let (detail, comparison) = gt::question_content(
7411 &w,
7412 &pr.title,
7413 &pr.body,
7414 &self.state.id,
7415 rejected.as_deref(),
7416 artifact.as_deref(),
7417 g.rewrite.as_ref(),
7418 );
7419 let mut q = ask::Question::new(
7420 self.state.id.clone(),
7421 gt::ASK_NODE.to_owned(),
7422 gt::ASK_SEAT.to_owned(),
7423 gt::question_summary(&self.state.config.graph.language, &w),
7424 detail,
7425 gt::question_choices(&w, g.rewrite.as_ref()),
7426 );
7427 q.posting_comparison = Some(comparison);
7428 q.answer_timeout = timeout;
7429 g.question = Some(q.id.clone());
7432 g.asks += 1;
7433 self.state.github_text = Some(g.clone());
7434 self.state.event(
7435 "github-text",
7436 format!("asking the owner about the withheld text ({})", q.short()),
7437 );
7438 self.state.save()?;
7439 store
7440 .put(&mut q)
7441 .context("file the posting-gate question")?;
7442 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7443 tracing::warn!(
7444 "could not notify about posting-gate question {}: {e:#}",
7445 q.short()
7446 );
7447 }
7448 self.park_on_github_text(&g)?;
7449 return Ok(None);
7450 }
7451 }
7452 let resolved = g.chosen_title.clone();
7453 let original = g.choice == Some(GithubTextChoice::Original);
7454 self.state.event(
7456 "github-text",
7457 if original {
7458 format!(
7459 "posting the original text for: {}",
7460 match (g.title, g.body) {
7461 (true, true) => "title, description",
7462 (true, false) => "title",
7463 _ => "description",
7464 }
7465 )
7466 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7467 "posting the saved rewrite title and description".to_owned()
7468 } else if resolved.is_some() {
7469 "posting the owner's replacement title".to_owned()
7470 } else {
7471 "posting the neutral text for the withheld fields".to_owned()
7472 },
7473 );
7474 self.state.github_text = Some(g.clone());
7475 self.state.save()?;
7476 if g.choice == Some(GithubTextChoice::Rewrite)
7477 && let Some(r) = g.rewrite.as_ref()
7478 {
7479 return Ok(Some(PrMessage {
7480 title: r.title.clone(),
7481 body: r.body.clone(),
7482 }));
7483 }
7484 let w = gt::Withheld {
7485 title: g.title,
7486 body: g.body,
7487 categories: g.categories.clone(),
7488 };
7489 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7490 if let Some(t) = resolved {
7491 title = t;
7492 }
7493 let english = gt::LanguageDecision {
7495 title_english: true,
7496 body_english: true,
7497 body_complete: true,
7498 };
7499 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7500 if original {
7501 if g.title {
7504 title = pr.title.clone();
7505 }
7506 if g.body {
7507 body = pr.body.clone();
7508 }
7509 }
7510 Ok(Some(PrMessage { title, body }))
7511 }
7512
7513 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7514 self.state.github_text = Some(g.clone());
7515 self.state.end_pass_parked(ParkCause::Question);
7516 self.state.parked = true;
7517 self.state.event(
7518 "github-text",
7519 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7520 );
7521 self.state.save()
7522 }
7523
7524 async fn judge_pr_language(
7527 &mut self,
7528 cwd: &Path,
7529 pr: &PrMessage,
7530 ) -> Option<crate::github_text::LanguageDecision> {
7531 self.state.config.roles.language_judge.as_ref()?;
7532 if !self.state.config.graph.language_gate() {
7533 return None;
7534 }
7535 let decision =
7536 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7537 self.state.event(
7538 "github-text",
7539 if decision.is_some() {
7540 "language decided by the language judge"
7541 } else {
7542 "language judge unavailable; using built-in heuristics"
7543 },
7544 );
7545 decision
7546 }
7547
7548 async fn merge(&mut self) -> Result<()> {
7549 if self
7564 .state
7565 .base_sync
7566 .as_ref()
7567 .is_some_and(|s| s.conflict.is_some())
7568 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7569 != Some(RunStatus::Gating)
7570 || !self.state.gate_status().ok()
7579 {
7580 return Ok(());
7581 }
7582 if self.state.merge.is_some() && !self.state.pr_publication_pending {
7591 return Ok(());
7592 }
7593 let Some(winner) = self.state.winner().cloned() else {
7594 return Ok(());
7595 };
7596 let repo = self.state.repo.clone();
7597 let base = self.state.base_branch.clone();
7598 let mode = self.state.config.merge.mode;
7599 let style = self.state.config.merge.style;
7600 let facts = if is_review_run(&self.state) {
7601 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7602 let start = review_base(
7603 &repo,
7604 &self.state.config.merge.remote,
7605 &base,
7606 &self.state.base_commit,
7607 &winner.branch,
7608 )
7609 .await;
7610 branch_facts(&repo, &start, &winner.branch).await
7611 } else {
7612 None
7613 };
7614 let leaked = if is_review_run(&self.state) {
7617 leaked_subjects(&self.state, &winner.branch).await
7618 } else {
7619 Some(Vec::new())
7620 };
7621 let Some(pr) = self
7625 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7626 .await?
7627 else {
7628 return Ok(());
7629 };
7630 let message = pr.commit_message();
7631
7632 let outcome = match mode {
7633 MergeMode::None => MergeOutcome {
7634 mode,
7635 ok: true,
7636 detail: manual_merge_command(style, &repo, &winner.branch, &message),
7637 empty: false,
7638 },
7639 MergeMode::Pr | MergeMode::Local
7640 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7641 {
7642 MergeOutcome {
7643 mode,
7644 ok: false,
7645 detail: empty_candidate_detail(&self.state, &base),
7646 empty: true,
7647 }
7648 }
7649 MergeMode::Local => {
7650 let on = git::current_branch(&repo).await?;
7651 if on.as_deref() != Some(base.as_str()) {
7652 MergeOutcome {
7653 mode,
7654 ok: false,
7655 detail: format!(
7656 "{} has {} checked out, not the base branch {base}",
7657 repo.display(),
7658 on.unwrap_or_else(|| "a detached HEAD".to_owned())
7659 ),
7660 empty: false,
7661 }
7662 } else if !git::is_clean(&repo).await? {
7663 MergeOutcome {
7664 mode,
7665 ok: false,
7666 detail: format!("{} is dirty; refusing to merge", repo.display()),
7667 empty: false,
7668 }
7669 } else {
7670 let out = match style {
7671 MergeStyle::Merge => {
7672 git::merge_no_ff(&repo, &winner.branch, &message).await?
7673 }
7674 MergeStyle::Squash => {
7675 git::merge_squash(&repo, &winner.branch, &message).await?
7676 }
7677 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7678 };
7679 MergeOutcome {
7680 mode,
7681 ok: out.ok(),
7682 detail: if out.ok() { out.stdout } else { out.stderr },
7683 empty: false,
7684 }
7685 }
7686 }
7687 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7688 self.state.pr_publication_pending = true;
7689 self.state.save()?;
7690 let out =
7691 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7692 self.state.pr_publication_pending = out.is_err();
7693 MergeOutcome {
7694 mode,
7695 ok: out.is_ok(),
7696 detail: match out {
7697 Ok(url) => url,
7698 Err(e) => format!("{e:#}"),
7699 },
7700 empty: false,
7701 }
7702 }
7703 MergeMode::Pr => {
7704 let remote = self.state.config.merge.remote.clone();
7705 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
7706 if !pushed.ok() {
7707 MergeOutcome {
7708 mode,
7709 ok: false,
7710 detail: pushed.stderr,
7711 empty: false,
7712 }
7713 } else {
7714 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7720 let out = match pr_merge_plan(found) {
7721 PrPlan::Create => {
7722 gh_pr_create(
7723 &winner.worktree,
7724 &base,
7725 &winner.branch,
7726 &pr.title,
7727 &pr.body,
7728 )
7729 .await
7730 }
7731 PrPlan::Adopt { url, title } => {
7732 self.state
7733 .event("merge", format!("Pr: adopted open pull request {url}"));
7734 if self.state.github_text.as_ref().is_some_and(|g| {
7735 g.resolved
7736 && g.choice == Some(GithubTextChoice::Rewrite)
7737 && g.rewrite.is_some()
7738 }) {
7739 land::set_pr_text_verbatim(
7742 &winner.worktree,
7743 &url,
7744 &pr.title,
7745 &pr.body,
7746 )
7747 .await
7748 .map(|()| url)
7749 } else {
7750 if title != pr.title
7751 && (!is_review_run(&self.state)
7752 || leaked
7753 .as_deref()
7754 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7755 && let Err(e) =
7756 if self.state.github_text.as_ref().is_some_and(|g| {
7757 g.resolved
7758 && g.title
7759 && g.choice == Some(GithubTextChoice::Original)
7760 }) {
7761 land::set_pr_title_verbatim(
7763 &winner.worktree,
7764 &url,
7765 &pr.title,
7766 )
7767 .await
7768 } else {
7769 land::set_pr_title(
7770 &mut self.state,
7771 &winner.worktree,
7772 &url,
7773 &pr.title,
7774 )
7775 .await
7776 }
7777 {
7778 tracing::warn!("could not refresh title of {url}: {e:#}");
7779 self.state
7780 .event("merge", format!("Pr: title refresh failed: {e:#}"));
7781 }
7782 Ok(url)
7783 }
7784 }
7785 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7786 };
7787 match out {
7788 Ok(url) => MergeOutcome {
7789 mode,
7790 ok: true,
7791 detail: url,
7792 empty: false,
7793 },
7794 Err(e) => MergeOutcome {
7795 mode,
7796 ok: false,
7797 detail: e.to_string(),
7798 empty: false,
7799 },
7800 }
7801 }
7802 }
7803 };
7804
7805 self.state.status = match (mode, outcome.ok) {
7806 (MergeMode::None, _) => RunStatus::Ready,
7807 (_, true) => RunStatus::Merged,
7808 (_, false) => RunStatus::Blocked,
7809 };
7810 self.state.event(
7811 "merge",
7812 format!(
7813 "{:?}: {}",
7814 mode,
7815 outcome.detail.lines().next().unwrap_or("")
7816 ),
7817 );
7818 self.state.merge = Some(outcome);
7819 self.state.save()?;
7820
7821 if self.state.config.graph.land
7827 && mode == MergeMode::Pr
7828 && self.state.status == RunStatus::Merged
7829 {
7830 self.run_land().await?;
7831 }
7832 self.settle_questions();
7837 Ok(())
7838 }
7839
7840 async fn run_land(&mut self) -> Result<()> {
7851 crate::pr_head::ensure_owner(&self.state)?;
7852 let url = self
7853 .state
7854 .merge
7855 .as_ref()
7856 .map(|m| m.detail.clone())
7857 .unwrap_or_default();
7858 let url = url.lines().next().unwrap_or("").trim().to_owned();
7859 if !url.starts_with("http") {
7860 return Ok(());
7861 }
7862 match land::land(&mut self.state, &url).await {
7865 Ok(pr) if self.state.parked => {
7866 let _ = pr;
7870 }
7871 Ok(pr) => {
7872 self.state.status = match pr.state {
7873 land::PrLifecycle::Merged => RunStatus::Merged,
7874 _ => RunStatus::Blocked,
7875 };
7876 if bump::should_release_bump(self.state.status)
7883 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7884 {
7885 self.state
7891 .event("bump", format!("release bump skipped: {e:#}"));
7892 }
7893 if self.state.status == RunStatus::Merged {
7896 crate::followup::after_merge(&mut self.state, &pr.url).await;
7897 }
7898 self.state.save()?;
7899 }
7900 Err(e) => {
7901 self.state.status = RunStatus::Blocked;
7902 self.state.event("land", format!("gave up: {e}"));
7903 self.state.save()?;
7904 }
7905 }
7906 Ok(())
7907 }
7908
7909 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7913 if let Some(existing) = self.state.seats.get(key)
7914 && existing.agent == agent
7915 {
7916 return existing.clone();
7917 }
7918 let fresh = if self.state.seats.contains_key(key) {
7921 handover_seat(key, agent, self.state.next_seat_seed())
7922 } else {
7923 SeatState::new(key, agent, self.state.seed)
7924 };
7925 self.state.seats.insert(key.to_owned(), fresh.clone());
7926 fresh
7927 }
7928
7929 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7935 match self.state.seats.get(key) {
7936 Some(s) if s.agent != spec.id => {
7937 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7938 }
7939 _ => spec,
7940 }
7941 }
7942
7943 fn view(&self, c: &Candidate) -> CandidateView {
7945 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7946 .unwrap_or_default();
7947 let (patch, _) = blind::sanitize_patch(
7948 &format!("candidate {} patch", c.label),
7949 &raw,
7950 &self.state.config.blind,
7951 );
7952 CandidateView {
7953 label: c.label,
7954 branch: c.branch.clone(),
7955 summary: c.summary.clone(),
7956 stat: c.stat.clone(),
7957 patch,
7958 }
7959 }
7960
7961 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7963 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7964 prompt::judge(
7965 "(see above)",
7966 &views,
7967 self.roles.judges.len(),
7968 base_short,
7969 "en",
7970 )
7971 }
7972
7973 fn vote_prompt_full(
7979 &self,
7980 j: usize,
7981 labels: &[char],
7982 language: &str,
7983 candidates: &[Candidate],
7984 base_short: &str,
7985 ) -> String {
7986 let mut text = format!(
7987 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7988 prompt::final_vote(labels, language),
7989 self.state.instruction,
7990 self.candidate_block(candidates, base_short)
7991 );
7992 if let Some(own) = self
7993 .state
7994 .judgements
7995 .get(j)
7996 .filter(|r| !r.ranking.is_empty())
7997 {
7998 let reasons = own
7999 .reasons
8000 .iter()
8001 .map(|(k, v)| format!("- {k}: {v}"))
8002 .collect::<Vec<_>>()
8003 .join("\n");
8004 text.push_str(&format!(
8005 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8006 own.ranking.iter().collect::<String>(),
8007 if reasons.is_empty() {
8008 ""
8009 } else {
8010 ", because:\n"
8011 }
8012 ));
8013 }
8014 if !self.state.deliberation.is_empty() {
8015 text.push_str("\n# What was argued before this vote\n");
8016 for t in self.transcript(&[], j) {
8017 text.push_str(&format!(
8018 "\n## {}{}\n\n{}\n",
8019 t.who,
8020 if t.is_self { " (you)" } else { "" },
8021 t.body.trim()
8022 ));
8023 }
8024 }
8025 text
8026 }
8027
8028 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8035 let mut turns = Vec::new();
8036 for j in &self.state.judgements {
8037 if j.ranking.is_empty() {
8038 continue;
8039 }
8040 let reasons = j
8041 .reasons
8042 .iter()
8043 .map(|(k, v)| format!("- {k}: {v}"))
8044 .collect::<Vec<_>>()
8045 .join("\n");
8046 turns.push(Turn {
8047 who: format!("Judge {} (opening ranking)", j.judge),
8048 is_self: j.judge == self_idx + 1,
8049 body: format!(
8050 "Ranked {}{}{reasons}",
8051 j.ranking.iter().collect::<String>(),
8052 if reasons.is_empty() {
8053 ""
8054 } else {
8055 ", because:\n"
8056 }
8057 ),
8058 });
8059 }
8060 for t in self
8061 .state
8062 .deliberation
8063 .iter()
8064 .flat_map(|r| r.turns.iter())
8065 .chain(current)
8066 {
8067 turns.push(Turn {
8068 who: format!("Judge {}", t.judge),
8069 is_self: t.judge == self_idx + 1,
8070 body: t.body.clone(),
8071 });
8072 }
8073 turns
8074 }
8075}
8076
8077fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8079 agent::has_session(spec.kind, seat, sessions)
8080}
8081
8082fn next_untried_in_roster<'a>(
8103 roster: &'a [AgentSpec],
8104 start: usize,
8105 tried: &BTreeSet<String>,
8106) -> Option<&'a AgentSpec> {
8107 roster
8108 .get(start + 1..)?
8109 .iter()
8110 .find(|s| !tried.contains(&s.id))
8111}
8112
8113fn pick_successor<'a>(
8134 roster: &'a [AgentSpec],
8135 start: usize,
8136 tried: &BTreeSet<String>,
8137 carried: Option<&BTreeSet<String>>,
8138 others: &BTreeSet<String>,
8139) -> Option<&'a AgentSpec> {
8140 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8141 roster
8142 .get(start + 1..)
8143 .and_then(|tail| {
8144 tail.iter()
8145 .filter(free)
8146 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8147 })
8148 .or_else(|| {
8149 carried
8150 .is_some()
8151 .then(|| roster.iter().find(free))
8152 .flatten()
8153 })
8154 .or_else(|| match carried {
8155 Some(c) => next_for_seat(roster, start, tried, c),
8156 None => next_untried_in_roster(roster, start, tried),
8157 })
8158}
8159
8160fn next_for_seat<'a>(
8170 roster: &'a [AgentSpec],
8171 start: usize,
8172 round_tried: &BTreeSet<String>,
8173 carried_failed: &BTreeSet<String>,
8174) -> Option<&'a AgentSpec> {
8175 roster
8176 .get(start + 1..)?
8177 .iter()
8178 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8179 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8180}
8181
8182fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8188 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8189 return spec;
8190 };
8191 let ok = |id: &str| !h.failed.contains(id);
8192 if let Some(last) = h.last_ok.as_deref()
8193 && ok(last)
8194 && let Some(s) = roster.iter().find(|s| s.id == last)
8195 {
8196 return s.clone();
8197 }
8198 if ok(&spec.id) {
8199 return spec;
8200 }
8201 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8202 roster
8203 .get(from + 1..)
8204 .into_iter()
8205 .flatten()
8206 .chain(roster.iter())
8207 .find(|s| ok(&s.id))
8208 .cloned()
8209 .unwrap_or(spec)
8210}
8211
8212pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8216 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8217}
8218
8219const TIMED_OUT: &str = "timed out";
8222
8223impl FailClass {
8224 fn of(out: &AgentOutcome) -> Option<Self> {
8226 match out {
8227 AgentOutcome::Ok(_) => None,
8228 AgentOutcome::Quota(_) => Some(Self::Quota),
8229 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8230 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8231 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8232 AgentOutcome::ResourceBlocked(_) => None,
8235 }
8236 }
8237
8238 fn stem_word(&self) -> &'static str {
8240 match self {
8241 Self::Quota => "quota",
8242 _ => "handover",
8243 }
8244 }
8245}
8246
8247fn failure_signature(msg: &str) -> String {
8251 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8252 let mut out = Vec::new();
8253 for word in line.split_whitespace() {
8254 if word.contains('/') || word.contains('\\') {
8255 out.push("<path>".to_owned());
8256 continue;
8257 }
8258 let mut w = String::new();
8259 let mut in_digits = false;
8260 for c in word.chars() {
8261 if c.is_ascii_digit() {
8262 if !in_digits {
8263 w.push('#');
8264 }
8265 in_digits = true;
8266 } else {
8267 in_digits = false;
8268 w.push(c);
8269 }
8270 }
8271 out.push(w);
8272 }
8273 out.join(" ").chars().take(120).collect()
8274}
8275
8276fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8283 match cur {
8284 FailClass::Quota | FailClass::Timeout => true,
8285 FailClass::Other(_) => prev != Some(cur),
8286 }
8287}
8288
8289fn fail_reason(out: &AgentOutcome) -> String {
8291 match out {
8292 AgentOutcome::Ok(_) => String::new(),
8293 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8294 AgentOutcome::Dropped(o) => format!(
8295 "the CLI dropped the stream ({})",
8296 o.dropped
8297 .as_ref()
8298 .map(|d| d.why.as_str())
8299 .unwrap_or("it ended without delivering its answer")
8300 ),
8301 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8302 e.lines().next().unwrap_or("").chars().take(160).collect()
8303 }
8304 }
8305}
8306
8307pub(crate) fn record_handover(
8310 state: &mut RunState,
8311 node: &str,
8312 seat: &str,
8313 from: &str,
8314 to: &str,
8315 class: &FailClass,
8316 reason: &str,
8317) {
8318 let message = if *class == FailClass::Quota {
8319 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8320 } else {
8321 format!("{seat}: handed over {from} -> {to} ({reason})")
8322 };
8323 state.event(node, message);
8324 state.handovers.push(Handover {
8325 at: Timestamp::now(),
8326 node: node.to_owned(),
8327 seat: seat.to_owned(),
8328 from: from.to_owned(),
8329 to: to.to_owned(),
8330 reason: reason.to_owned(),
8331 });
8332}
8333
8334fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8348 commands.iter().any(|c| c.exit_code.is_none())
8349}
8350
8351fn verified_noop_claim(
8364 usable: bool,
8365 commands: &[agent::CommandEvidence],
8366 text: &str,
8367) -> Option<String> {
8368 (usable && !has_unconfirmed_command(commands))
8369 .then(|| verdict::verified_noop(text))
8370 .flatten()
8371}
8372
8373fn short(commit: &str) -> String {
8374 commit.chars().take(7).collect()
8375}
8376
8377fn make_executable(path: &Path) -> Result<()> {
8378 #[cfg(unix)]
8379 {
8380 use std::os::unix::fs::PermissionsExt as _;
8381 let mut perms = std::fs::metadata(path)?.permissions();
8382 perms.set_mode(0o755);
8383 std::fs::set_permissions(path, perms)?;
8384 }
8385 #[cfg(not(unix))]
8386 {
8387 let _ = path;
8388 }
8389 Ok(())
8390}
8391
8392struct WaveCtx<'a> {
8399 run: &'a str,
8402 node: &'a str,
8404 prompts: &'a Prompts,
8405 cache: Option<&'a Path>,
8407 round: Option<usize>,
8410 carry_seats: bool,
8414 pauses: Option<(&'a Pause, &'a Pause)>,
8417}
8418
8419async fn run_one(
8421 job: SeatJob,
8422 sem: Arc<Semaphore>,
8423 ctx: &WaveCtx<'_>,
8424 state: &mut RunState,
8425 attempt: usize,
8426) -> (SeatState, AgentOutcome) {
8427 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8428 .await
8429 .pop()
8430 .expect("one job in, one result out");
8431 (seat, out)
8432}
8433
8434async fn wave(
8440 jobs: Vec<SeatJob>,
8441 sem: Arc<Semaphore>,
8442 ctx: &WaveCtx<'_>,
8443 state: &mut RunState,
8444 attempt: usize,
8445) -> Vec<(usize, SeatState, AgentOutcome)> {
8446 let WaveCtx {
8447 run,
8448 node,
8449 prompts,
8450 cache,
8451 round,
8452 carry_seats: _,
8453 pauses,
8454 } = *ctx;
8455 for job in &jobs {
8456 state.seat_started(node, &job.seat.key, job.timeout, attempt);
8457 }
8458 if let Err(e) = state.save() {
8459 tracing::warn!("could not persist in-progress seats: {e:#}");
8464 }
8465 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8481 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8485 let cache_guard = if let Some(cache_dir) = cache {
8486 if jobs_had_a_writer {
8487 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8488 let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8489 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8490 {
8491 Ok(Some(g)) => Some(g),
8492 Ok(None) => {
8495 state.resource_block = Some(crate::run::ResourceBlock {
8496 node: node.to_owned(),
8497 kind: "shared build cache".to_owned(),
8498 detail: "the run is parking".to_owned(),
8499 at: Timestamp::now(),
8500 });
8501 return block_wave(
8502 jobs,
8503 state,
8504 node,
8505 attempt,
8506 "parked while waiting for the shared build cache",
8507 );
8508 }
8509 Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
8512 Err(e) => {
8513 let why = format!("{e:#}");
8517 state.resource_block = Some(crate::run::ResourceBlock {
8518 node: node.to_owned(),
8519 kind: "shared build cache".to_owned(),
8520 detail: why.clone(),
8521 at: Timestamp::now(),
8522 });
8523 state.event(
8524 "resource",
8525 format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
8526 );
8527 return block_wave(jobs, state, node, attempt, &why);
8528 }
8529 }
8530 } else {
8531 None
8532 }
8533 } else {
8534 None
8535 };
8536 state.seats_spawning(node, attempt);
8539 if let Err(e) = state.save() {
8540 tracing::warn!("could not persist the start of a wave: {e:#}");
8541 }
8542 let slot_limit = resource_limit
8546 + jobs
8547 .iter()
8548 .map(|j| j.timeout)
8549 .max()
8550 .unwrap_or_default()
8551 .saturating_mul(jobs.len() as u32);
8552 let mut set = tokio::task::JoinSet::new();
8553 let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
8556 let overlay = prompts.overlay(node);
8557 for (i, mut job) in jobs.into_iter().enumerate() {
8558 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
8559 if cache.is_some() {
8560 job.prompt.push('\n');
8561 job.prompt
8562 .push_str(&prompt::build_cache_note(node, job.allow_write));
8563 }
8564 let sem = Arc::clone(&sem);
8565 let run = run.to_owned();
8566 let node = node.to_owned();
8567 let attachments = if node == "implement" {
8570 state.attachments.clone()
8571 } else {
8572 Vec::new()
8573 };
8574 let cache = cache
8585 .filter(|_| job.allow_write && cache_guard.is_some())
8586 .map(Path::to_path_buf);
8587 let spawned_tx = spawned_tx.clone();
8588 set.spawn(async move {
8589 let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
8593 Ok(p) => p,
8594 Err(_) => {
8595 return (
8596 i,
8597 job.seat,
8598 AgentOutcome::ResourceBlocked(format!(
8599 "no parallel slot became free within {}s",
8600 slot_limit.as_secs()
8601 )),
8602 );
8603 }
8604 };
8605 spawned_tx.send(job.seat.key.clone()).ok();
8606 let mut seat = job.seat;
8607 let out = agent::invoke(
8608 &job.spec,
8609 &mut seat,
8610 &Invocation {
8611 cwd: &job.cwd,
8612 prompt: &job.prompt,
8613 timeout: job.timeout,
8614 allow_write: job.allow_write,
8615 unsandboxed: false,
8616 sessions: job.sessions,
8617 artifacts: &job.artifacts,
8618 stem: &job.stem,
8619 run: &run,
8620 node: &node,
8621 cache_dir: cache.as_deref(),
8622 attachments: &attachments,
8623 writable: &[],
8624 },
8625 )
8626 .await;
8627 let out = match out {
8628 Ok(o) if o.usable() => AgentOutcome::Ok(o),
8629 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
8630 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
8638 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
8639 Ok(o) => AgentOutcome::Failed(format!(
8640 "exited with {:?} and no usable output",
8641 o.exit_code
8642 )),
8643 Err(e) => AgentOutcome::Failed(e.to_string()),
8644 };
8645 (i, seat, out)
8646 });
8647 }
8648 drop(spawned_tx);
8649 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
8650 loop {
8651 let joined = tokio::select! {
8652 Some(key) = spawned_rx.recv() => {
8653 state.seat_spawned(&key);
8655 if let Err(e) = state.save() {
8656 tracing::warn!("could not persist a seat's start: {e:#}");
8657 }
8658 continue;
8659 }
8660 joined = set.join_next() => joined,
8661 };
8662 let Some(joined) = joined else {
8663 break;
8664 };
8665 let (i, seat, out) = match joined {
8666 Ok(v) => v,
8667 Err(e) => {
8671 tracing::error!("agent task panicked: {e}");
8672 continue;
8673 }
8674 };
8675 state.seat_finished(&seat.key);
8676 if let AgentOutcome::ResourceBlocked(why) = &out
8677 && state.resource_block.is_none()
8678 {
8679 state.resource_block = Some(crate::run::ResourceBlock {
8680 node: node.to_owned(),
8681 kind: "parallel slot".to_owned(),
8682 detail: why.clone(),
8683 at: Timestamp::now(),
8684 });
8685 state.event("resource", format!("{node}: no agent was started - {why}"));
8686 }
8687 record_jobs(state, node, round, &seat.key, &out);
8688 if let Err(e) = state.save() {
8689 tracing::warn!("could not persist a seat's completion: {e:#}");
8690 }
8691 if collected.len() <= i {
8692 collected.resize_with(i + 1, || None);
8693 }
8694 collected[i] = Some((i, seat, out));
8695 }
8696 if state
8702 .active
8703 .values()
8704 .any(|a| a.node == node && a.attempt == attempt)
8705 {
8706 state
8707 .active
8708 .retain(|_, a| !(a.node == node && a.attempt == attempt));
8709 if let Err(e) = state.save() {
8710 tracing::warn!("could not persist the end of a wave: {e:#}");
8711 }
8712 }
8713 if let Some(cache_dir) = cache
8720 && jobs_had_a_writer
8721 {
8722 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
8723 }
8724 if let Some(guard) = cache_guard {
8725 guard.release();
8726 }
8727 collected.into_iter().flatten().collect()
8728}
8729
8730fn record_jobs(
8741 state: &mut RunState,
8742 node: &str,
8743 round: Option<usize>,
8744 seat: &str,
8745 out: &AgentOutcome,
8746) {
8747 let commands: &[agent::CommandEvidence] = match out {
8748 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
8749 AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
8750 };
8751 let checked_at = Timestamp::now();
8752 for c in commands {
8753 state.jobs.push(JobRecord {
8754 node: node.to_owned(),
8755 round,
8756 seat: seat.to_owned(),
8757 id: c.id.clone(),
8758 description: c.description.clone(),
8759 checked_at,
8760 status: match c.exit_code {
8761 Some(0) => JobStatus::Completed,
8762 Some(_) => JobStatus::Failed,
8763 None => JobStatus::Unknown,
8764 },
8765 exit_code: c.exit_code,
8766 result_summary: c.result_summary.clone(),
8767 source: c.source.clone(),
8768 });
8769 }
8770}
8771
8772fn round_is_clean(
8793 blocking: usize,
8794 e2e_ok: bool,
8795 answered: usize,
8796 expected: usize,
8797 quota_missing: usize,
8798 policy: IncompleteReviewPolicy,
8799) -> bool {
8800 if blocking != 0 || !e2e_ok {
8801 return false;
8802 }
8803 if answered == expected || policy == IncompleteReviewPolicy::Warn {
8804 return true;
8805 }
8806 answered > 0 && expected - answered <= quota_missing
8807}
8808
8809fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
8833 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
8834 return Some(RunStatus::Gating);
8835 }
8836 let last = reviews.last()?;
8837 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
8838 if reviews.len() < max_rounds && !stagnant {
8839 return None;
8840 }
8841 if last.incomplete() && last.blocking == 0 {
8842 return Some(RunStatus::Blocked);
8843 }
8844 if last.e2e_status() == E2eStatus::ResourceBlocked {
8845 return None;
8846 }
8847 Some(if last.e2e.iter().all(CommandOutcome::ok) {
8848 RunStatus::Gating
8849 } else {
8850 RunStatus::Blocked
8851 })
8852}
8853
8854pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
8869 if nudged {
8870 (full / 4).max(Duration::from_secs(120)).min(full)
8871 } else {
8872 full
8873 }
8874}
8875
8876#[allow(clippy::too_many_arguments)]
8905async fn ask_json_wave<T>(
8906 jobs: Vec<SeatJob>,
8907 sem: Arc<Semaphore>,
8908 retries: usize,
8909 roster: &[AgentSpec],
8910 ctx: &WaveCtx<'_>,
8911 losses: &mut Vec<QuotaLoss>,
8912 state: &mut RunState,
8913 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8914) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8915where
8916 T: serde::de::DeserializeOwned + Send + 'static,
8917{
8918 ask_wave_with(
8919 jobs,
8920 sem,
8921 retries,
8922 roster,
8923 ctx,
8924 losses,
8925 state,
8926 &|text: &str| {
8927 let v = verdict::extract_json::<T>(text)?;
8928 validate(&v)?;
8929 Ok(v)
8930 },
8931 )
8932 .await
8933}
8934
8935#[allow(clippy::too_many_arguments)]
8944async fn ask_wave_with<T>(
8945 jobs: Vec<SeatJob>,
8946 sem: Arc<Semaphore>,
8947 retries: usize,
8948 roster: &[AgentSpec],
8949 ctx: &WaveCtx<'_>,
8950 losses: &mut Vec<QuotaLoss>,
8951 state: &mut RunState,
8952 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8953) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8954where
8955 T: Send + 'static,
8956{
8957 let n = jobs.len();
8958 let originals: Vec<SeatJob> = jobs;
8959 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8960 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8961 let mut nudges: Vec<usize> = vec![0; n];
8968 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8972 let mut tried: Vec<BTreeSet<String>> = specs
8973 .iter()
8974 .map(|s| BTreeSet::from([s.id.clone()]))
8975 .collect();
8976 let starts: Vec<usize> = specs
8977 .iter()
8978 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8979 .collect();
8980 let carry = ctx.carry_seats && !roster.is_empty();
8981 let carried: Vec<BTreeSet<String>> = originals
8984 .iter()
8985 .map(|j| {
8986 state
8987 .seat_history
8988 .get(&j.seat.key)
8989 .filter(|_| carry)
8990 .map(|h| h.failed.clone())
8991 .unwrap_or_default()
8992 })
8993 .collect();
8994 let mut prev: Vec<Option<FailClass>> = originals
8995 .iter()
8996 .map(|j| {
8997 state
8998 .seat_history
8999 .get(&j.seat.key)
9000 .filter(|_| carry)
9001 .and_then(|h| h.last_fail.clone())
9002 })
9003 .collect();
9004 let next_agent =
9005 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9006 let others: BTreeSet<String> = specs
9007 .iter()
9008 .enumerate()
9009 .filter(|(j, _)| *j != i)
9010 .map(|(_, s)| s.id.clone())
9011 .collect();
9012 pick_successor(
9013 roster,
9014 starts[i],
9015 tried,
9016 carry.then(|| &carried[i]),
9017 &others,
9018 )
9019 .cloned()
9020 };
9021 let mut fresh: Vec<Option<String>> = vec![None; n];
9022 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9023 let mut pending: Vec<usize> = (0..n).collect();
9024
9025 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9030 for round in 0..max_rounds {
9031 if pending.is_empty() {
9032 break;
9033 }
9034 let mut batch = Vec::with_capacity(pending.len());
9035 let mut renudged: Vec<&str> = Vec::new();
9036 for &i in &pending {
9037 let src = &originals[i];
9038 let full: &str = match &src.handover {
9043 Some(h) if specs[i].id != src.spec.id => h,
9044 _ => &src.prompt,
9045 };
9046 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9049 (
9050 full.to_owned(),
9051 src.timeout,
9052 format!("{}-{word}-{}", src.stem, specs[i].id),
9053 )
9054 } else if nudges[i] == 0 {
9055 (full.to_owned(), src.timeout, src.stem.clone())
9056 } else {
9057 renudged.push(src.seat.key.as_str());
9058 let why = done[i]
9059 .as_ref()
9060 .and_then(|r| r.as_ref().err().map(ToString::to_string))
9061 .unwrap_or_else(|| "no parsable answer".to_owned());
9062 let nudge = prompt::nudge(&why);
9063 let nudged = has_context(&specs[i], &seats[i], src.sessions);
9064 let prompt = if nudged {
9065 nudge
9066 } else {
9067 format!("{full}\n\n---\n\n{nudge}")
9068 };
9069 (
9070 prompt,
9071 retry_budget(src.timeout, nudged),
9072 format!("{}-retry{}", src.stem, nudges[i]),
9073 )
9074 };
9075 batch.push(SeatJob {
9076 spec: specs[i].clone(),
9077 seat: seats[i].clone(),
9078 cwd: src.cwd.clone(),
9079 prompt,
9080 timeout,
9081 allow_write: src.allow_write,
9082 sessions: src.sessions,
9083 artifacts: src.artifacts.clone(),
9084 stem,
9085 handover: None,
9086 });
9087 }
9088
9089 if !renudged.is_empty() {
9090 state.event(
9091 ctx.node,
9092 format!("retry {round}: re-asking {}", renudged.join(", ")),
9093 );
9094 }
9095 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9096 let mut still = Vec::new();
9097 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9098 seats[i] = seat;
9099 if let AgentOutcome::ResourceBlocked(why) = &out {
9102 done[i] = Some(Err(anyhow::anyhow!("{why}")));
9103 continue;
9104 }
9105 let class = FailClass::of(&out);
9106 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9109 && nudges[i] < retries
9110 && !roster.is_empty();
9111 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9112 let next = should_hand_over(prev[i].as_ref(), &cur)
9113 .then(|| next_agent(i, &tried[i], &specs))
9114 .flatten();
9115 if carry {
9116 let h = state
9117 .seat_history
9118 .entry(originals[i].seat.key.clone())
9119 .or_default();
9120 h.failed.insert(specs[i].id.clone());
9121 h.last_fail = Some(cur.clone());
9122 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9123 h.last_ok = None;
9124 }
9125 if let Err(e) = state.save() {
9128 tracing::warn!("could not persist a seat's failure history: {e:#}");
9129 }
9130 }
9131 if let Some(next) = next {
9132 record_handover(
9133 state,
9134 ctx.node,
9135 &originals[i].seat.key,
9136 &specs[i].id,
9137 &next.id,
9138 &cur,
9139 &fail_reason(&out),
9140 );
9141 tried[i].insert(next.id.clone());
9142 prev[i] = Some(cur.clone());
9143 seats[i] =
9144 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9145 specs[i] = next;
9146 fresh[i] = Some(cur.stem_word().to_owned());
9147 nudges[i] = 0;
9148 done[i] = Some(Err(anyhow::anyhow!(
9149 "handed over after: {}",
9150 fail_reason(&out)
9151 )));
9152 still.push(i);
9153 continue;
9154 }
9155 }
9156 if carry
9157 && !nudge_first
9158 && let Some(cur) = class.clone()
9159 {
9160 let h = state
9162 .seat_history
9163 .entry(originals[i].seat.key.clone())
9164 .or_default();
9165 h.failed.insert(specs[i].id.clone());
9166 h.last_fail = Some(cur);
9167 }
9168 let parsed = match out {
9169 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9170 AgentOutcome::Quota(o) => {
9171 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9172 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9173 }
9174 AgentOutcome::Dropped(o) => {
9178 let why = o
9179 .dropped
9180 .as_ref()
9181 .map(|d| d.why.as_str())
9182 .unwrap_or("the CLI ended the stream without delivering its answer");
9183 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9184 }
9185 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9186 Err(anyhow::anyhow!(e))
9187 }
9188 };
9189 let quota = class == Some(FailClass::Quota);
9190 let failed = parsed.is_err();
9191 done[i] = Some(parsed);
9192 if carry && !failed {
9193 let h = state
9194 .seat_history
9195 .entry(originals[i].seat.key.clone())
9196 .or_default();
9197 h.failed.remove(&specs[i].id);
9198 h.last_ok = Some(specs[i].id.clone());
9199 h.last_fail = None;
9200 }
9201 let agent_failure = class.is_some()
9209 && !nudge_first
9210 && !roster.is_empty()
9211 && next_agent(i, &tried[i], &specs).is_some();
9212 if failed && !quota && !agent_failure && nudges[i] < retries {
9213 nudges[i] += 1;
9214 still.push(i);
9215 }
9216 }
9217 pending = still;
9218 }
9219
9220 for (i, q) in last_quota.into_iter().enumerate() {
9223 if let Some(reset) = q {
9224 losses.push(QuotaLoss {
9225 seat: originals[i].seat.key.clone(),
9226 node: ctx.node.to_owned(),
9227 at: Timestamp::now(),
9228 reset,
9229 });
9230 }
9231 }
9232
9233 seats
9234 .into_iter()
9235 .zip(done)
9236 .zip(nudges)
9237 .map(|((seat, res), attempts)| {
9238 (
9239 seat,
9240 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9241 attempts,
9242 )
9243 })
9244 .collect()
9245}
9246
9247fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9249 results
9250 .iter()
9251 .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9252}
9253
9254fn block_wave(
9258 jobs: Vec<SeatJob>,
9259 state: &mut RunState,
9260 node: &str,
9261 attempt: usize,
9262 why: &str,
9263) -> Vec<(usize, SeatState, AgentOutcome)> {
9264 state.resource_wait = None;
9265 state
9266 .active
9267 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9268 if let Err(e) = state.save() {
9269 tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9270 }
9271 jobs.into_iter()
9272 .enumerate()
9273 .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9274 .collect()
9275}
9276
9277async fn acquire_cache_lease(
9290 state: &mut RunState,
9291 cache_dir: &Path,
9292 owner: &crate::cache::Owner,
9293 budget: Duration,
9294 context: &str,
9295 cancel: &(dyn Fn() -> bool + Sync),
9296) -> Result<Option<crate::cache::Guard>> {
9297 let home = crate::run::home();
9298 let started = Instant::now();
9299 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9300 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9301 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9302 Err(e) => {
9303 state.event(
9304 "verify",
9305 format!("{context}: could not check the shared build cache: {e:#}"),
9306 );
9307 if let Err(e2) = state.save() {
9308 tracing::warn!("could not persist a cache-check failure: {e2:#}");
9309 }
9310 return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9311 }
9312 };
9313 state.event(
9314 "verify",
9315 format!(
9316 "{context}: waiting for the shared build cache at {} ({})",
9317 cache_dir.display(),
9318 busy.describe()
9319 ),
9320 );
9321 state.resource_wait = Some(crate::run::ResourceWait {
9324 node: context.to_owned(),
9325 kind: "shared build cache".to_owned(),
9326 holder: busy.describe(),
9327 since: Timestamp::now(),
9328 limit_secs: budget.as_secs(),
9329 });
9330 if let Err(e) = state.save() {
9331 tracing::warn!("could not persist a cache wait: {e:#}");
9332 }
9333 let remaining = budget.saturating_sub(started.elapsed());
9334 let waited = crate::cache::wait_for_unless(
9335 &home,
9336 cache_dir,
9337 owner,
9338 remaining,
9339 Duration::from_secs(5),
9340 cancel,
9341 )
9342 .await;
9343 state.resource_wait = None;
9344 match waited {
9345 Ok(g) => Ok(g),
9346 Err(e) => {
9347 state.event("verify", format!("{context}: {e:#}"));
9348 if let Err(e2) = state.save() {
9349 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9350 }
9351 Err(e)
9352 }
9353 }
9354}
9355
9356#[derive(Debug)]
9361struct CacheCheckError(String);
9362
9363impl std::fmt::Display for CacheCheckError {
9364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9365 f.write_str(&self.0)
9366 }
9367}
9368
9369impl std::error::Error for CacheCheckError {}
9370
9371#[allow(clippy::too_many_arguments)]
9392async fn with_cache_lease<'s, F, Fut>(
9393 state: &'s mut RunState,
9394 cache_dir: Option<&Path>,
9395 node: &str,
9396 seat: &str,
9397 worktree: &Path,
9398 head: &str,
9399 budget: Duration,
9400 context: &str,
9401 body: F,
9402) -> (Vec<CommandOutcome>, bool)
9403where
9404 F: FnOnce(&'s mut RunState, Duration) -> Fut,
9405 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9406{
9407 let Some(cache_dir) = cache_dir else {
9408 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9409 return (outcomes, retried);
9410 };
9411 let home = crate::run::home();
9412 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9413 let started = Instant::now();
9414 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9415 .await
9416 .and_then(|g| {
9417 g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9418 }) {
9419 Ok(g) => g,
9420 Err(e) => {
9421 return (
9422 vec![CommandOutcome {
9423 command: "(waiting for the shared build cache)".to_owned(),
9424 code: None,
9425 output_tail: e.to_string(),
9426 duration_ms: started.elapsed().as_millis() as u64,
9427 resource_blocked: true,
9428 }],
9429 false,
9430 );
9431 }
9432 };
9433 let identity = crate::cache::Identity::new(worktree, head);
9434 if let Err(e) = crate::cache::ensure_fresh(
9435 &home,
9436 cache_dir,
9437 &identity,
9438 &state.config.verify.cargo_manifests(),
9439 ) {
9440 state.event(
9448 "verify",
9449 format!(
9450 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9451 worktree.display(),
9452 short(head)
9453 ),
9454 );
9455 guard.release();
9456 return (
9457 vec![CommandOutcome {
9458 command: "(confirming the shared build cache is fresh)".to_owned(),
9459 code: None,
9460 output_tail: e.to_string(),
9461 duration_ms: started.elapsed().as_millis() as u64,
9462 resource_blocked: true,
9463 }],
9464 false,
9465 );
9466 }
9467 let remaining = budget.saturating_sub(started.elapsed());
9468 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9469 if !timed_out_pids.is_empty() {
9474 wait_for_timed_out_children_to_die(&timed_out_pids).await;
9475 }
9476 guard.release();
9477 (outcomes, retried)
9478}
9479
9480async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9492 wait_for_pids_with(
9493 pids,
9494 crate::proc::pid_alive,
9495 LEASE_RELEASE_POLL,
9496 LEASE_RELEASE_MAX_WAIT,
9497 )
9498 .await;
9499}
9500
9501async fn wait_for_pids_with<F: Fn(u32) -> bool>(
9507 pids: &[u32],
9508 alive: F,
9509 poll: Duration,
9510 max_wait: Duration,
9511) {
9512 let deadline = Instant::now() + max_wait;
9513 loop {
9514 if pids.iter().all(|&pid| !alive(pid)) {
9515 return;
9516 }
9517 if Instant::now() >= deadline {
9518 return;
9519 }
9520 tokio::time::sleep(poll).await;
9521 }
9522}
9523
9524fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
9531 outcomes.iter().any(|o| o.resource_blocked)
9532}
9533
9534enum GateFix {
9536 Retry,
9538 Stop,
9541 Defer,
9544}
9545
9546fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
9554 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
9555 red.peek().is_some()
9556 && red.all(|o| {
9557 !o.resource_blocked
9558 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
9559 && !o.output_tail.trim().is_empty()
9560 })
9561}
9562
9563fn e2e_outcome_label(o: &CommandOutcome) -> String {
9567 if o.ok() {
9568 return "pass".to_owned();
9569 }
9570 let reason = if o.build_failed() {
9571 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
9572 } else {
9573 format!("FAIL ({:?})", o.code)
9574 };
9575 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
9576}
9577
9578async fn run_e2e_with_retry(
9586 state: &mut RunState,
9587 shell: &[String],
9588 commands: &[String],
9589 worktree: &Path,
9590 timeout: Duration,
9591 context: &str,
9592) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
9593 let (mut e2e, mut timed_out_pids) = run_commands(
9594 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
9595 )
9596 .await;
9597 for o in &e2e {
9598 state.event(
9599 "verify",
9600 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
9601 );
9602 }
9603 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
9607 if verify_retried {
9608 state.event(
9609 "verify",
9610 format!(
9611 "{context}: verify could not build/link, not a test result — retrying once \
9612 before concluding"
9613 ),
9614 );
9615 let retried = run_commands(
9616 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
9617 )
9618 .await;
9619 e2e = retried.0;
9620 timed_out_pids.extend(retried.1);
9623 for o in &e2e {
9624 state.event(
9625 "verify",
9626 format!(
9627 "{context}: retry `{}` -> {}",
9628 o.command,
9629 e2e_outcome_label(o)
9630 ),
9631 );
9632 }
9633 }
9634 (e2e, verify_retried, timed_out_pids)
9635}
9636
9637#[allow(clippy::too_many_arguments)]
9653async fn run_commands(
9654 state: &mut RunState,
9655 node: &str,
9656 task: &str,
9657 attempt: usize,
9658 shell: &[String],
9659 commands: &[String],
9660 cwd: &Path,
9661 timeout: Duration,
9662) -> (Vec<CommandOutcome>, Vec<u32>) {
9663 if commands.is_empty() {
9664 return (Vec::new(), Vec::new());
9669 }
9670 let mut out = Vec::new();
9671 let mut timed_out_pids = Vec::new();
9672 let total = commands.len();
9673 for (idx, command) in commands.iter().enumerate() {
9674 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
9675 if let Err(e) = state.save() {
9676 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
9677 }
9678 let started = Instant::now();
9679 let mut cmd = tokio::process::Command::new(&shell[0]);
9680 cmd.quiet();
9681 cmd.args(&shell[1..])
9682 .arg(command)
9683 .current_dir(cwd)
9684 .stdin(std::process::Stdio::null())
9685 .stdout(std::process::Stdio::piped())
9686 .stderr(std::process::Stdio::piped())
9687 .kill_on_drop(true);
9688 let spawned = cmd.spawn();
9689 let (code, body) = match spawned {
9690 Ok(child) => {
9691 let pid = child.id();
9696 match tokio::time::timeout(timeout, child.wait_with_output()).await {
9697 Ok(Ok(o)) => {
9698 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
9699 body.push_str(&String::from_utf8_lossy(&o.stderr));
9700 (o.status.code(), body)
9701 }
9702 Ok(Err(e)) => (None, format!("failed to run: {e}")),
9703 Err(_) => {
9704 if let Some(pid) = pid {
9705 timed_out_pids.push(pid);
9706 }
9707 (None, format!("timed out after {}s", timeout.as_secs()))
9708 }
9709 }
9710 }
9711 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
9712 };
9713 out.push(CommandOutcome {
9714 command: command.clone(),
9715 code,
9716 output_tail: tail(&body, OUTPUT_TAIL),
9717 duration_ms: started.elapsed().as_millis() as u64,
9718 resource_blocked: false,
9719 });
9720 }
9721 state.task_finished(task);
9722 if let Err(e) = state.save() {
9723 tracing::warn!("could not persist the end of {task}: {e:#}");
9724 }
9725 (out, timed_out_pids)
9726}
9727
9728fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
9740 let repo = repo.display();
9741 match style {
9742 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
9743 MergeStyle::Squash => {
9744 let subject = message
9747 .lines()
9748 .next()
9749 .unwrap_or(branch)
9750 .replace(['\\', '"', '$', '`'], "");
9751 format!(
9752 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
9753 )
9754 }
9755 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
9756 }
9757}
9758
9759const PR_TITLE_MAX: usize = 240;
9770
9771fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
9782 let line = queue::first_line(text)?;
9783 let chars: Vec<char> = line.chars().collect();
9784 let (kept, mark) = if chars.len() <= max {
9785 (line.to_owned(), "")
9786 } else if let Some(end) = sentence_end(&chars, max) {
9787 (chars[..end].iter().collect::<String>(), "")
9788 } else {
9789 let room = max.saturating_sub(3);
9790 let cut = if chars[room].is_whitespace() {
9793 room
9794 } else {
9795 chars[..room]
9796 .iter()
9797 .rposition(|c| c.is_whitespace())
9798 .unwrap_or(room)
9799 };
9800 let head: String = chars[..cut].iter().collect();
9801 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
9802 (head.to_owned(), "...")
9803 };
9804 if kept.is_empty() {
9805 return None;
9806 }
9807 if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
9808 return None;
9809 }
9810 if !ascii_only && !kept.chars().any(char::is_alphabetic) {
9811 return None;
9812 }
9813 Some(format!("{kept}{mark}"))
9814}
9815
9816fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
9820 const MIN: usize = 20;
9821 for i in MIN..max.min(chars.len()) {
9822 if !matches!(chars[i], '.' | '!' | '?') {
9823 continue;
9824 }
9825 let Some(&next) = chars.get(i + 1) else {
9826 continue;
9827 };
9828 if !next.is_whitespace() {
9829 continue;
9830 }
9831 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
9832 if after.is_some_and(|c| c.is_ascii_lowercase()) {
9833 continue;
9834 }
9835 let word: String = chars[..i]
9836 .iter()
9837 .rev()
9838 .take_while(|c| !c.is_whitespace())
9839 .collect::<Vec<_>>()
9840 .into_iter()
9841 .rev()
9842 .collect();
9843 let word = word.to_ascii_lowercase();
9844 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
9845 continue;
9846 }
9847 let end = chars[..i]
9848 .iter()
9849 .rposition(|c| !c.is_whitespace())
9850 .map_or(i, |p| p + 1);
9851 return Some(end);
9852 }
9853 None
9854}
9855
9856struct PrMessage {
9861 title: String,
9862 body: String,
9863}
9864
9865impl PrMessage {
9866 fn commit_message(&self) -> String {
9870 format!("{}\n\n{}", self.title, self.body)
9871 }
9872}
9873
9874fn title_marker(line: &str) -> Option<&str> {
9876 let line = line.trim();
9877 let head = line.get(..6)?;
9878 head.eq_ignore_ascii_case("title:")
9879 .then(|| line[6..].trim())
9880}
9881
9882fn summary_title(summary: &str) -> Option<String> {
9887 let first = summary.lines().find(|l| !l.trim().is_empty())?;
9888 let raw = title_marker(first)?;
9889 if raw.is_empty() {
9890 return None;
9891 }
9892 let title = queue::title_from(raw, PR_TITLE_MAX);
9893 let lower = title.to_ascii_lowercase();
9894 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9895 return None;
9896 }
9897 Some(title)
9898}
9899
9900const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
9902
9903const EXISTING_BRANCH: &str = "(existing branch)";
9905
9906fn is_review_run(state: &RunState) -> bool {
9910 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
9911}
9912
9913fn review_title(state: &RunState) -> Option<String> {
9918 english_subject(
9919 state.reviewed_commits.as_ref()?.first()?,
9920 state.config.graph.github_english(),
9921 )
9922}
9923
9924fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
9927 let raw = raw.trim();
9928 if raw.is_empty() {
9929 return None;
9930 }
9931 if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
9932 return None;
9933 }
9934 if !ascii_only && !raw.chars().any(char::is_alphabetic) {
9935 return None;
9936 }
9937 let title = queue::title_from(raw, PR_TITLE_MAX);
9938 let lower = title.to_ascii_lowercase();
9939 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9940 return None;
9941 }
9942 Some(title)
9943}
9944
9945#[derive(Debug, Clone, PartialEq, Eq)]
9948struct BranchFacts {
9949 commits: Vec<(String, String)>,
9951 stat: String,
9953}
9954
9955const STAT_FILE_LINES: usize = 25;
9957const COMMITS_MAX_CHARS: usize = 20_000;
9959
9960async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
9963 let commits = git::commit_log(repo, base, branch).await.ok()?;
9964 if commits.is_empty() {
9965 return None;
9966 }
9967 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
9968 let lines: Vec<&str> = stat.lines().collect();
9969 let stat = if lines.len() > STAT_FILE_LINES + 1 {
9970 let omitted = lines.len() - 1 - STAT_FILE_LINES;
9971 let more = format!(" ... {omitted} more file(s)");
9972 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
9973 kept.push(&more);
9974 kept.push(lines[lines.len() - 1]);
9975 kept.join("\n")
9976 } else {
9977 lines.join("\n")
9978 };
9979 Some(BranchFacts { commits, stat })
9980}
9981
9982fn markdown_safe(text: &str) -> String {
9985 text.replace("</details>", "</details>")
9986 .replace("\x60\x60\x60", "~~~")
9987}
9988
9989fn neutral_title(state: &RunState, winner: char) -> String {
9990 format!(
9991 "chore: land candidate {} of run {}",
9992 winner.to_ascii_uppercase(),
9993 state.id
9994 )
9995}
9996
9997pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10003 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10004 ""
10005 } else {
10006 pr_title
10007 }
10008}
10009
10010pub fn landing_subject_source(state: &RunState) -> String {
10014 if is_review_run(state) {
10015 let winner = state.candidates.first().map_or('A', |c| c.label);
10016 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10017 }
10018 state.instruction.clone()
10019}
10020
10021fn summary_without_title(summary: &str) -> String {
10024 let mut lines = summary.trim().lines().peekable();
10025 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10026 lines.next();
10027 }
10028 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10029}
10030
10031#[cfg(test)]
10045fn pr_message(state: &RunState, winner: char) -> PrMessage {
10046 pr_message_with(state, winner, None)
10047}
10048
10049#[cfg(test)]
10052fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10053 let raw = pr_message_raw(
10054 state,
10055 winner,
10056 facts,
10057 crate::pr_outcome::FinalHead::Unreported,
10058 ChangeNote::NotAsked,
10059 );
10060 let id = crate::scrub::Identity::current();
10061 PrMessage {
10062 title: crate::scrub::scrub(&raw.title, &id),
10063 body: crate::scrub::scrub(&raw.body, &id),
10064 }
10065}
10066
10067#[derive(Debug, Clone, Copy)]
10069enum ChangeNote<'a> {
10070 NotAsked,
10074 Summary(&'a crate::run::ChangeSummary),
10076 Unavailable(&'a str),
10078}
10079
10080fn pr_message_raw(
10081 state: &RunState,
10082 winner: char,
10083 facts: Option<&BranchFacts>,
10084 head: crate::pr_outcome::FinalHead<'_>,
10085 change: ChangeNote<'_>,
10086) -> PrMessage {
10087 let summary = state
10088 .candidates
10089 .iter()
10090 .find(|c| c.label == winner)
10091 .map(|c| c.summary.as_str())
10092 .unwrap_or_default();
10093 let review = is_review_run(state);
10096 let generated_title = match change {
10097 ChangeNote::Summary(c) if review && !c.title.trim().is_empty() => Some(c.title.clone()),
10098 _ => None,
10099 };
10100 let title = if let Some(t) = generated_title {
10101 t
10102 } else if review {
10103 facts
10104 .and_then(|f| {
10105 english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10106 })
10107 .or_else(|| review_title(state))
10108 .or_else(|| {
10109 state
10110 .candidates
10111 .iter()
10112 .find(|c| c.label == winner)
10113 .filter(|c| !c.branch.starts_with("magi/"))
10114 .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10115 })
10116 .unwrap_or_else(|| neutral_title(state, winner))
10117 } else {
10118 summary_title(summary).unwrap_or_else(|| {
10119 english_title(
10120 &state.instruction,
10121 PR_TITLE_MAX,
10122 state.config.graph.github_english(),
10123 )
10124 .unwrap_or_else(|| neutral_title(state, winner))
10125 })
10126 };
10127
10128 let mut body = String::new();
10129 let what = summary_without_title(summary);
10130 if review {
10131 match change {
10132 ChangeNote::Summary(c) => {
10133 body.push_str("## Summary\n\n");
10134 body.push_str(c.body.trim());
10135 body.push_str("\n\n");
10136 }
10137 ChangeNote::Unavailable(why) => body.push_str(&format!(
10138 "## Summary\n\nNo description of what this change does could be generated ({why}). \
10139 The commit list and diff stat below only list what the branch carries; they do not \
10140 explain it.\n\n"
10141 )),
10142 ChangeNote::NotAsked => {}
10143 }
10144 }
10145 if !what.is_empty() {
10146 body.push_str("## Summary\n\n");
10147 body.push_str(&what);
10148 body.push_str("\n\n");
10149 }
10150
10151 body.push_str(&crate::pr_outcome::review_fixes(state, head).0);
10155 body.push_str(&crate::pr_outcome::final_verification(state, head));
10156 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10157
10158 let open = state.open_findings();
10159 if !open.is_empty() {
10160 body.push_str("## Open review findings\n\n");
10161 for f in &open {
10162 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
10163 }
10164 body.push('\n');
10165 }
10166
10167 if let Some(fix) = fix
10168 && !fix.rejected.is_empty()
10169 {
10170 body.push_str("## Declined by the fixer\n\n");
10171 for r in &fix.rejected {
10172 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
10173 }
10174 body.push('\n');
10175 }
10176
10177 if review {
10178 body.push_str("## Commits under review\n\n");
10180 if let Some(facts) = facts {
10181 let mut left = COMMITS_MAX_CHARS;
10182 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10183 let mut entry = format!("- {}\n", markdown_safe(subject));
10184 for l in markdown_safe(text).lines() {
10185 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10186 }
10187 if left == 0 {
10188 body.push_str(&format!(
10189 "- ... {} more commit(s)\n",
10190 facts.commits.len() - i
10191 ));
10192 break;
10193 }
10194 if entry.len() > left {
10195 let mut end = left;
10198 while !entry.is_char_boundary(end) {
10199 end -= 1;
10200 }
10201 entry.truncate(end);
10202 entry.push_str("\n ... (truncated)\n");
10203 left = 0;
10204 } else {
10205 left -= entry.len();
10206 }
10207 body.push_str(&entry);
10208 }
10209 if !facts.stat.trim().is_empty() {
10210 body.push_str(&format!(
10211 "\n## Diff stat\n\n```\n{}\n```\n",
10212 markdown_safe(facts.stat.trim())
10213 ));
10214 }
10215 } else {
10216 match &state.reviewed_commits {
10217 Some(subjects) => {
10218 for s in subjects {
10219 body.push_str(&format!("- {}\n", s.trim()));
10220 }
10221 }
10222 None => {
10223 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10226 body.push_str(rest.trim());
10227 body.push('\n');
10228 }
10229 }
10230 }
10231 } else {
10232 let task = state.instruction.trim();
10233 let task = if task.is_empty() {
10234 "(empty task)"
10235 } else {
10236 task
10237 };
10238 body.push_str(&format!(
10239 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10240 task.replace("</details>", "</details>")
10241 ));
10242 }
10243
10244 body.push_str(&format!(
10245 "\n---\nmagi:run/{} magi:candidate-{}\n",
10246 state.id,
10247 winner.to_ascii_lowercase()
10248 ));
10249
10250 PrMessage { title, body }
10251}
10252
10253fn seeded_instruction(state: &RunState) -> String {
10257 match refs::describe(&state.seeds) {
10258 Some(facts) => format!(
10259 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10260 Candidates start from the unmerged branch named above, when there \
10261 is one, and carry any unmerged commit named by sha as a \
10262 cherry-pick. Check that this is what the task meant before \
10263 building on it.",
10264 state.instruction
10265 ),
10266 None => state.instruction.clone(),
10267 }
10268}
10269
10270async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10274 let base = &state.base_branch;
10275 let mut against = base.clone();
10276 if mode == MergeMode::Pr {
10277 let remote = &state.config.merge.remote;
10281 let tracking = format!("{remote}/{base}");
10282 let fetched = git::fetch(repo, remote, base).await;
10283 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10284 against = tracking;
10285 } else {
10286 return false;
10287 }
10288 }
10289 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10290}
10291
10292fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10295 let mut detail = format!(
10296 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10297 nothing to open a pull request for"
10298 );
10299 match refs::describe(&state.seeds) {
10300 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10301 None => detail.push_str(
10302 "\nThe task names no existing branch or commit; if it means to land work \
10303 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10304 ),
10305 }
10306 detail
10307}
10308
10309#[derive(Debug, PartialEq, Eq)]
10312enum PrPlan {
10313 Create,
10314 Adopt { url: String, title: String },
10315 Stop(String),
10316}
10317
10318fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10321 match found {
10322 Ok(land::OpenPr::None) => PrPlan::Create,
10323 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10324 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10325 "several open pull requests exist for this branch, not picking one: {}",
10326 urls.join(" ")
10327 )),
10328 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10329 }
10330}
10331
10332pub(crate) async fn gh_pr_create(
10334 cwd: &Path,
10335 base: &str,
10336 head: &str,
10337 title: &str,
10338 body: &str,
10339) -> Result<String> {
10340 let out = tokio::process::Command::new("gh")
10341 .args([
10342 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10343 ])
10344 .current_dir(cwd)
10345 .quiet()
10346 .stdin(std::process::Stdio::null())
10347 .output()
10348 .await
10349 .context("spawn gh")?;
10350 if out.status.success() {
10351 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10352 } else {
10353 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10354 }
10355}
10356
10357pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10366 let repo = state.repo.clone();
10367 let root = state.worktree_root();
10368 let winner = state.tally.as_ref().map(|t| t.winner);
10369 let mut removed = Vec::new();
10370
10371 for i in 0..state.candidates.len() {
10372 let c = state.candidates[i].clone();
10373 let is_winner = Some(c.label) == winner;
10374 if is_winner && !drop_winner {
10375 continue;
10376 }
10377 if c.worktree.exists() {
10378 git::worktree_remove(&repo, &c.worktree).await.ok();
10379 removed.push(c.worktree.to_string_lossy().into_owned());
10380 }
10381 let handed_over = state.released_branches.contains(&c.branch);
10384 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10385 git::branch_delete(&repo, &c.branch).await.ok();
10386 removed.push(c.branch.clone());
10387 }
10388 state.candidates[i].folded = true;
10389 }
10390
10391 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10392 let path = name.path();
10393 let keep = !drop_winner
10394 && winner.is_some_and(|w| {
10395 path.file_name()
10396 .is_some_and(|n| n == format!("cand-{w}").as_str())
10397 });
10398 if keep {
10399 continue;
10400 }
10401 git::worktree_remove(&repo, &path).await.ok();
10402 removed.push(path.to_string_lossy().into_owned());
10403 }
10404
10405 remove_if_empty(&root);
10414
10415 if state.enabled_worktree_config && drop_winner {
10416 git::release_worktree_config(&repo).await.ok();
10420 state.enabled_worktree_config = false;
10421 }
10422 state.save_under(home)?;
10423 Ok(removed)
10424}
10425
10426fn remove_if_empty(dir: &Path) {
10437 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10438 std::fs::remove_dir(dir).ok();
10439 }
10440}
10441
10442pub fn worst_open(state: &RunState) -> Option<Severity> {
10444 state
10445 .reviews
10446 .last()?
10447 .reviews
10448 .iter()
10449 .flat_map(|r| r.findings.iter())
10450 .map(|f| f.severity)
10451 .max()
10452}
10453
10454async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10458 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10459 git::acquire_worktree_config(repo).await?;
10460 state.enabled_worktree_config = true;
10461 }
10462 Ok(())
10463}
10464
10465#[cfg(test)]
10466mod tests {
10467 #[test]
10468 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
10469 let leaked = vec!["chore(deps): update a crate".to_owned()];
10470 let own = "fix(daemon): apply a chosen action";
10471 assert!(should_retitle("", own, &leaked));
10472 assert!(should_retitle(
10473 &format!("{REVIEW_PROMPT_OPENING} `x`"),
10474 own,
10475 &leaked
10476 ));
10477 assert!(should_retitle(
10478 "chore: land candidate A of run 1",
10479 own,
10480 &leaked
10481 ));
10482 assert!(should_retitle(
10483 "magi: candidate A (uncommitted work)",
10484 own,
10485 &leaked
10486 ));
10487 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
10488 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
10489 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
10490 }
10491
10492 #[test]
10493 fn pr_merge_plan_creates_adopts_or_stops() {
10494 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
10495 assert_eq!(
10496 pr_merge_plan(Ok(land::OpenPr::One {
10497 url: "u".into(),
10498 title: "t".into()
10499 })),
10500 PrPlan::Adopt {
10501 url: "u".into(),
10502 title: "t".into()
10503 }
10504 );
10505 let PrPlan::Stop(many) =
10506 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
10507 else {
10508 panic!("many must stop");
10509 };
10510 assert!(many.contains('a') && many.contains('b'));
10511 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
10512 panic!("a failed lookup must stop");
10513 };
10514 assert!(err.contains("bad token"));
10515 }
10516
10517 use super::*;
10518 use crate::run::GateStatus;
10519 use std::collections::BTreeMap;
10520 use std::time::Duration;
10521
10522 fn conductor() -> AgentSpec {
10523 AgentSpec {
10524 id: "conductor".to_owned(),
10525 kind: crate::config::AgentKind::Command,
10526 model: None,
10527 command: vec!["true".to_owned()],
10528 extra_args: Vec::new(),
10529 env: BTreeMap::new(),
10530 prompt_delivery: None,
10531 }
10532 }
10533
10534 fn spec(id: &str) -> AgentSpec {
10535 AgentSpec {
10536 id: id.to_owned(),
10537 kind: crate::config::AgentKind::Command,
10538 model: None,
10539 command: vec!["true".to_owned()],
10540 extra_args: Vec::new(),
10541 env: BTreeMap::new(),
10542 prompt_delivery: None,
10543 }
10544 }
10545
10546 fn ids(xs: &[&str]) -> BTreeSet<String> {
10547 xs.iter().map(|s| (*s).to_owned()).collect()
10548 }
10549
10550 #[test]
10551 fn pick_successor_skips_an_agent_another_seat_holds() {
10552 let roster = [spec("a"), spec("b"), spec("c")];
10554 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10555 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10556 }
10557
10558 #[test]
10559 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
10560 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
10562 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10563 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10564 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
10565 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
10566 }
10567
10568 #[test]
10569 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
10570 let roster = [spec("a"), spec("b")];
10571 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10572 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10573 let carried = ids(&["b"]);
10574 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
10575 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10576 }
10577
10578 #[test]
10579 fn pick_successor_returns_none_without_any_untried_successor() {
10580 let roster = [spec("a"), spec("b")];
10581 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
10582 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
10583 let carried = ids(&["a"]);
10584 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
10585 }
10586
10587 #[test]
10588 fn pick_successor_rescue_avoids_another_seats_occupant() {
10589 let roster = [spec("a"), spec("b"), spec("c")];
10591 let carried = ids(&["b", "c"]);
10592 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
10593 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10594 }
10595
10596 #[test]
10597 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
10598 let roster = [spec("a"), spec("b"), spec("c")];
10599 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
10600 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10601 }
10602
10603 #[test]
10604 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
10605 let roster = [spec("a"), spec("b"), spec("c")];
10606 let failed = ids(&["a", "b", "c"]);
10607 let mut tried = ids(&["b"]);
10609 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10610 assert_eq!(first.id, "a");
10611 tried.insert(first.id.clone());
10612 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10613 assert_eq!(second.id, "c");
10614 tried.insert(second.id.clone());
10615 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
10616 }
10617
10618 #[test]
10619 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
10620 let roster = [spec("a"), spec("b")];
10621 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
10622 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10623 }
10624
10625 #[test]
10626 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
10627 let roster = [spec("a"), spec("b"), spec("c")];
10628 let h = SeatHistory {
10629 failed: ids(&["a"]),
10630 last_ok: Some("b".to_owned()),
10631 last_fail: None,
10632 };
10633 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
10634 let gone = SeatHistory {
10636 last_ok: Some("zzz".to_owned()),
10637 ..h.clone()
10638 };
10639 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
10640 let failed = SeatHistory {
10641 failed: ids(&["a", "b"]),
10642 last_ok: Some("b".to_owned()),
10643 last_fail: None,
10644 };
10645 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
10646 }
10647
10648 #[test]
10649 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
10650 let first = SeatState::new("review-1", "alpha", 7);
10651 let next = handover_seat("review-1", "gamma", 7);
10652 assert_ne!(first.claude_session, next.claude_session);
10653 }
10654
10655 #[test]
10656 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
10657 let mut state = state_with_summary("x", "y");
10658 let a = handover_seat("review-1", "beta", state.next_seat_seed());
10659 let b = handover_seat("review-1", "beta", state.next_seat_seed());
10660 assert_ne!(a.claude_session, b.claude_session);
10661 }
10662
10663 #[test]
10664 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
10665 let roster = [spec("a"), spec("b")];
10666 let h = SeatHistory {
10667 failed: ids(&["a", "b"]),
10668 ..SeatHistory::default()
10669 };
10670 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
10671 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
10672 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
10673 }
10674
10675 #[test]
10682 fn failure_signature_ignores_numbers_and_paths() {
10683 assert_eq!(
10684 failure_signature("exited with Some(2) and no usable output"),
10685 failure_signature("exited with Some(137) and no usable output")
10686 );
10687 assert_eq!(
10688 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
10689 failure_signature("cannot open /var/x.txt: denied")
10690 );
10691 assert_ne!(failure_signature("boom"), failure_signature("bang"));
10692 }
10693
10694 #[test]
10695 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
10696 let other = FailClass::Other("x".into());
10697 assert!(should_hand_over(None, &FailClass::Quota));
10698 assert!(should_hand_over(Some(&other), &FailClass::Quota));
10699 assert!(should_hand_over(
10700 Some(&FailClass::Timeout),
10701 &FailClass::Timeout
10702 ));
10703 assert!(should_hand_over(None, &other));
10704 assert!(!should_hand_over(Some(&other), &other));
10705 assert!(should_hand_over(
10706 Some(&other),
10707 &FailClass::Other("y".into())
10708 ));
10709 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
10711 assert!(should_hand_over(Some(&FailClass::Quota), &other));
10712 }
10713
10714 #[test]
10715 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
10716 let a = SeatState::new("judge-1", "alpha", 7);
10717 let b = handover_seat("judge-1", "beta", 7);
10718 assert_ne!(a.claude_session, b.claude_session);
10719 assert_eq!(b.turns, 0);
10720 }
10721
10722 #[test]
10723 fn a_timeout_is_classified_apart_from_other_failures() {
10724 assert_eq!(
10725 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
10726 Some(FailClass::Timeout)
10727 );
10728 assert!(matches!(
10729 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
10730 Some(FailClass::Other(_))
10731 ));
10732 }
10733
10734 #[test]
10735 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
10736 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10737 let tried = BTreeSet::from(["beta".to_owned()]);
10738 let next = next_untried_in_roster(&roster, 1, &tried);
10741 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
10742 }
10743
10744 #[test]
10745 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
10746 let roster = vec![spec("alpha"), spec("beta")];
10747 let tried = BTreeSet::from(["beta".to_owned()]);
10748 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10752 }
10753
10754 #[test]
10755 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
10756 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10757 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
10758 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10762 }
10763
10764 #[test]
10765 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
10766 let roster = vec![spec("a"), spec("a"), spec("b")];
10767 let tried = BTreeSet::from(["a".to_owned()]);
10768 let next = next_untried_in_roster(&roster, 0, &tried);
10769 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10770 }
10771
10772 #[test]
10773 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
10774 let roster = vec![spec("a"), spec("b")];
10775 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
10776 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
10777 }
10778
10779 #[test]
10780 fn remove_if_empty_only_ever_takes_a_bare_directory() {
10781 let dir = tempfile::tempdir().unwrap();
10782 let bay = dir.path().join("ffff");
10783
10784 remove_if_empty(&bay);
10786 assert!(!bay.exists());
10787
10788 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
10791 remove_if_empty(&bay);
10792 assert!(bay.exists(), "non-empty directory must survive");
10793
10794 std::fs::remove_dir(bay.join("cand-A")).unwrap();
10796 remove_if_empty(&bay);
10797 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
10798 }
10799
10800 #[test]
10809 fn a_full_panel_that_found_nothing_is_clean() {
10810 assert!(round_is_clean(
10811 0,
10812 true,
10813 2,
10814 2,
10815 0,
10816 IncompleteReviewPolicy::Block
10817 ));
10818 }
10819
10820 #[test]
10821 fn a_missing_seat_is_never_clean_under_the_default_policy() {
10822 assert!(!round_is_clean(
10823 0,
10824 true,
10825 1,
10826 2,
10827 0,
10828 IncompleteReviewPolicy::Block
10829 ));
10830 }
10831
10832 #[test]
10833 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
10834 assert!(!round_is_clean(
10835 1,
10836 true,
10837 1,
10838 2,
10839 0,
10840 IncompleteReviewPolicy::Warn
10841 ));
10842 }
10843
10844 #[test]
10845 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
10846 assert!(round_is_clean(
10847 0,
10848 true,
10849 1,
10850 2,
10851 0,
10852 IncompleteReviewPolicy::Warn
10853 ));
10854 }
10855
10856 #[test]
10857 fn a_full_panel_with_an_open_finding_is_not_clean() {
10858 assert!(!round_is_clean(
10859 1,
10860 true,
10861 2,
10862 2,
10863 0,
10864 IncompleteReviewPolicy::Block
10865 ));
10866 }
10867
10868 #[test]
10869 fn a_full_panel_with_a_red_e2e_is_not_clean() {
10870 assert!(!round_is_clean(
10871 0,
10872 false,
10873 2,
10874 2,
10875 0,
10876 IncompleteReviewPolicy::Block
10877 ));
10878 }
10879
10880 #[test]
10887 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
10888 assert!(round_is_clean(
10891 0,
10892 true,
10893 1,
10894 2,
10895 1,
10896 IncompleteReviewPolicy::Block
10897 ));
10898 }
10899
10900 #[test]
10901 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
10902 assert!(!round_is_clean(
10905 0,
10906 true,
10907 1,
10908 2,
10909 0,
10910 IncompleteReviewPolicy::Block
10911 ));
10912 }
10913
10914 #[test]
10915 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
10916 assert!(!round_is_clean(
10917 1,
10918 true,
10919 1,
10920 2,
10921 1,
10922 IncompleteReviewPolicy::Block
10923 ));
10924 assert!(!round_is_clean(
10925 0,
10926 false,
10927 1,
10928 2,
10929 1,
10930 IncompleteReviewPolicy::Block
10931 ));
10932 }
10933
10934 #[test]
10935 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
10936 assert!(!round_is_clean(
10940 0,
10941 true,
10942 0,
10943 2,
10944 2,
10945 IncompleteReviewPolicy::Block
10946 ));
10947 }
10948
10949 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
10950 CommandOutcome {
10951 command: "test".to_owned(),
10952 code,
10953 output_tail: String::new(),
10954 duration_ms: 0,
10955 resource_blocked,
10956 }
10957 }
10958
10959 #[test]
10960 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
10961 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
10962 assert!(
10963 !verify_inconclusive(&[outcome(Some(1), false)]),
10964 "an ordinary failure is still evidence about the patch"
10965 );
10966 assert!(verify_inconclusive(&[outcome(None, true)]));
10967 assert!(
10968 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
10969 "one inconclusive outcome taints the whole batch"
10970 );
10971 assert!(!verify_inconclusive(&[]));
10972 }
10973
10974 #[tokio::test]
10975 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
10976 let calls = std::sync::atomic::AtomicUsize::new(0);
10980 let started = Instant::now();
10981 wait_for_pids_with(
10982 &[123],
10983 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
10984 Duration::from_millis(5),
10985 Duration::from_secs(5),
10986 )
10987 .await;
10988 assert!(
10989 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
10990 "must keep checking rather than deciding on the first answer"
10991 );
10992 assert!(
10993 started.elapsed() < Duration::from_secs(1),
10994 "must return the moment it is confirmed dead, not wait out the ceiling"
10995 );
10996 }
10997
10998 #[tokio::test]
10999 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11000 let started = Instant::now();
11001 wait_for_pids_with(
11002 &[123],
11003 |_| true, Duration::from_millis(5),
11005 Duration::from_millis(30),
11006 )
11007 .await;
11008 let elapsed = started.elapsed();
11009 assert!(
11010 elapsed >= Duration::from_millis(30),
11011 "must not give up before its own ceiling: {elapsed:?}"
11012 );
11013 assert!(
11014 elapsed < Duration::from_secs(1),
11015 "must not wait past its own ceiling either: {elapsed:?}"
11016 );
11017 }
11018
11019 #[tokio::test]
11020 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11021 let started = Instant::now();
11022 wait_for_pids_with(
11023 &[],
11024 |_| true,
11025 Duration::from_secs(5),
11026 Duration::from_secs(5),
11027 )
11028 .await;
11029 assert!(
11030 started.elapsed() < Duration::from_millis(200),
11031 "an empty pid list has nothing to confirm"
11032 );
11033 }
11034
11035 fn review_round(
11041 clean: bool,
11042 blocking: usize,
11043 answered: usize,
11044 expected: usize,
11045 progressed: bool,
11046 e2e_ok: bool,
11047 ) -> ReviewRound {
11048 ReviewRound {
11049 round: 1,
11050 head: "h".to_owned(),
11051 verified_head: None,
11052 verified_at: None,
11053 reviews: Vec::new(),
11054 e2e: vec![CommandOutcome {
11055 command: "test".to_owned(),
11056 code: Some(if e2e_ok { 0 } else { 1 }),
11057 output_tail: String::new(),
11058 duration_ms: 0,
11059 resource_blocked: false,
11060 }],
11061 verify_retried: false,
11062 e2e_deferred: false,
11063 e2e_defer_reason: None,
11064 fix: None,
11065 blocking,
11066 answered,
11067 expected,
11068 clean,
11069 progressed,
11070 vote_split: false,
11071 reconsideration: Vec::new(),
11072 verdict: None,
11073 }
11074 }
11075
11076 #[test]
11077 fn review_conclusion_is_none_when_nothing_has_run() {
11078 assert_eq!(review_conclusion(&[], 3), None);
11079 }
11080
11081 #[test]
11082 fn review_conclusion_is_none_while_rounds_remain() {
11083 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11084 assert_eq!(review_conclusion(&rounds, 3), None);
11085 }
11086
11087 #[test]
11088 fn review_conclusion_is_gating_once_a_round_is_clean() {
11089 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11090 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11091 }
11092
11093 #[test]
11094 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11095 let rounds = vec![
11096 review_round(false, 1, 2, 2, true, true),
11097 review_round(false, 1, 2, 2, true, true),
11098 ];
11099 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11100 }
11101
11102 #[test]
11103 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11104 let rounds = vec![
11105 review_round(false, 1, 2, 2, true, true),
11106 review_round(false, 1, 2, 2, true, false),
11107 ];
11108 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11109 }
11110
11111 #[test]
11112 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11113 let mut blocked = review_round(false, 1, 2, 2, true, false);
11120 blocked.e2e[0].resource_blocked = true;
11121 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11122 assert_eq!(review_conclusion(&rounds, 2), None);
11123 }
11124
11125 #[test]
11126 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11127 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11129 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11130 }
11131
11132 #[test]
11133 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11134 let rounds = vec![
11135 review_round(false, 1, 2, 2, false, true),
11136 review_round(false, 1, 2, 2, false, true),
11137 ];
11138 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11139 }
11140
11141 fn secs(n: u64) -> Duration {
11142 Duration::from_secs(n)
11143 }
11144
11145 fn init_repo(dir: &Path) {
11148 let run = |args: &[&str]| {
11149 let out = std::process::Command::new("git")
11150 .args(args)
11151 .current_dir(dir)
11152 .quiet()
11153 .output()
11154 .expect("spawn git");
11155 assert!(
11156 out.status.success(),
11157 "git {args:?} failed: {}",
11158 String::from_utf8_lossy(&out.stderr)
11159 );
11160 };
11161 run(&["init", "-b", "main"]);
11162 run(&["config", "user.name", "magi test"]);
11163 run(&["config", "user.email", "magi@example.com"]);
11164 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11165 run(&["add", "-A"]);
11166 run(&["commit", "-m", "init"]);
11167 }
11168
11169 fn ask_test_home() {
11177 crate::run::pin_test_home();
11178 }
11179
11180 fn runner_at(status: RunStatus) -> Runner {
11183 let mut state = RunState::new(
11184 PathBuf::from("/nonexistent/repo"),
11185 "main".to_owned(),
11186 "deadbeef".to_owned(),
11187 "task".to_owned(),
11188 Config::default(),
11189 );
11190 state.status = status;
11191 Runner {
11192 state,
11193 roles: ResolvedRoles {
11194 implementers: Vec::new(),
11195 judges: Vec::new(),
11196 reviewers: Vec::new(),
11197 fixer: None,
11198 conductor: conductor(),
11199 implementer_roster: Vec::new(),
11200 judge_roster: Vec::new(),
11201 reviewer_roster: Vec::new(),
11202 },
11203 sem: Arc::new(Semaphore::new(1)),
11204 pause: Pause::new(),
11205 interrupt: Pause::new(),
11206 }
11207 }
11208
11209 #[test]
11213 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
11214 crate::run::pin_test_home();
11215 let mut runner = runner_at(RunStatus::Implementing);
11216 let interrupt = Pause::new();
11217 runner.watch_interrupt(interrupt.clone());
11218
11219 interrupt.park_because("task a1b2 asked to run first");
11220
11221 assert!(runner.park_here().expect("park_here"));
11222 assert!(runner.state.parked);
11223 let last = runner.state.events.last().expect("a park event");
11224 assert_eq!(last.node, "park");
11225 assert!(
11226 last.message.contains("task a1b2 asked to run first"),
11227 "expected the interrupt reason in {:?}",
11228 last.message
11229 );
11230 }
11231
11232 #[test]
11235 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
11236 crate::run::pin_test_home();
11237 let mut runner = runner_at(RunStatus::Implementing);
11238 runner.state.begin_pass();
11239 let shutdown = Pause::new();
11240 runner.on_pause(shutdown.clone());
11241 let upgrade = ParkCause::Upgrade {
11242 from: "0.121.0".to_owned(),
11243 to: Some("0.121.1".to_owned()),
11244 };
11245 shutdown.park_for(upgrade.clone());
11246 shutdown.park_for(ParkCause::Operator);
11247
11248 assert!(runner.park_here().expect("park_here"));
11249 assert_eq!(
11250 runner.state.passes[0].end,
11251 Some(crate::run::PassEnd::Parked { cause: upgrade })
11252 );
11253 let last = runner.state.events.last().expect("a park event");
11254 assert!(last.message.contains("0.121.0"), "{}", last.message);
11255 }
11256
11257 #[test]
11265 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
11266 crate::run::pin_test_home();
11267 let mut runner = runner_at(RunStatus::Implementing);
11268 let shutdown = Pause::new();
11269 runner.on_pause(shutdown.clone());
11270 let interrupt = Pause::new();
11271 runner.watch_interrupt(interrupt.clone());
11272
11273 assert!(!runner.park_here().expect("park_here"));
11275 assert!(!runner.state.parked);
11276
11277 interrupt.park_because("test");
11279 assert!(!shutdown.parked());
11280 assert!(runner.park_here().expect("park_here"));
11281 }
11282
11283 #[tokio::test]
11297 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
11298 crate::run::pin_test_home();
11299 let mut runner = runner_at(RunStatus::Implementing);
11300 let interrupt = Pause::new();
11301 runner.watch_interrupt(interrupt.clone());
11302
11303 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
11304 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
11305
11306 let node = async move {
11310 started_tx.send(()).expect("send started");
11311 finish_rx.await.expect("recv finish");
11312 "node finished"
11313 };
11314
11315 let interrupter = async move {
11316 started_rx.await.expect("recv started");
11317 interrupt.park_because("higher-priority task waiting");
11319 tokio::task::yield_now().await;
11323 finish_tx.send(()).expect("send finish");
11324 };
11325
11326 let (node_result, ()) = tokio::join!(node, interrupter);
11327 assert_eq!(
11328 node_result, "node finished",
11329 "the in-flight call ran to completion"
11330 );
11331
11332 assert!(runner.park_here().expect("park_here"));
11335 assert!(runner.state.parked);
11336 }
11337
11338 #[test]
11344 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
11345 crate::run::pin_test_home();
11346 let mut runner = runner_at(RunStatus::Judging);
11347 runner.state.config.agents = vec![conductor()];
11351 runner.state.candidates = vec![Candidate {
11352 index: 0,
11353 label: 'A',
11354 agent: "alpha".to_owned(),
11355 branch: "magi/x/A".to_owned(),
11356 worktree: PathBuf::from("/nonexistent/worktree"),
11357 summary: "did the thing".to_owned(),
11358 stat: "1 file changed".to_owned(),
11359 files: 1,
11360 commits: 1,
11361 empty: false,
11362 failed: None,
11363 verified_noop: None,
11364 duration_ms: 1234,
11365 folded: false,
11366 }];
11367 let run_id = runner.state.id.clone();
11368
11369 let interrupt = Pause::new();
11370 runner.watch_interrupt(interrupt.clone());
11371 interrupt.park_because("task c3d4 asked to run first");
11372 assert!(runner.park_here().expect("park_here"));
11373
11374 let resumed = Runner::resume(&run_id).expect("resume");
11375 assert_eq!(resumed.state.candidates.len(), 1);
11376 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
11377 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
11378 assert_eq!(resumed.state.status, runner.state.status);
11379 assert!(
11380 resumed.state.parked,
11381 "still parked until `execute` actually walks the graph again"
11382 );
11383 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
11384 }
11385
11386 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
11388 let mut q = ask::Question::new(
11389 run.to_owned(),
11390 "implement".to_owned(),
11391 "impl-A".to_owned(),
11392 "Which storage backend should the cache use?".to_owned(),
11393 String::new(),
11394 vec!["SQLite".to_owned(), "Redis".to_owned()],
11395 );
11396 store.put(&mut q).unwrap();
11397 q
11398 }
11399
11400 #[test]
11401 fn a_failed_runs_open_question_is_abandoned() {
11402 ask_test_home();
11403 let store = ask::Questions::open();
11404 let mut runner = runner_at(RunStatus::Failed);
11405 let run = runner.state.id.clone();
11406 let q = ask_open_question(&store, &run);
11407
11408 runner.settle_questions();
11409
11410 let back = store.get(&q.id).unwrap();
11411 assert!(
11412 !back.status.open(),
11413 "the seat that asked died with the run; nobody is left to read an answer"
11414 );
11415 assert!(
11416 back.detail.contains(&run) && back.detail.contains("failed"),
11417 "the reason names what the run became, not just that it is gone: {}",
11418 back.detail
11419 );
11420 }
11421
11422 #[test]
11423 fn a_merged_runs_open_question_is_abandoned_too() {
11424 ask_test_home();
11425 let store = ask::Questions::open();
11426 for status in [RunStatus::Merged, RunStatus::Ready] {
11429 let mut runner = runner_at(status);
11430 let run = runner.state.id.clone();
11431 let q = ask_open_question(&store, &run);
11432
11433 runner.settle_questions();
11434
11435 let back = store.get(&q.id).unwrap();
11436 assert!(
11437 !back.status.open(),
11438 "{status:?} run's question must not outlive the run"
11439 );
11440 }
11441 }
11442
11443 #[test]
11444 fn a_still_resumable_runs_open_question_is_left_alone() {
11445 ask_test_home();
11446 let store = ask::Questions::open();
11447 for status in [RunStatus::Blocked, RunStatus::Stalled] {
11453 let mut runner = runner_at(status);
11454 let run = runner.state.id.clone();
11455 let q = ask_open_question(&store, &run);
11456
11457 runner.settle_questions();
11458
11459 let back = store.get(&q.id).unwrap();
11460 assert!(
11461 back.status.open(),
11462 "{status:?} is still alive; the question must still be waiting"
11463 );
11464 }
11465 }
11466
11467 #[test]
11468 fn settle_questions_never_touches_an_already_answered_question() {
11469 ask_test_home();
11470 let store = ask::Questions::open();
11471 let mut runner = runner_at(RunStatus::Failed);
11472 let run = runner.state.id.clone();
11473 let mut q = ask_open_question(&store, &run);
11474 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
11475 .unwrap();
11476 store.put(&mut q).unwrap();
11477
11478 runner.settle_questions();
11483 runner.settle_questions();
11484
11485 let back = store.get(&q.id).unwrap();
11486 assert_eq!(
11487 back.status,
11488 ask::QuestionStatus::Answered,
11489 "a real answer is a decision on record, never overwritten by a sweep"
11490 );
11491 }
11492
11493 #[tokio::test]
11504 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
11505 crate::run::pin_test_home();
11506 let tmp = tempfile::tempdir().expect("tempdir");
11507 let repo = tmp.path().join("repo");
11508 std::fs::create_dir_all(&repo).unwrap();
11509 init_repo(&repo);
11510
11511 let mut config = Config::default();
11512 config.graph.worktree_root = Some(tmp.path().join("wt"));
11513
11514 let mut state = RunState::new(
11515 repo.clone(),
11516 "main".to_owned(),
11517 "deadbeef".to_owned(),
11518 "task".to_owned(),
11519 config,
11520 );
11521 let root = state.worktree_root();
11522 let wt_a = root.join("cand-A");
11523 let wt_b = root.join("cand-B");
11524 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
11525 .await
11526 .expect("worktree A");
11527 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
11528 .await
11529 .expect("worktree B");
11530
11531 state.candidates = vec![
11532 Candidate {
11533 index: 0,
11534 label: 'A',
11535 agent: "alpha".to_owned(),
11536 branch: "magi/x/A".to_owned(),
11537 worktree: wt_a.clone(),
11538 summary: String::new(),
11539 stat: String::new(),
11540 files: 0,
11541 commits: 0,
11542 empty: false,
11543 failed: None,
11544 verified_noop: None,
11545 duration_ms: 0,
11546 folded: false,
11547 },
11548 Candidate {
11549 index: 1,
11550 label: 'B',
11551 agent: "beta".to_owned(),
11552 branch: "magi/x/B".to_owned(),
11553 worktree: wt_b.clone(),
11554 summary: String::new(),
11555 stat: String::new(),
11556 files: 0,
11557 commits: 0,
11558 empty: false,
11559 failed: None,
11560 verified_noop: None,
11561 duration_ms: 0,
11562 folded: false,
11563 },
11564 ];
11565 state.tally = Some(Tally {
11566 first_choice: BTreeMap::from([('A', 1)]),
11567 borda: BTreeMap::new(),
11568 winner: 'A',
11569 rankings: 1,
11570 unanimous_initial: true,
11571 deliberated: false,
11572 changed_votes: 0,
11573 unanimous_final: true,
11574 tie_break: None,
11575 judges: 1,
11576 present: 1,
11577 quorum: 1,
11578 met_quorum: true,
11579 uncontested: None,
11580 });
11581 state.status = RunStatus::Ready;
11582
11583 fold_run(&mut state, false, &crate::run::home())
11584 .await
11585 .expect("fold_run");
11586
11587 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
11588 assert!(
11589 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11590 "the unmerged winner's branch survives"
11591 );
11592 assert!(
11593 !state.candidates[0].folded,
11594 "the winner is not marked folded"
11595 );
11596
11597 assert!(!wt_b.exists(), "the loser's worktree is removed");
11598 assert!(
11599 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
11600 "the loser's branch is removed"
11601 );
11602 assert!(state.candidates[1].folded, "the loser is marked folded");
11603 }
11604
11605 #[tokio::test]
11608 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
11609 let tmp = tempfile::tempdir().expect("tempdir");
11610 let repo = tmp.path().join("repo");
11611 std::fs::create_dir_all(&repo).unwrap();
11612 init_repo(&repo);
11613 let home = tmp.path().join("home");
11614
11615 let mut config = Config::default();
11616 config.graph.worktree_root = Some(tmp.path().join("wt"));
11617 let mut state = RunState::new(
11618 repo.clone(),
11619 "main".to_owned(),
11620 "deadbeef".to_owned(),
11621 "task".to_owned(),
11622 config,
11623 );
11624 git::git(&repo, &["branch", "magi/x/A", "main"])
11626 .await
11627 .expect("branch");
11628 state.candidates = vec![Candidate {
11629 index: 0,
11630 label: 'A',
11631 agent: "alpha".to_owned(),
11632 branch: "magi/x/A".to_owned(),
11633 worktree: state.worktree_root().join("cand-A"),
11634 summary: String::new(),
11635 stat: String::new(),
11636 files: 0,
11637 commits: 0,
11638 empty: false,
11639 failed: None,
11640 verified_noop: None,
11641 duration_ms: 0,
11642 folded: true,
11643 }];
11644 state.released_to = Some("20260901-000000-new1".to_owned());
11645 state.released_branches = vec!["magi/x/A".to_owned()];
11646
11647 fold_run(&mut state, true, &home).await.expect("fold_run");
11648
11649 assert!(
11650 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11651 "the handed-over branch survives a fold"
11652 );
11653 }
11654
11655 #[tokio::test]
11659 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
11660 let tmp = tempfile::tempdir().expect("tempdir");
11661 let repo = tmp.path().join("repo");
11662 std::fs::create_dir_all(&repo).unwrap();
11663 init_repo(&repo);
11664 let run = |args: &[&str]| {
11665 let out = std::process::Command::new("git")
11666 .quiet()
11667 .args(args)
11668 .current_dir(&repo)
11669 .output()
11670 .expect("spawn git");
11671 assert!(out.status.success(), "git {args:?}");
11672 };
11673 run(&["branch", "magi/x/A"]);
11674 run(&["checkout", "-q", "-b", "magi/x/B"]);
11675 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
11676 run(&["add", "-A"]);
11677 run(&["commit", "-q", "-m", "work"]);
11678 run(&["checkout", "-q", "main"]);
11679
11680 let probe = RunState::new(
11684 repo.clone(),
11685 "main".to_owned(),
11686 "deadbeef".to_owned(),
11687 "task".to_owned(),
11688 Config::default(),
11689 );
11690 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
11691 let bare = tmp.path().join("origin.git");
11692 let out = std::process::Command::new("git")
11693 .quiet()
11694 .args(["init", "-q", "--bare"])
11695 .arg(&bare)
11696 .output()
11697 .expect("spawn git");
11698 assert!(out.status.success(), "git init --bare");
11699 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
11700 run(&["push", "-q", "origin", "main"]);
11701
11702 let mut state = RunState::new(
11703 repo.clone(),
11704 "main".to_owned(),
11705 "deadbeef".to_owned(),
11706 "task".to_owned(),
11707 Config::default(),
11708 );
11709 state.seeds = vec![refs::Seed {
11710 token: "magi/27b2/A".to_owned(),
11711 kind: refs::SeedKind::Unresolved,
11712 sha: String::new(),
11713 branch: true,
11714 detail: "no branch or commit named magi/27b2/A".to_owned(),
11715 }];
11716
11717 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
11718 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
11719 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
11720 let detail = empty_candidate_detail(&state, "main");
11721 assert!(detail.starts_with("empty candidate"), "{detail}");
11722 assert!(detail.contains("magi/27b2/A"), "{detail}");
11723 }
11724
11725 #[tokio::test]
11734 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
11735 let tmp = tempfile::tempdir().expect("tempdir");
11736 let repo = tmp.path().join("repo");
11737 std::fs::create_dir_all(&repo).unwrap();
11738 init_repo(&repo);
11739
11740 let mut config = Config::default();
11741 config.merge.mode = MergeMode::Local;
11742
11743 let mut state = RunState::new(
11744 repo.clone(),
11745 "main".to_owned(),
11746 "deadbeef".to_owned(),
11747 "task".to_owned(),
11748 config,
11749 );
11750 state.candidates = vec![Candidate {
11751 index: 0,
11752 label: 'A',
11753 agent: "alpha".to_owned(),
11754 branch: "does-not-exist".to_owned(),
11755 worktree: repo.clone(),
11756 summary: String::new(),
11757 stat: String::new(),
11758 files: 0,
11759 commits: 0,
11760 empty: false,
11761 failed: None,
11762 verified_noop: None,
11763 duration_ms: 0,
11764 folded: false,
11765 }];
11766 state.tally = Some(Tally {
11767 first_choice: BTreeMap::from([('A', 1)]),
11768 borda: BTreeMap::new(),
11769 winner: 'A',
11770 rankings: 1,
11771 unanimous_initial: true,
11772 deliberated: false,
11773 changed_votes: 0,
11774 unanimous_final: true,
11775 tie_break: None,
11776 judges: 0,
11777 present: 0,
11778 quorum: 0,
11779 met_quorum: true,
11780 uncontested: Some("only candidate A produced a change".to_owned()),
11781 });
11782 state.reviews = vec![ReviewRound {
11783 round: 1,
11784 head: "deadbeef".to_owned(),
11785 verified_head: None,
11786 verified_at: None,
11787 reviews: Vec::new(),
11788 e2e: Vec::new(),
11789 fix: None,
11790 blocking: 0,
11791 answered: 0,
11792 expected: 0,
11793 clean: true,
11794 verify_retried: false,
11795 e2e_deferred: false,
11796 e2e_defer_reason: None,
11797 progressed: false,
11798 vote_split: false,
11799 reconsideration: Vec::new(),
11800 verdict: None,
11801 }];
11802 state.gate = vec![CommandOutcome {
11803 command: "test".to_owned(),
11804 code: Some(0),
11805 output_tail: String::new(),
11806 duration_ms: 0,
11807 resource_blocked: false,
11808 }];
11809 state.gate_ran = true;
11810 state.status = RunStatus::Ready;
11815 state.merge = Some(MergeOutcome {
11816 mode: MergeMode::Local,
11817 ok: false,
11818 detail: "already concluded".to_owned(),
11819 empty: false,
11820 });
11821
11822 let mut runner = Runner {
11823 state,
11824 roles: ResolvedRoles {
11825 implementers: Vec::new(),
11826 judges: Vec::new(),
11827 reviewers: Vec::new(),
11828 fixer: None,
11829 conductor: conductor(),
11830 implementer_roster: Vec::new(),
11831 judge_roster: Vec::new(),
11832 reviewer_roster: Vec::new(),
11833 },
11834 sem: Arc::new(Semaphore::new(1)),
11835 pause: Pause::new(),
11836 interrupt: Pause::new(),
11837 };
11838
11839 runner.merge().await.expect("merge");
11840
11841 assert_eq!(
11842 runner.state.status,
11843 RunStatus::Ready,
11844 "a concluded run's status must not change on reentry"
11845 );
11846 assert_eq!(
11847 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11848 Some("already concluded"),
11849 "merge must not run again once the node already recorded an outcome"
11850 );
11851 }
11852
11853 #[tokio::test]
11862 async fn merge_refuses_a_gate_that_has_not_actually_run() {
11863 let tmp = tempfile::tempdir().expect("tempdir");
11864 let repo = tmp.path().join("repo");
11865 std::fs::create_dir_all(&repo).unwrap();
11866 init_repo(&repo);
11867
11868 let mut config = Config::default();
11869 config.merge.mode = MergeMode::Local;
11870
11871 let mut state = RunState::new(
11872 repo.clone(),
11873 "main".to_owned(),
11874 "deadbeef".to_owned(),
11875 "task".to_owned(),
11876 config,
11877 );
11878 state.candidates = vec![Candidate {
11879 index: 0,
11880 label: 'A',
11881 agent: "alpha".to_owned(),
11882 branch: "does-not-exist".to_owned(),
11883 worktree: repo.clone(),
11884 summary: String::new(),
11885 stat: String::new(),
11886 files: 0,
11887 commits: 0,
11888 empty: false,
11889 failed: None,
11890 verified_noop: None,
11891 duration_ms: 0,
11892 folded: false,
11893 }];
11894 state.tally = Some(Tally {
11895 first_choice: BTreeMap::from([('A', 1)]),
11896 borda: BTreeMap::new(),
11897 winner: 'A',
11898 rankings: 1,
11899 unanimous_initial: true,
11900 deliberated: false,
11901 changed_votes: 0,
11902 unanimous_final: true,
11903 tie_break: None,
11904 judges: 0,
11905 present: 0,
11906 quorum: 0,
11907 met_quorum: true,
11908 uncontested: Some("only candidate A produced a change".to_owned()),
11909 });
11910 state.reviews = vec![ReviewRound {
11911 round: 1,
11912 head: "deadbeef".to_owned(),
11913 verified_head: None,
11914 verified_at: None,
11915 reviews: Vec::new(),
11916 e2e: Vec::new(),
11917 fix: None,
11918 blocking: 0,
11919 answered: 0,
11920 expected: 0,
11921 clean: true,
11922 verify_retried: false,
11923 e2e_deferred: false,
11924 e2e_defer_reason: None,
11925 progressed: false,
11926 vote_split: false,
11927 reconsideration: Vec::new(),
11928 verdict: None,
11929 }];
11930 state.gate = Vec::new();
11932 state.gate_ran = false;
11933 state.status = RunStatus::Gating;
11934
11935 let mut runner = Runner {
11936 state,
11937 roles: ResolvedRoles {
11938 implementers: Vec::new(),
11939 judges: Vec::new(),
11940 reviewers: Vec::new(),
11941 fixer: None,
11942 conductor: conductor(),
11943 implementer_roster: Vec::new(),
11944 judge_roster: Vec::new(),
11945 reviewer_roster: Vec::new(),
11946 },
11947 sem: Arc::new(Semaphore::new(1)),
11948 pause: Pause::new(),
11949 interrupt: Pause::new(),
11950 };
11951
11952 runner.merge().await.expect("merge");
11953
11954 assert!(
11955 runner.state.merge.is_none(),
11956 "an empty gate must never be read as a passing one: {:?}",
11957 runner.state.merge
11958 );
11959 }
11960
11961 #[tokio::test]
11968 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
11969 let tmp = tempfile::tempdir().expect("tempdir");
11970 let repo = tmp.path().join("repo");
11971 std::fs::create_dir_all(&repo).unwrap();
11972 init_repo(&repo);
11973
11974 let config = Config::default();
11976
11977 let mut state = RunState::new(
11978 repo.clone(),
11979 "main".to_owned(),
11980 "deadbeef".to_owned(),
11981 "task".to_owned(),
11982 config,
11983 );
11984 state.candidates = vec![Candidate {
11985 index: 0,
11986 label: 'A',
11987 agent: "alpha".to_owned(),
11988 branch: "does-not-exist".to_owned(),
11989 worktree: repo.clone(),
11990 summary: String::new(),
11991 stat: String::new(),
11992 files: 0,
11993 commits: 0,
11994 empty: false,
11995 failed: None,
11996 verified_noop: None,
11997 duration_ms: 0,
11998 folded: false,
11999 }];
12000 state.tally = Some(Tally {
12001 first_choice: BTreeMap::from([('A', 1)]),
12002 borda: BTreeMap::new(),
12003 winner: 'A',
12004 rankings: 1,
12005 unanimous_initial: true,
12006 deliberated: false,
12007 changed_votes: 0,
12008 unanimous_final: true,
12009 tie_break: None,
12010 judges: 0,
12011 present: 0,
12012 quorum: 0,
12013 met_quorum: true,
12014 uncontested: Some("only candidate A produced a change".to_owned()),
12015 });
12016 state.reviews = vec![ReviewRound {
12017 round: 1,
12018 head: "deadbeef".to_owned(),
12019 verified_head: None,
12020 verified_at: None,
12021 reviews: Vec::new(),
12022 e2e: Vec::new(),
12023 fix: None,
12024 blocking: 0,
12025 answered: 0,
12026 expected: 0,
12027 clean: true,
12028 verify_retried: false,
12029 e2e_deferred: false,
12030 e2e_defer_reason: None,
12031 progressed: false,
12032 vote_split: false,
12033 reconsideration: Vec::new(),
12034 verdict: None,
12035 }];
12036
12037 let mut runner = Runner {
12038 state,
12039 roles: ResolvedRoles {
12040 implementers: Vec::new(),
12041 judges: Vec::new(),
12042 reviewers: Vec::new(),
12043 fixer: None,
12044 conductor: conductor(),
12045 implementer_roster: Vec::new(),
12046 judge_roster: Vec::new(),
12047 reviewer_roster: Vec::new(),
12048 },
12049 sem: Arc::new(Semaphore::new(1)),
12050 pause: Pause::new(),
12051 interrupt: Pause::new(),
12052 };
12053
12054 runner.gate().await.expect("gate");
12055 assert!(
12056 runner.state.gate_ran,
12057 "zero configured commands is still a real attempt, not an unrun gate"
12058 );
12059 assert!(runner.state.gate.is_empty());
12060 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12061 assert_ne!(
12062 runner.state.status,
12063 RunStatus::Blocked,
12064 "a gate with nothing to check must not read as failed"
12065 );
12066
12067 runner.merge().await.expect("merge");
12068 assert_eq!(
12069 runner.state.status,
12070 RunStatus::Ready,
12071 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12072 );
12073 }
12074
12075 #[tokio::test]
12086 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12087 crate::run::pin_test_home();
12088 let home = crate::run::home();
12089
12090 let tmp = tempfile::tempdir().expect("tempdir");
12091 let repo = tmp.path().join("repo");
12092 std::fs::create_dir_all(&repo).unwrap();
12093 init_repo(&repo);
12094 let cache_dir = tmp.path().join("target");
12097
12098 let mut config = Config::default();
12099 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12100 config.graph.timeout_verify = Some(2);
12103
12104 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12105 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12106 .expect("no io error acquiring directly")
12107 {
12108 crate::cache::AcquireOutcome::Acquired(g) => g,
12109 crate::cache::AcquireOutcome::Busy(b) => {
12110 panic!("expected the direct acquire to win the lease first: {b:?}")
12111 }
12112 };
12113
12114 let mut state = RunState::new(
12115 repo.clone(),
12116 "main".to_owned(),
12117 "deadbeef".to_owned(),
12118 "task".to_owned(),
12119 config,
12120 );
12121 state.candidates = vec![Candidate {
12122 index: 0,
12123 label: 'A',
12124 agent: "alpha".to_owned(),
12125 branch: "does-not-exist".to_owned(),
12126 worktree: repo.clone(),
12127 summary: String::new(),
12128 stat: String::new(),
12129 files: 0,
12130 commits: 0,
12131 empty: false,
12132 failed: None,
12133 verified_noop: None,
12134 duration_ms: 0,
12135 folded: false,
12136 }];
12137 state.tally = Some(Tally {
12138 first_choice: BTreeMap::from([('A', 1)]),
12139 borda: BTreeMap::new(),
12140 winner: 'A',
12141 rankings: 1,
12142 unanimous_initial: true,
12143 deliberated: false,
12144 changed_votes: 0,
12145 unanimous_final: true,
12146 tie_break: None,
12147 judges: 0,
12148 present: 0,
12149 quorum: 0,
12150 met_quorum: true,
12151 uncontested: Some("only candidate A produced a change".to_owned()),
12152 });
12153 state.reviews = vec![ReviewRound {
12154 round: 1,
12155 head: "deadbeef".to_owned(),
12156 verified_head: None,
12157 verified_at: None,
12158 reviews: Vec::new(),
12159 e2e: Vec::new(),
12160 fix: None,
12161 blocking: 0,
12162 answered: 0,
12163 expected: 0,
12164 clean: true,
12165 verify_retried: false,
12166 e2e_deferred: false,
12167 e2e_defer_reason: None,
12168 progressed: false,
12169 vote_split: false,
12170 reconsideration: Vec::new(),
12171 verdict: None,
12172 }];
12173
12174 let mut runner = Runner {
12175 state,
12176 roles: ResolvedRoles {
12177 implementers: Vec::new(),
12178 judges: Vec::new(),
12179 reviewers: Vec::new(),
12180 fixer: None,
12181 conductor: conductor(),
12182 implementer_roster: Vec::new(),
12183 judge_roster: Vec::new(),
12184 reviewer_roster: Vec::new(),
12185 },
12186 sem: Arc::new(Semaphore::new(1)),
12187 pause: Pause::new(),
12188 interrupt: Pause::new(),
12189 };
12190
12191 let started = std::time::Instant::now();
12192 runner.gate().await.expect("gate");
12193 assert!(
12194 started.elapsed() < Duration::from_secs(1),
12195 "a gate with nothing to run must never wait on a lease it never needed"
12196 );
12197 assert!(
12198 runner.state.gate_ran,
12199 "zero commands is still a real, immediate attempt"
12200 );
12201 assert!(runner.state.gate.is_empty());
12202 assert_ne!(
12203 runner.state.status,
12204 RunStatus::Blocked,
12205 "must not read as resource-blocked on a lease it never asked for"
12206 );
12207 }
12208
12209 #[tokio::test]
12219 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
12220 crate::run::pin_test_home();
12221
12222 let tmp = tempfile::tempdir().expect("tempdir");
12223 let repo = tmp.path().join("repo");
12224 std::fs::create_dir_all(&repo).unwrap();
12225 init_repo(&repo);
12226
12227 let mut config = Config::default();
12228 config.verify.gate = vec![
12229 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
12230 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
12231 .to_owned(),
12232 ];
12233
12234 let mut state = RunState::new(
12235 repo.clone(),
12236 "main".to_owned(),
12237 "deadbeef".to_owned(),
12238 "task".to_owned(),
12239 config,
12240 );
12241 let run_id = state.id.clone();
12242 state.candidates = vec![Candidate {
12243 index: 0,
12244 label: 'A',
12245 agent: "alpha".to_owned(),
12246 branch: "does-not-exist".to_owned(),
12247 worktree: repo.clone(),
12248 summary: String::new(),
12249 stat: String::new(),
12250 files: 0,
12251 commits: 0,
12252 empty: false,
12253 failed: None,
12254 verified_noop: None,
12255 duration_ms: 0,
12256 folded: false,
12257 }];
12258 state.tally = Some(Tally {
12259 first_choice: BTreeMap::from([('A', 1)]),
12260 borda: BTreeMap::new(),
12261 winner: 'A',
12262 rankings: 1,
12263 unanimous_initial: true,
12264 deliberated: false,
12265 changed_votes: 0,
12266 unanimous_final: true,
12267 tie_break: None,
12268 judges: 0,
12269 present: 0,
12270 quorum: 0,
12271 met_quorum: true,
12272 uncontested: Some("only candidate A produced a change".to_owned()),
12273 });
12274 state.reviews = vec![ReviewRound {
12275 round: 1,
12276 head: "deadbeef".to_owned(),
12277 verified_head: None,
12278 verified_at: None,
12279 reviews: Vec::new(),
12280 e2e: Vec::new(),
12281 fix: None,
12282 blocking: 0,
12283 answered: 0,
12284 expected: 0,
12285 clean: true,
12286 verify_retried: false,
12287 e2e_deferred: false,
12288 e2e_defer_reason: None,
12289 progressed: false,
12290 vote_split: false,
12291 reconsideration: Vec::new(),
12292 verdict: None,
12293 }];
12294
12295 let mut runner = Runner {
12296 state,
12297 roles: ResolvedRoles {
12298 implementers: Vec::new(),
12299 judges: Vec::new(),
12300 reviewers: Vec::new(),
12301 fixer: None,
12302 conductor: conductor(),
12303 implementer_roster: Vec::new(),
12304 judge_roster: Vec::new(),
12305 reviewer_roster: Vec::new(),
12306 },
12307 sem: Arc::new(Semaphore::new(1)),
12308 pause: Pause::new(),
12309 interrupt: Pause::new(),
12310 };
12311
12312 let started_marker = repo.join("started.marker");
12313 let release_marker = repo.join("release.marker");
12314 let poller = tokio::spawn(async move {
12315 for _ in 0..100 {
12320 if started_marker.exists()
12321 && let Ok(s) = crate::run::RunState::load(&run_id)
12322 && let Some(a) = s.active.get("gate")
12323 {
12324 std::fs::write(&release_marker, b"go").expect("release marker");
12325 return Some(a.clone());
12326 }
12327 tokio::time::sleep(Duration::from_millis(50)).await;
12328 }
12329 None
12330 });
12331
12332 runner.gate().await.expect("gate");
12333 let captured = poller.await.expect("poller task");
12334 let captured = captured.expect(
12335 "the poller never saw a `gate` task entry in run.json while the command was \
12336 still blocked on its own release marker",
12337 );
12338
12339 assert_eq!(captured.task.as_deref(), Some("gate"));
12340 assert_eq!(captured.node, "gate");
12341 assert_eq!(captured.index, Some(1));
12342 assert_eq!(captured.total, Some(1));
12343 assert!(
12344 captured
12345 .command
12346 .as_deref()
12347 .is_some_and(|c| c.contains("started.marker")),
12348 "{captured:?}"
12349 );
12350
12351 assert!(
12352 runner.state.active.is_empty(),
12353 "the entry must be cleared once the command actually finished: {:?}",
12354 runner.state.active
12355 );
12356 assert!(runner.state.gate_ran);
12357 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
12358 }
12359
12360 #[tokio::test]
12363 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
12364 use crate::verdict::{Finding, ReviewVote, Severity};
12365 crate::run::pin_test_home();
12366 let tmp = tempfile::tempdir().expect("tempdir");
12367 let repo = tmp.path().join("repo");
12368 std::fs::create_dir_all(&repo).unwrap();
12369 init_repo(&repo);
12370
12371 for (severity, vote, expect) in [
12372 (Severity::Major, ReviewVote::Reject, true),
12373 (Severity::Minor, ReviewVote::Reject, false),
12374 (Severity::Major, ReviewVote::Approve, false),
12375 ] {
12376 let mut round = review_round(false, 1, 1, 1, false, true);
12377 round.reviews = vec![ReviewRecord {
12378 reviewer: 1,
12379 agent: "alpha".to_owned(),
12380 summary: String::new(),
12381 findings: vec![Finding {
12382 id: "R1-1-1".to_owned(),
12383 severity,
12384 file: None,
12385 line: None,
12386 title: "t".to_owned(),
12387 detail: String::new(),
12388 }],
12389 vote: Some(vote),
12390 failed: None,
12391 duration_ms: 0,
12392 attempts: 0,
12393 }];
12394 let mut state = RunState::new(
12395 repo.clone(),
12396 "main".to_owned(),
12397 "deadbeef".to_owned(),
12398 "task".to_owned(),
12399 Config::default(),
12400 );
12401 state.reviews = vec![round];
12402 let mut runner = Runner {
12403 state,
12404 roles: ResolvedRoles {
12405 implementers: Vec::new(),
12406 judges: Vec::new(),
12407 reviewers: Vec::new(),
12408 fixer: None,
12409 conductor: conductor(),
12410 implementer_roster: Vec::new(),
12411 judge_roster: Vec::new(),
12412 reviewer_roster: Vec::new(),
12413 },
12414 sem: Arc::new(Semaphore::new(1)),
12415 pause: Pause::new(),
12416 interrupt: Pause::new(),
12417 };
12418 let shell = runner.state.config.shell();
12419 runner
12420 .stop_reviewing("round budget spent", &shell, &repo)
12421 .await
12422 .expect("stop_reviewing");
12423 assert_eq!(runner.state.status, RunStatus::Gating);
12424 assert_eq!(
12425 runner.state.contested_handoff.is_some(),
12426 expect,
12427 "{severity:?} + {vote:?}"
12428 );
12429 }
12430 }
12431
12432 #[tokio::test]
12445 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
12446 crate::run::pin_test_home();
12447 let home = crate::run::home();
12448
12449 let tmp = tempfile::tempdir().expect("tempdir");
12450 let repo = tmp.path().join("repo");
12451 std::fs::create_dir_all(&repo).unwrap();
12452 init_repo(&repo);
12453 let head = crate::git::rev_parse(&repo, "HEAD")
12454 .await
12455 .expect("rev-parse");
12456 let cache_dir = tmp.path().join("target");
12459
12460 let mut config = Config::default();
12461 config.verify.e2e = vec![format!(
12462 "CARGO_TARGET_DIR='{}' test -f README.md",
12463 cache_dir.display()
12464 )];
12465 config.graph.review_rounds = 1;
12466 config.graph.timeout_verify = Some(2);
12469
12470 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12471 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12472 .expect("no io error acquiring directly")
12473 {
12474 crate::cache::AcquireOutcome::Acquired(g) => g,
12475 crate::cache::AcquireOutcome::Busy(b) => {
12476 panic!("expected the direct acquire to win the lease first: {b:?}")
12477 }
12478 };
12479
12480 let mut state = RunState::new(
12481 repo.clone(),
12482 "main".to_owned(),
12483 head.clone(),
12484 "task".to_owned(),
12485 config,
12486 );
12487 state.candidates = vec![Candidate {
12488 index: 0,
12489 label: 'A',
12490 agent: "alpha".to_owned(),
12491 branch: "does-not-exist".to_owned(),
12492 worktree: repo.clone(),
12493 summary: String::new(),
12494 stat: String::new(),
12495 files: 0,
12496 commits: 0,
12497 empty: false,
12498 failed: None,
12499 verified_noop: None,
12500 duration_ms: 0,
12501 folded: false,
12502 }];
12503 state.tally = Some(Tally {
12504 first_choice: BTreeMap::from([('A', 1)]),
12505 borda: BTreeMap::new(),
12506 winner: 'A',
12507 rankings: 1,
12508 unanimous_initial: true,
12509 deliberated: false,
12510 changed_votes: 0,
12511 unanimous_final: true,
12512 tie_break: None,
12513 judges: 0,
12514 present: 0,
12515 quorum: 0,
12516 met_quorum: true,
12517 uncontested: Some("only candidate A produced a change".to_owned()),
12518 });
12519 state.reviews = vec![ReviewRound {
12523 round: 1,
12524 head: head.clone(),
12525 verified_head: None,
12526 verified_at: None,
12527 reviews: Vec::new(),
12528 e2e: Vec::new(),
12529 fix: None,
12530 blocking: 1,
12531 answered: 1,
12532 expected: 1,
12533 clean: false,
12534 verify_retried: false,
12535 e2e_deferred: true,
12536 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
12537 progressed: false,
12538 vote_split: false,
12539 reconsideration: Vec::new(),
12540 verdict: None,
12541 }];
12542
12543 let mut runner = Runner {
12544 state,
12545 roles: ResolvedRoles {
12546 implementers: Vec::new(),
12547 judges: Vec::new(),
12548 reviewers: Vec::new(),
12549 fixer: None,
12550 conductor: conductor(),
12551 implementer_roster: Vec::new(),
12552 judge_roster: Vec::new(),
12553 reviewer_roster: Vec::new(),
12554 },
12555 sem: Arc::new(Semaphore::new(1)),
12556 pause: Pause::new(),
12557 interrupt: Pause::new(),
12558 };
12559
12560 let shell = runner.state.config.shell();
12561 runner
12562 .stop_reviewing("round budget spent", &shell, &repo)
12563 .await
12564 .expect("stop_reviewing");
12565
12566 let last = runner.state.reviews.last().expect("round record");
12567 assert_eq!(
12568 last.e2e_status(),
12569 E2eStatus::ResourceBlocked,
12570 "the shared cache is still held; the attempt must read as blocked, not deferred or \
12571 failed: {last:?}"
12572 );
12573 assert_eq!(
12574 last.verified_head.as_deref(),
12575 Some(head.as_str()),
12576 "which commit this attempt targeted is known even though nothing finished checking \
12577 it"
12578 );
12579 let first_attempt_at = last
12580 .verified_at
12581 .expect("when this attempt ran is known too");
12582 assert_ne!(
12583 runner.state.status,
12584 RunStatus::Blocked,
12585 "contention is evidence about the machine, not the patch — it must not settle the \
12586 run as blocked: {:?}",
12587 runner.state.status
12588 );
12589 assert!(
12590 !runner
12591 .state
12592 .events
12593 .iter()
12594 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
12595 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
12596 runner.state.events
12597 );
12598
12599 runner
12604 .stop_reviewing("round budget spent", &shell, &repo)
12605 .await
12606 .expect("stop_reviewing retry");
12607 assert_eq!(
12608 runner.state.reviews.len(),
12609 1,
12610 "no new round was started: {:?}",
12611 runner.state.reviews
12612 );
12613 let last = runner.state.reviews.last().expect("round record");
12614 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
12615 assert!(
12616 last.verified_at.expect("still known") > first_attempt_at,
12617 "a second reentry must be a fresh attempt, not a stale copy of the first"
12618 );
12619 assert_ne!(runner.state.status, RunStatus::Blocked);
12620
12621 held.release();
12622 }
12623
12624 #[tokio::test]
12636 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
12637 crate::run::pin_test_home();
12638 let home = crate::run::home();
12639
12640 let tmp = tempfile::tempdir().expect("tempdir");
12641 let repo = tmp.path().join("repo");
12642 std::fs::create_dir_all(&repo).unwrap();
12643 init_repo(&repo);
12644 let head = crate::git::rev_parse(&repo, "HEAD")
12645 .await
12646 .expect("rev-parse");
12647 let cache_dir = tmp.path().join("target");
12648
12649 let mut config = Config::default();
12650 config.verify.e2e = vec![format!(
12651 "CARGO_TARGET_DIR='{}' test -f README.md",
12652 cache_dir.display()
12653 )];
12654 config.graph.review_rounds = 1;
12655 config.graph.timeout_verify = Some(2);
12656
12657 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12658 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12659 .expect("no io error acquiring directly")
12660 {
12661 crate::cache::AcquireOutcome::Acquired(g) => g,
12662 crate::cache::AcquireOutcome::Busy(b) => {
12663 panic!("expected the direct acquire to win the lease first: {b:?}")
12664 }
12665 };
12666
12667 let mut state = RunState::new(
12668 repo.clone(),
12669 "main".to_owned(),
12670 head.clone(),
12671 "task".to_owned(),
12672 config,
12673 );
12674 state.candidates = vec![Candidate {
12675 index: 0,
12676 label: 'A',
12677 agent: "alpha".to_owned(),
12678 branch: "does-not-exist".to_owned(),
12679 worktree: repo.clone(),
12680 summary: String::new(),
12681 stat: String::new(),
12682 files: 0,
12683 commits: 0,
12684 empty: false,
12685 failed: None,
12686 verified_noop: None,
12687 duration_ms: 0,
12688 folded: false,
12689 }];
12690 state.tally = Some(Tally {
12691 first_choice: BTreeMap::from([('A', 1)]),
12692 borda: BTreeMap::new(),
12693 winner: 'A',
12694 rankings: 1,
12695 unanimous_initial: true,
12696 deliberated: false,
12697 changed_votes: 0,
12698 unanimous_final: true,
12699 tie_break: None,
12700 judges: 0,
12701 present: 0,
12702 quorum: 0,
12703 met_quorum: true,
12704 uncontested: Some("only candidate A produced a change".to_owned()),
12705 });
12706 state.reviews = vec![ReviewRound {
12710 round: 1,
12711 head: head.clone(),
12712 verified_head: Some(head.clone()),
12713 verified_at: Some(jiff::Timestamp::now()),
12714 reviews: Vec::new(),
12715 e2e: vec![CommandOutcome {
12716 command: format!(
12717 "CARGO_TARGET_DIR='{}' test -f README.md",
12718 cache_dir.display()
12719 ),
12720 code: None,
12721 output_tail: "waiting for the shared build cache".to_owned(),
12722 duration_ms: 0,
12723 resource_blocked: true,
12724 }],
12725 fix: None,
12726 blocking: 1,
12727 answered: 1,
12728 expected: 1,
12729 clean: false,
12730 verify_retried: false,
12731 e2e_deferred: false,
12732 e2e_defer_reason: None,
12733 progressed: false,
12734 vote_split: false,
12735 reconsideration: Vec::new(),
12736 verdict: None,
12737 }];
12738
12739 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
12740 let mut runner = Runner {
12741 state,
12742 roles: ResolvedRoles {
12743 implementers: Vec::new(),
12744 judges: Vec::new(),
12745 reviewers: Vec::new(),
12746 fixer: None,
12747 conductor: conductor(),
12748 implementer_roster: Vec::new(),
12749 judge_roster: Vec::new(),
12750 reviewer_roster: Vec::new(),
12751 },
12752 sem: Arc::new(Semaphore::new(1)),
12753 pause: Pause::new(),
12754 interrupt: Pause::new(),
12755 };
12756
12757 runner.review_loop().await.expect("review_loop");
12762
12763 assert_eq!(
12764 runner.state.reviews.len(),
12765 1,
12766 "no new round was started on top of the unresolved one: {:?}",
12767 runner.state.reviews
12768 );
12769 let last = &runner.state.reviews[0];
12770 assert_eq!(
12771 last.e2e_status(),
12772 E2eStatus::ResourceBlocked,
12773 "still contended: {last:?}"
12774 );
12775 assert!(
12776 last.verified_at.expect("still known") > first_attempt_at,
12777 "review_loop must have actually retried the check, not left it exactly as found"
12778 );
12779 assert_ne!(
12780 runner.state.status,
12781 RunStatus::Blocked,
12782 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
12783 runner.state.status
12784 );
12785
12786 held.release();
12787 }
12788
12789 #[tokio::test]
12790 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
12791 crate::run::pin_test_home();
12792 let tmp = tempfile::tempdir().expect("tempdir");
12793 let repo = tmp.path().join("repo");
12794 std::fs::create_dir_all(&repo).unwrap();
12795 init_repo(&repo);
12796
12797 let mut config = Config::default();
12798 config.merge.mode = MergeMode::Pr;
12799 config.graph.land = true;
12800 config.graph.land_approval = false;
12801
12802 let mut state = RunState::new(
12803 repo.clone(),
12804 "main".to_owned(),
12805 "deadbeef".to_owned(),
12806 "task".to_owned(),
12807 config,
12808 );
12809 state.candidates = vec![Candidate {
12810 index: 0,
12811 label: 'A',
12812 agent: "alpha".to_owned(),
12813 branch: "does-not-exist".to_owned(),
12814 worktree: repo.clone(),
12815 summary: String::new(),
12816 stat: String::new(),
12817 files: 0,
12818 commits: 0,
12819 empty: false,
12820 failed: None,
12821 verified_noop: None,
12822 duration_ms: 0,
12823 folded: false,
12824 }];
12825 state.tally = Some(Tally {
12826 first_choice: BTreeMap::from([('A', 1)]),
12827 borda: BTreeMap::new(),
12828 winner: 'A',
12829 rankings: 1,
12830 unanimous_initial: true,
12831 deliberated: false,
12832 changed_votes: 0,
12833 unanimous_final: true,
12834 tie_break: None,
12835 judges: 0,
12836 present: 0,
12837 quorum: 0,
12838 met_quorum: true,
12839 uncontested: Some("only candidate A produced a change".to_owned()),
12840 });
12841 state.reviews = vec![ReviewRound {
12842 round: 1,
12843 head: "deadbeef".to_owned(),
12844 verified_head: None,
12845 verified_at: None,
12846 reviews: Vec::new(),
12847 e2e: Vec::new(),
12848 fix: None,
12849 blocking: 0,
12850 answered: 0,
12851 expected: 0,
12852 clean: true,
12853 verify_retried: false,
12854 e2e_deferred: false,
12855 e2e_defer_reason: None,
12856 progressed: false,
12857 vote_split: false,
12858 reconsideration: Vec::new(),
12859 verdict: None,
12860 }];
12861 state.gate = vec![CommandOutcome {
12862 command: "test".to_owned(),
12863 code: Some(0),
12864 output_tail: String::new(),
12865 duration_ms: 0,
12866 resource_blocked: false,
12867 }];
12868 state.gate_ran = true;
12869 state.status = RunStatus::Landing;
12873 state.merge = Some(MergeOutcome {
12874 mode: MergeMode::Pr,
12875 ok: true,
12876 detail: "https://example.invalid/x/y/pull/1".to_owned(),
12877 empty: false,
12878 });
12879
12880 ask_test_home();
12884 state.save().expect("save landing run");
12887 let store = ask::Questions::open();
12888 let q = ask_open_question(&store, &state.id);
12889
12890 let mut runner = Runner {
12891 state,
12892 roles: ResolvedRoles {
12893 implementers: Vec::new(),
12894 judges: Vec::new(),
12895 reviewers: Vec::new(),
12896 fixer: None,
12897 conductor: conductor(),
12898 implementer_roster: Vec::new(),
12899 judge_roster: Vec::new(),
12900 reviewer_roster: Vec::new(),
12901 },
12902 sem: Arc::new(Semaphore::new(1)),
12903 pause: Pause::new(),
12904 interrupt: Pause::new(),
12905 };
12906
12907 runner.execute().await.expect("execute");
12912
12913 assert_eq!(
12914 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12915 Some("https://example.invalid/x/y/pull/1"),
12916 "reentry must not push again or open a second pull request over the \
12917 one `land` is already watching"
12918 );
12919 assert_ne!(
12920 runner.state.status,
12921 RunStatus::Landing,
12922 "land could not actually reach the fake pull request, so it must \
12923 have given up rather than left the run silently parked forever"
12924 );
12925 assert_eq!(runner.state.status, RunStatus::Blocked);
12929 assert!(
12930 store.get(&q.id).unwrap().status.open(),
12931 "Blocked is still alive; settle_questions must have been a no-op here"
12932 );
12933 }
12934
12935 fn state_with_round(round: ReviewRound) -> RunState {
12936 let mut s = RunState::new(
12937 PathBuf::from("/repo"),
12938 "main".to_owned(),
12939 "abc1234".to_owned(),
12940 "add retries".to_owned(),
12941 Config::default(),
12942 );
12943 s.reviews = vec![round];
12944 s
12945 }
12946
12947 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
12948 crate::verdict::Finding {
12949 id: id.to_owned(),
12950 severity,
12951 file: None,
12952 line: None,
12953 title: title.to_owned(),
12954 detail: String::new(),
12955 }
12956 }
12957
12958 #[test]
12959 fn pr_body_names_open_findings_and_declined_ones() {
12960 let round = ReviewRound {
12961 round: 2,
12962 head: "deadbee".to_owned(),
12963 verified_head: None,
12964 verified_at: None,
12965 reviews: vec![ReviewRecord {
12966 attempts: 0,
12967 reviewer: 1,
12968 agent: "alpha".to_owned(),
12969 summary: String::new(),
12970 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
12971 vote: None,
12972 failed: None,
12973 duration_ms: 0,
12974 }],
12975 e2e: vec![CommandOutcome {
12976 command: "cargo test".to_owned(),
12977 code: Some(0),
12978 output_tail: String::new(),
12979 duration_ms: 0,
12980 resource_blocked: false,
12981 }],
12982 verify_retried: false,
12983 e2e_deferred: false,
12984 e2e_defer_reason: None,
12985 fix: Some(FixRecord {
12986 agent: "alpha".to_owned(),
12987 addressed: Vec::new(),
12988 rejected: vec![crate::verdict::Rejection {
12989 id: "R1-1-1".to_owned(),
12990 why: "not reachable from any caller".to_owned(),
12991 }],
12992 notes: String::new(),
12993 committed: true,
12994 failed: None,
12995 duration_ms: 0,
12996 continuation: None,
12997 }),
12998 blocking: 0,
12999 answered: 1,
13000 expected: 1,
13001 clean: false,
13002 progressed: true,
13003 vote_split: false,
13004 reconsideration: Vec::new(),
13005 verdict: None,
13006 };
13007 let state = state_with_round(round);
13008 let body = pr_message(&state, 'A').body;
13009
13010 assert!(body.contains("add retries"), "the task must still be there");
13011 assert!(body.contains("R2-1-1"), "{body}");
13012 assert!(body.contains("unused import"), "{body}");
13013 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13014 assert!(
13015 body.contains("not reachable from any caller"),
13016 "the reason it was declined: {body}"
13017 );
13018 }
13019
13020 #[test]
13021 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13022 let round = ReviewRound {
13023 round: 1,
13024 head: "deadbee".to_owned(),
13025 verified_head: None,
13026 verified_at: None,
13027 reviews: vec![ReviewRecord {
13028 attempts: 0,
13029 reviewer: 1,
13030 agent: "alpha".to_owned(),
13031 summary: String::new(),
13032 findings: Vec::new(),
13033 vote: None,
13034 failed: None,
13035 duration_ms: 0,
13036 }],
13037 e2e: Vec::new(),
13038 verify_retried: false,
13039 e2e_deferred: false,
13040 e2e_defer_reason: None,
13041 fix: None,
13042 blocking: 0,
13043 answered: 1,
13044 expected: 1,
13045 clean: true,
13046 progressed: false,
13047 vote_split: false,
13048 reconsideration: Vec::new(),
13049 verdict: None,
13050 };
13051 let state = state_with_round(round);
13052 let body = pr_message(&state, 'A').body;
13053 assert!(!body.contains("Open review findings"), "{body}");
13054 assert!(!body.contains("Declined"), "{body}");
13055 }
13056
13057 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13058 let mut state = RunState::new(
13059 PathBuf::from("/repo"),
13060 "main".to_owned(),
13061 "abc1234".to_owned(),
13062 instruction.to_owned(),
13063 Config::default(),
13064 );
13065 state.candidates.push(Candidate {
13066 index: 0,
13067 label: 'A',
13068 agent: "alpha".to_owned(),
13069 branch: "magi/x/A".to_owned(),
13070 worktree: PathBuf::from("/wt"),
13071 summary: summary.to_owned(),
13072 stat: String::new(),
13073 files: 1,
13074 commits: 1,
13075 empty: false,
13076 failed: None,
13077 verified_noop: None,
13078 folded: false,
13079 duration_ms: 0,
13080 });
13081 state
13082 }
13083
13084 fn review_state(subjects: &[&str]) -> RunState {
13085 let mut state = state_with_summary(
13086 "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",
13087 "",
13088 );
13089 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13090 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13091 state
13092 }
13093
13094 #[tokio::test]
13097 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13098 ask_test_home();
13099 let tmp = tempfile::tempdir().unwrap();
13100 let repo = tmp.path().join("repo");
13101 std::fs::create_dir_all(&repo).unwrap();
13102 init_repo(&repo);
13103 let origin = tmp.path().join("origin.git");
13104 let g = |dir: &Path, args: &[&str]| {
13105 let out = std::process::Command::new("git")
13106 .args(args)
13107 .current_dir(dir)
13108 .quiet()
13109 .output()
13110 .expect("spawn git");
13111 assert!(
13112 out.status.success(),
13113 "git {args:?}: {}",
13114 String::from_utf8_lossy(&out.stderr)
13115 );
13116 };
13117 g(
13118 tmp.path(),
13119 &[
13120 "clone",
13121 "--bare",
13122 "-q",
13123 repo.to_str().unwrap(),
13124 origin.to_str().unwrap(),
13125 ],
13126 );
13127 g(
13128 &repo,
13129 &["remote", "add", "origin", origin.to_str().unwrap()],
13130 );
13131 let c1 = git::rev_parse(&repo, "main").await.unwrap();
13132
13133 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
13135 g(&repo, &["add", "-A"]);
13136 g(
13137 &repo,
13138 &["commit", "-q", "-m", "chore(deps): update a crate"],
13139 );
13140 g(&repo, &["push", "-q", "origin", "main"]);
13141 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
13142 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13143 g(&repo, &["add", "-A"]);
13144 g(
13145 &repo,
13146 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
13147 );
13148 g(&repo, &["checkout", "-q", "main"]);
13149
13150 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
13151 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
13152 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
13154 assert_eq!(fallback, c1);
13155
13156 let mut state = review_state(&[
13157 "chore(deps): update a crate",
13158 "fix(daemon): apply a chosen action",
13159 ]);
13160 state.repo = repo.clone();
13161 state.base_branch = "main".to_owned();
13162 state.base_commit = c1;
13163 refresh_reviewed_commits(&mut state, "feat/own").await;
13164 assert_eq!(
13165 state.reviewed_commits,
13166 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
13167 );
13168 assert_eq!(
13169 review_title(&state).as_deref(),
13170 Some("fix(daemon): apply a chosen action")
13171 );
13172 assert_eq!(
13173 leaked_subjects(&state, "feat/own").await,
13174 Some(vec!["chore(deps): update a crate".to_owned()])
13175 );
13176 state.config.merge.remote = "nowhere".to_owned();
13179 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
13180 }
13181
13182 struct SummaryFixture {
13186 _tmp: tempfile::TempDir,
13187 repo: PathBuf,
13188 reply: PathBuf,
13189 runner: Runner,
13190 }
13191
13192 fn summary_fixture() -> SummaryFixture {
13193 ask_test_home();
13194 let tmp = tempfile::tempdir().unwrap();
13195 let repo = tmp.path().join("repo");
13196 std::fs::create_dir_all(&repo).unwrap();
13197 init_repo(&repo);
13198 let origin = tmp.path().join("origin.git");
13199 let g = |args: &[&str]| {
13200 let out = std::process::Command::new("git")
13201 .args(args)
13202 .current_dir(&repo)
13203 .quiet()
13204 .output()
13205 .expect("spawn git");
13206 assert!(
13207 out.status.success(),
13208 "git {args:?}: {}",
13209 String::from_utf8_lossy(&out.stderr)
13210 );
13211 };
13212 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
13213 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
13214 g(&["push", "-q", "origin", "main"]);
13215 g(&["checkout", "-q", "-b", "feat/own"]);
13216 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13217 g(&["add", "-A"]);
13218 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
13219 let reply = tmp.path().join("reply.txt");
13220 std::fs::write(
13221 &reply,
13222 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
13223 )
13224 .unwrap();
13225 let mut runner = runner_at(RunStatus::Gating);
13226 let mut state = review_state(&["feat(own): add the own file"]);
13227 state.repo = repo.clone();
13228 state.base_branch = "main".to_owned();
13229 state.candidates[0].branch = "feat/own".to_owned();
13230 state.candidates[0].worktree = repo.clone();
13231 let mut author = spec("alpha");
13232 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
13233 author
13234 .env
13235 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
13236 state.config.agents = vec![author];
13237 runner.state = state;
13238 SummaryFixture {
13239 _tmp: tmp,
13240 repo,
13241 reply,
13242 runner,
13243 }
13244 }
13245
13246 async fn posted(f: &mut SummaryFixture) -> PrMessage {
13247 let winner = f.runner.state.candidates[0].clone();
13248 f.runner
13249 .guarded_pr_message(&winner, None, true)
13250 .await
13251 .unwrap()
13252 .expect("an English description never parks")
13253 }
13254
13255 #[tokio::test]
13256 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
13257 let mut f = summary_fixture();
13258 let m = posted(&mut f).await;
13259 assert_eq!(m.title, "feat(own): add the own file");
13260 assert!(
13261 m.body
13262 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
13263 "{}",
13264 m.body
13265 );
13266 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
13267 assert!(m.body.contains("## Commits under review"), "{}", m.body);
13268 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13269 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13270 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
13271 let kept = f.runner.state.change_summary.as_ref().unwrap();
13272 assert_eq!(
13273 (kept.head.as_str(), kept.base.as_str()),
13274 (head.as_str(), base.as_str())
13275 );
13276 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
13277 }
13278
13279 #[tokio::test]
13280 async fn review_only_summary_receives_the_configured_github_language() {
13281 let mut f = summary_fixture();
13282 f.runner.state.config.graph.github_language = "ja".into();
13283 let captured = f.repo.join("summary-prompt.txt");
13284 let author = &mut f.runner.state.config.agents[0];
13285 author.command = vec![
13286 "sh".into(),
13287 "-c".into(),
13288 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
13289 ];
13290 author.env.insert(
13291 "PROMPT_FILE".into(),
13292 captured.to_string_lossy().into_owned(),
13293 );
13294 std::fs::write(
13295 &f.reply,
13296 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
13297 )
13298 .unwrap();
13299 let m = posted(&mut f).await;
13300 let prompt = std::fs::read_to_string(captured).unwrap();
13301 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
13302 assert!(!prompt.contains("in English"));
13303 assert_eq!(m.title, "feat(own): ファイルを追加");
13304 assert!(m.body.contains("own.txt を追加します。"));
13305 }
13306
13307 #[tokio::test]
13308 async fn review_fixes_come_after_the_whole_change_summary() {
13309 let mut f = summary_fixture();
13310 f.runner.state.reviews = vec![
13311 round_with_notes(1, Some("handled the PATH case")),
13312 round_with_notes(2, None),
13313 ];
13314 let m = posted(&mut f).await;
13315 let summary = m.body.find("## Summary").expect("summary");
13316 let fixes = m.body.find("## Review fixes").expect("review fixes");
13317 assert!(summary < fixes, "{}", m.body);
13318 assert!(m.body.contains("handled the PATH case"), "{}", m.body);
13319 assert!(m.body.contains("## Final verification"), "{}", m.body);
13320 }
13321
13322 #[tokio::test]
13323 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
13324 let mut f = summary_fixture();
13325 let first = posted(&mut f).await;
13326 let first_head = f.runner.state.change_summary.clone().unwrap().head;
13327 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
13329 let again = posted(&mut f).await;
13330 assert_eq!((again.title, again.body), (first.title, first.body));
13331
13332 let g = |args: &[&str]| {
13333 let out = std::process::Command::new("git")
13334 .args(args)
13335 .current_dir(&f.repo)
13336 .quiet()
13337 .output()
13338 .expect("spawn git");
13339 assert!(out.status.success(), "git {args:?}");
13340 };
13341 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
13342 g(&["add", "-A"]);
13343 g(&["commit", "-q", "-m", "fix(own): handle more"]);
13344 let second = posted(&mut f).await;
13345 let kept = f.runner.state.change_summary.clone().unwrap();
13346 assert_ne!(kept.head, first_head);
13347 assert!(second.body.contains("Not read yet."), "{}", second.body);
13348 assert!(!second.body.contains("so the first summary is read"));
13349 assert_eq!(second.title, "feat(own): other");
13350 }
13351
13352 #[tokio::test]
13353 async fn an_unavailable_summary_is_stated_and_never_invented() {
13354 let mut f = summary_fixture();
13355 std::fs::write(&f.reply, "no title line here").unwrap();
13356 let m = posted(&mut f).await;
13357 assert!(
13358 m.body
13359 .contains("No description of what this change does could be generated"),
13360 "{}",
13361 m.body
13362 );
13363 assert!(m.body.contains("do not \nexplain it") || m.body.contains("do not explain it"));
13364 assert!(f.runner.state.change_summary.is_none());
13365 assert!(m.body.contains("## Commits under review"), "{}", m.body);
13366 let winner = f.runner.state.candidates[0].clone();
13368 let preview = f
13369 .runner
13370 .guarded_pr_message(&winner, None, false)
13371 .await
13372 .unwrap()
13373 .unwrap();
13374 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
13375 }
13376
13377 #[test]
13378 fn pr_message_review_single_commit_uses_its_subject() {
13379 let state = review_state(&["feat(nats): per-role user"]);
13380 let m = pr_message(&state, 'A');
13381 assert_eq!(m.title, "feat(nats): per-role user");
13382 assert!(!m.body.contains("Review the work already"), "{}", m.body);
13383 assert!(m.body.contains("## Commits under review"), "{}", m.body);
13384 }
13385
13386 #[test]
13387 fn pr_message_review_multi_commit_takes_the_oldest() {
13388 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
13389 let m = pr_message(&state, 'A');
13390 assert_eq!(m.title, "feat: the change");
13391 for s in ["feat: the change", "fix: typo", "fix: again"] {
13392 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
13393 }
13394 }
13395
13396 #[test]
13397 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
13398 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
13399 let mut state = review_state(&[first, "fix: later fixup"]);
13400 state.candidates[0].branch = "機能/ログイン".to_owned();
13401 let m = pr_message(&state, 'A');
13402 assert!(
13403 m.title.starts_with("chore: land candidate A of run"),
13404 "{}",
13405 m.title
13406 );
13407 }
13408 }
13409
13410 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
13411 BranchFacts {
13412 commits: commits
13413 .iter()
13414 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
13415 .collect(),
13416 stat: stat.to_owned(),
13417 }
13418 }
13419
13420 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
13421 let mut r = review_round(true, 0, 1, 1, true, true);
13422 r.round = round;
13423 r.fix = notes.map(|n| FixRecord {
13424 agent: "fixer".to_owned(),
13425 addressed: Vec::new(),
13426 rejected: Vec::new(),
13427 notes: n.to_owned(),
13428 committed: true,
13429 failed: None,
13430 duration_ms: 0,
13431 continuation: None,
13432 });
13433 r
13434 }
13435
13436 #[test]
13437 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
13438 let state = review_state(&["ignored"]);
13439 let f = facts(
13440 &[
13441 (
13442 "fix(login): resolve PATH on macOS",
13443 "Login shells skip rc files.",
13444 ),
13445 ("fix: address review", ""),
13446 ],
13447 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
13448 );
13449 let m = pr_message_with(&state, 'A', Some(&f));
13450 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
13451 assert!(
13452 m.body
13453 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
13454 "{}",
13455 m.body
13456 );
13457 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
13458 assert!(m.body.contains("## Diff stat"), "{}", m.body);
13459 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
13460 for banned in [
13461 "Review the work already",
13462 "no task statement",
13463 "Original task",
13464 ] {
13465 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
13466 }
13467 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
13468 }
13469
13470 #[test]
13471 fn pr_message_review_truncates_a_huge_first_commit_body() {
13472 let state = review_state(&["ignored"]);
13473 let f = facts(
13474 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
13475 "s",
13476 );
13477 let m = pr_message_with(&state, 'A', Some(&f));
13478 assert!(m.body.len() < 30_000, "{}", m.body.len());
13479 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
13480 assert!(
13481 m.body.contains("- ... 1 more commit(s)"),
13482 "{}",
13483 m.body.len()
13484 );
13485 assert!(m.body.ends_with("magi:candidate-a\n"));
13486 }
13487
13488 #[test]
13489 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
13490 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
13491 assert_eq!(m.title, "feat: x");
13492 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
13493 assert!(!m.body.contains("Diff stat"), "{}", m.body);
13494 assert!(!m.body.contains("no task statement"), "{}", m.body);
13495 }
13496
13497 #[test]
13498 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
13499 let mut state = review_state(&["日本語の件名"]);
13500 state.candidates[0].branch = "fix/macos-login-path".to_owned();
13501 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
13502 state.candidates[0].branch = "機能/ログイン".to_owned();
13503 assert!(
13504 pr_message(&state, 'A')
13505 .title
13506 .starts_with("chore: land candidate A")
13507 );
13508 }
13509
13510 #[test]
13511 fn pr_message_review_fixes_survive_a_clean_final_round() {
13512 let mut state = review_state(&["feat: x"]);
13513 state.reviews = vec![
13514 round_with_notes(1, Some("handled the PATH case")),
13515 round_with_notes(2, None),
13516 ];
13517 let body = pr_message(&state, 'A').body;
13518 assert!(
13519 body.contains("## Review fixes\n\nhandled the PATH case\n"),
13520 "{body}"
13521 );
13522 assert!(!body.contains("### Round"), "{body}");
13523
13524 state.reviews = vec![
13525 round_with_notes(1, Some("first fix")),
13526 round_with_notes(2, Some("")),
13527 round_with_notes(3, Some("second fix")),
13528 round_with_notes(4, None),
13529 ];
13530 let body = pr_message(&state, 'A').body;
13531 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
13532 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
13533 assert!(!body.contains("### Round 2"), "{body}");
13534 }
13535
13536 const FINAL: &str = "abcdef1234567890";
13537
13538 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
13539 let mut r = round_with_notes(round, None);
13540 r.verified_head = verified.map(str::to_owned);
13541 r
13542 }
13543
13544 fn ok_gate(state: &mut RunState, head: Option<&str>) {
13545 state.gate = vec![CommandOutcome {
13546 command: "gate".to_owned(),
13547 code: Some(0),
13548 output_tail: String::new(),
13549 duration_ms: 0,
13550 resource_blocked: false,
13551 }];
13552 state.gate_ran = true;
13553 state.gate_head = head.map(str::to_owned);
13554 }
13555
13556 fn body_at(state: &RunState) -> String {
13557 pr_message_raw(
13558 state,
13559 'A',
13560 None,
13561 crate::pr_outcome::FinalHead::At(FINAL),
13562 ChangeNote::NotAsked,
13563 )
13564 .body
13565 }
13566
13567 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
13568
13569 #[test]
13570 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
13571 let mut state = review_state(&["feat: x"]);
13572 state.reviews = vec![
13573 round_with_notes(1, Some(SANDBOXED)),
13574 passing_round(2, Some(FINAL)),
13575 ];
13576 ok_gate(&mut state, Some(FINAL));
13577 let body = body_at(&state);
13578 assert!(body.contains("Handled the PATH case."), "{body}");
13579 assert!(!body.contains("could not commit"), "{body}");
13580 assert!(!body.contains("could not run"), "{body}");
13581 assert!(
13582 body.contains("The fix is committed on the branch"),
13583 "{body}"
13584 );
13585 assert!(body.contains("2 statement(s)"), "{body}");
13586 assert!(
13587 body.contains("Tests (`verify.e2e`): passed on the final commit"),
13588 "{body}"
13589 );
13590 assert!(
13591 body.contains("Gate (`verify.gate`): passed on the final commit"),
13592 "{body}"
13593 );
13594 assert!(
13596 state.reviews[0]
13597 .fix
13598 .as_ref()
13599 .unwrap()
13600 .notes
13601 .contains("could not commit")
13602 );
13603 }
13604
13605 #[test]
13606 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
13607 let mut state = review_state(&["feat: x"]);
13608 let mut r1 = round_with_notes(1, Some(SANDBOXED));
13609 r1.fix.as_mut().unwrap().committed = false;
13610 let mut r2 = passing_round(2, Some(FINAL));
13611 r2.e2e[0].code = Some(1);
13612 state.reviews = vec![r1, r2];
13613 let body = body_at(&state);
13614 assert!(body.contains("could not commit"), "{body}");
13615 assert!(body.contains("could not run"), "{body}");
13616 assert!(body.contains("This round produced no commit"), "{body}");
13617 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
13618 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
13619 assert!(!body.contains("passed"), "{body}");
13620 }
13621
13622 #[test]
13623 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
13624 let mut state = review_state(&["feat: x"]);
13625 state.reviews = vec![
13626 round_with_notes(1, Some(SANDBOXED)),
13627 passing_round(2, Some("0123456789abcdef")),
13628 ];
13629 ok_gate(&mut state, Some("0123456789abcdef"));
13630 let body = body_at(&state);
13631 assert!(body.contains("HTTP tests could not run"), "{body}");
13632 assert!(!body.contains("could not commit"), "{body}");
13634 assert!(
13635 body.contains("passed on an earlier commit (`0123456`)"),
13636 "{body}"
13637 );
13638 assert!(!body.contains("passed on the final commit"), "{body}");
13639
13640 ok_gate(&mut state, None);
13642 let body = body_at(&state);
13643 assert!(
13644 body.contains("passed on a commit that was not recorded"),
13645 "{body}"
13646 );
13647 }
13648
13649 #[test]
13650 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
13651 let state = state_with_summary(
13652 "add retries to the client",
13653 "TITLE: feat: retries\n\nDid it.",
13654 );
13655 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
13656 assert_eq!(m.title, "feat: retries");
13657 assert!(
13658 m.body.contains("<summary>Original task</summary>"),
13659 "{}",
13660 m.body
13661 );
13662 assert!(!m.body.contains("Commits under review"), "{}", m.body);
13663 assert!(
13664 m.body
13665 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
13666 "{}",
13667 m.body
13668 );
13669 }
13670
13671 #[test]
13672 fn pr_message_review_bounds_a_long_english_subject() {
13673 let long = format!("feat: {}", "word ".repeat(100));
13674 let m = pr_message(&review_state(&[&long]), 'A');
13675 assert!(m.title.starts_with("feat: word"), "{}", m.title);
13676 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13677 }
13678
13679 #[test]
13680 fn pr_message_implementation_run_is_unchanged_by_review_support() {
13681 let state = state_with_summary("add retries\n\ndetails", "- did some things");
13682 let m = pr_message(&state, 'A');
13683 assert_eq!(m.title, "add retries");
13684 assert!(m.body.contains("<summary>Original task</summary>"));
13685 assert!(!m.body.contains("Commits under review"));
13686 assert_eq!(landing_subject_source(&state), state.instruction);
13687 }
13688
13689 #[test]
13690 fn review_run_squash_subject_is_the_change_not_the_prompt() {
13691 let state = review_state(&["feat: the change", "fix: typo"]);
13692 let source = landing_subject_source(&state);
13693 assert_eq!(land::merge_subject("", &source), "feat: the change");
13694 assert_eq!(
13695 land::merge_subject("magi: candidate A (uncommitted work)", &source),
13696 "feat: the change"
13697 );
13698 assert_eq!(
13700 land::merge_subject("feat: renamed by hand", &source),
13701 "feat: renamed by hand"
13702 );
13703 let blank = review_state(&["日本語"]);
13704 assert!(
13705 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
13706 );
13707 }
13708
13709 #[test]
13710 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
13711 let state = review_state(&["feat: the change"]);
13712 let source = landing_subject_source(&state);
13713 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
13714 assert_eq!(
13715 land::merge_subject(landing_title(&state, old), &source),
13716 "feat: the change"
13717 );
13718 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
13719 let task = state_with_summary("add retries", "");
13720 assert_eq!(landing_title(&task, old), old);
13721 }
13722
13723 #[test]
13724 fn pr_message_describes_the_change_not_the_task() {
13725 let state = state_with_summary(
13726 "今回やってほしいこと: results projector を直す",
13727 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
13728 );
13729 let m = pr_message(&state, 'A');
13730 assert_eq!(m.title, "fix(web): batch the runs list reads");
13731 assert!(
13732 m.body.starts_with("## Summary\n\n- reads run.json once"),
13733 "{}",
13734 m.body
13735 );
13736 assert!(!m.body.contains("TITLE:"), "{}", m.body);
13737 let task_at = m.body.find("今回やってほしいこと").unwrap();
13738 let details_at = m.body.find("<details>").unwrap();
13739 assert!(
13740 details_at < task_at,
13741 "the task lives inside <details>: {}",
13742 m.body
13743 );
13744 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
13745 assert!(m.body.contains("magi:candidate-a"));
13746 }
13747
13748 #[test]
13749 fn pr_message_falls_back_to_the_task_without_a_title_line() {
13750 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
13751 let m = pr_message(&state, 'A');
13752 assert_eq!(m.title, "add retries");
13753 assert!(
13754 m.body.contains("## Summary\n\n- did some things"),
13755 "{}",
13756 m.body
13757 );
13758
13759 let none = RunState::new(
13760 PathBuf::from("/repo"),
13761 "main".to_owned(),
13762 "abc1234".to_owned(),
13763 "add retries".to_owned(),
13764 Config::default(),
13765 );
13766 let m = pr_message(&none, 'A');
13767 assert_eq!(m.title, "add retries");
13768 assert!(!m.body.contains("## Summary"), "{}", m.body);
13769 }
13770
13771 #[test]
13772 fn pr_message_refuses_the_candidate_commit_subject() {
13773 for bad in [
13774 "TITLE: magi: candidate A (uncommitted work)",
13775 "TITLE: chore: stuff (uncommitted work)",
13776 "TITLE: ",
13777 ] {
13778 let state = state_with_summary("add retries", bad);
13779 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
13780 }
13781 }
13782
13783 #[test]
13784 fn pr_message_bounds_a_very_long_task_and_title() {
13785 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
13786 let state = state_with_summary(&long, "- nothing");
13787 let m = pr_message(&state, 'A');
13788 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13789 assert!(!m.title.contains('\n'));
13790
13791 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
13792 let m = pr_message(&state, 'A');
13793 assert!(m.title.starts_with("feat: "));
13794 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13795 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
13796 }
13797
13798 fn long_title_of(instruction: &str) -> String {
13799 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
13800 }
13801
13802 #[test]
13803 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
13804 let first = "Make the landing path keep a readable title for long tasks";
13805 let line = format!(
13806 "{first}. {}",
13807 "And then keep going with more words ".repeat(20)
13808 );
13809 let t = long_title_of(&line);
13810 assert_eq!(t, first);
13811 assert!(!t.starts_with("chore: land"));
13812 }
13813
13814 #[test]
13815 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
13816 let line = "word ".repeat(200);
13817 let t = long_title_of(&line);
13818 assert!(t.ends_with("word..."), "{t}");
13819 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
13820 }
13821
13822 #[test]
13823 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
13824 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
13825 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
13826 }
13827 }
13828
13829 #[test]
13830 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
13831 let mut state = state_with_summary("再試行を追加する", "- nothing");
13832 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
13833 state.config.graph.github_language = "ja".into();
13834 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
13835 }
13836
13837 #[test]
13838 fn pr_message_title_limit_is_exact() {
13839 let at = "a".repeat(PR_TITLE_MAX);
13840 assert_eq!(long_title_of(&at), at);
13841 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
13842 assert!(over.ends_with("..."), "{over}");
13843 assert_eq!(over.chars().count(), PR_TITLE_MAX);
13844 }
13845
13846 #[test]
13847 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
13848 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
13849 let t = long_title_of(&line);
13850 assert!(t.ends_with("..."), "{t}");
13851 assert!(t.is_ascii(), "{t}");
13852 }
13853
13854 #[test]
13855 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
13856 let line = format!(
13857 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
13858 "plus more filler words ".repeat(20)
13859 );
13860 let t = long_title_of(&line);
13861 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
13862 }
13863
13864 #[test]
13865 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
13866 let line = format!("\n\n# {}", "title words ".repeat(40));
13867 let state = state_with_summary(&line, "- nothing");
13868 let m = pr_message(&state, 'A');
13869 assert!(m.title.starts_with("title words"), "{}", m.title);
13870 assert_eq!(
13871 land::merge_subject(&m.title, &landing_subject_source(&state)),
13872 m.title
13873 );
13874 assert_eq!(
13876 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
13877 "feat: renamed by hand"
13878 );
13879 }
13880
13881 #[tokio::test]
13882 async fn github_text_rewrite_is_bounded_and_falls_back() {
13883 crate::run::pin_test_home();
13884 for (reply, accepted) in [
13885 (
13886 "TITLE: fix: retries\nAdd retries for failed requests.",
13887 true,
13888 ),
13889 (
13890 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
13891 false,
13892 ),
13893 (
13894 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
13895 true,
13896 ),
13897 (
13898 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
13899 true,
13900 ),
13901 (
13902 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
13903 true,
13904 ),
13905 (
13906 "TITLE: fix: retries\n日本語の説明です。 token=secret",
13907 false,
13908 ),
13909 ("TITLE: fix: retries\n", false),
13910 ] {
13911 let dir = tempfile::tempdir().unwrap();
13912 let mut runner = runner_at(RunStatus::Gating);
13913 runner.state = state_with_summary(
13914 "add retries",
13915 "TITLE: fix: retries\n日本語の説明を書きます。",
13916 );
13917 runner.state.repo = dir.path().to_owned();
13918 runner.state.candidates[0].worktree = dir.path().to_owned();
13919 let mut author = spec("alpha");
13920 author.command = vec![
13921 "sh".into(),
13922 "-c".into(),
13923 "printf '%s' \"$REWRITE_REPLY\"".into(),
13924 ];
13925 author.env.insert("REWRITE_REPLY".into(), reply.into());
13926 runner.state.config.agents = vec![author];
13927 let winner = runner.state.candidates[0].clone();
13928 let first = runner
13929 .guarded_pr_message(&winner, None, true)
13930 .await
13931 .unwrap();
13932 let message = if accepted {
13933 first.expect("a passing rewrite never asks")
13934 } else {
13935 assert!(first.is_none());
13937 assert!(runner.state.parked);
13938 let store = ask::Questions::open();
13939 let asked: Vec<_> = store
13940 .list()
13941 .into_iter()
13942 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
13943 .collect();
13944 assert_eq!(asked.len(), 1);
13945 assert!(!asked[0].detail.contains("secret"));
13946 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
13947 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
13948 assert_eq!(
13949 asked[0]
13950 .choices
13951 .contains(&crate::github_text::USE_REWRITE.to_owned()),
13952 rewrite.is_some()
13953 );
13954 store
13957 .update(&asked[0].id, |q| {
13958 q.abandon("test");
13959 Ok(())
13960 })
13961 .unwrap();
13962 let again = runner
13963 .guarded_pr_message(&winner, None, true)
13964 .await
13965 .unwrap()
13966 .expect("an abandoned question falls back");
13967 let n = store
13968 .list()
13969 .into_iter()
13970 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
13971 .count();
13972 assert_eq!(n, 1, "asked once per rejected text");
13973 again
13974 };
13975 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
13976 assert!(!message.body.contains("secret"));
13977 assert!(!message.body.contains("prefix"));
13978 assert!(!message.title.contains("secret"));
13979 assert_eq!(
13980 message.body.contains("Add retries for failed requests."),
13981 accepted
13982 );
13983 assert_eq!(
13984 runner.state.seats["github-text-rewrite"].turns, 1,
13985 "only one rewrite invocation"
13986 );
13987 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
13988 if accepted {
13989 assert!(runner.state.github_text.is_none());
13990 assert!(!runner.state.parked);
13991 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
13992 assert!(
13993 message
13994 .body
13995 .contains(&format!("magi:run/{}", runner.state.id))
13996 );
13997 }
13998 if !accepted {
13999 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14000 assert!(
14001 message
14002 .body
14003 .contains(&format!("magi:run/{}", runner.state.id))
14004 );
14005 }
14006 }
14007 }
14008
14009 fn scripted(id: &str, reply: &str) -> AgentSpec {
14010 let mut a = spec(id);
14011 a.command = vec![
14012 "sh".into(),
14013 "-c".into(),
14014 "printf '%s' \"$REWRITE_REPLY\"".into(),
14015 ];
14016 a.env.insert("REWRITE_REPLY".into(), reply.into());
14017 a
14018 }
14019
14020 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
14021 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
14022
14023 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
14024 let mut runner = runner_at(RunStatus::Gating);
14025 runner.state = state_with_summary("add retries", JA_SUMMARY);
14026 if review_only {
14027 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14028 }
14029 runner.state.repo = dir.to_owned();
14030 runner.state.candidates[0].worktree = dir.to_owned();
14031 runner.state.config.agents = agents;
14032 runner
14033 }
14034
14035 fn gate_questions(runner: &Runner) -> usize {
14036 ask::Questions::open()
14037 .list()
14038 .into_iter()
14039 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14040 .count()
14041 }
14042
14043 #[tokio::test]
14044 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
14045 crate::run::pin_test_home();
14046 let dir = tempfile::tempdir().unwrap();
14047 let mut runner = rewrite_runner(
14048 dir.path(),
14049 true,
14050 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14051 );
14052 runner.state.config.roles.language_judge =
14053 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
14054 let winner = runner.state.candidates[0].clone();
14055 let msg = runner
14056 .guarded_pr_message(&winner, None, true)
14057 .await
14058 .unwrap()
14059 .expect("an English rewrite posts without asking");
14060 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
14061 assert!(msg.body.contains("<details>kept</details>"));
14062 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
14063 assert_eq!(gate_questions(&runner), 0);
14064 assert!(runner.state.github_text.is_none());
14065 assert!(!runner.state.seats.contains_key("impl-A"));
14066 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14067 }
14068
14069 #[tokio::test]
14070 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
14071 crate::run::pin_test_home();
14072 let dir = tempfile::tempdir().unwrap();
14073 let mut runner = rewrite_runner(
14074 dir.path(),
14075 false,
14076 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14077 );
14078 runner.state.config.roles.language_judge =
14079 Some(crate::config::AgentChoice::One("beta".into()));
14080 runner.state.seats.insert(
14081 "impl-A".into(),
14082 SeatState::new("impl-A", "alpha", runner.state.seed),
14083 );
14084 let winner = runner.state.candidates[0].clone();
14085 let msg = runner
14086 .guarded_pr_message(&winner, None, true)
14087 .await
14088 .unwrap()
14089 .expect("rewritten");
14090 assert!(msg.body.contains("<details>kept</details>"));
14091 assert_eq!(runner.state.seats["impl-A"].turns, 0);
14092 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14093 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
14094 }
14095
14096 #[tokio::test]
14097 async fn rewrite_reuses_a_resumable_implementer_session() {
14098 crate::run::pin_test_home();
14099 let dir = tempfile::tempdir().unwrap();
14100 let mut runner = rewrite_runner(
14101 dir.path(),
14102 false,
14103 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
14104 );
14105 runner.state.config.roles.language_judge =
14106 Some(crate::config::AgentChoice::One("beta".into()));
14107 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
14108 seat.turns = 1;
14109 runner.state.seats.insert("impl-A".into(), seat);
14110 let winner = runner.state.candidates[0].clone();
14111 runner
14112 .guarded_pr_message(&winner, None, true)
14113 .await
14114 .unwrap()
14115 .expect("rewritten");
14116 assert_eq!(runner.state.seats["impl-A"].turns, 2);
14117 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
14118 }
14119
14120 #[tokio::test]
14121 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
14122 crate::run::pin_test_home();
14123 let dir = tempfile::tempdir().unwrap();
14124 let mut broken = spec("bad");
14125 broken.command = vec!["false".into()];
14126 let mut runner = rewrite_runner(
14127 dir.path(),
14128 true,
14129 vec![
14130 broken,
14131 scripted("noformat", "Add retries."),
14132 scripted("good", EN_REWRITE),
14133 ],
14134 );
14135 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
14136 "bad".into(),
14137 "noformat".into(),
14138 "good".into(),
14139 ]));
14140 let winner = runner.state.candidates[0].clone();
14141 let msg = runner
14142 .guarded_pr_message(&winner, None, true)
14143 .await
14144 .unwrap()
14145 .expect("the third agent rewrites");
14146 assert!(msg.body.contains("`src/net.rs`"));
14147 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
14148 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
14149 }
14150
14151 #[tokio::test]
14152 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
14153 crate::run::pin_test_home();
14154 for (agents, role, saved) in [
14156 (
14157 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
14158 Some("beta"),
14159 true,
14160 ),
14161 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
14162 (Vec::new(), None, false),
14163 ] {
14164 let dir = tempfile::tempdir().unwrap();
14165 let mut runner = rewrite_runner(dir.path(), true, agents);
14166 runner.state.config.roles.language_judge =
14167 role.map(|r| crate::config::AgentChoice::One(r.into()));
14168 let winner = runner.state.candidates[0].clone();
14169 let first = runner
14170 .guarded_pr_message(&winner, None, true)
14171 .await
14172 .unwrap();
14173 assert!(first.is_none(), "withheld text parks the run");
14174 assert!(runner.state.parked);
14175 assert_eq!(gate_questions(&runner), 1);
14176 let g = runner.state.github_text.as_ref().unwrap();
14177 assert_eq!(g.rewrite.is_some(), saved);
14178 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
14179 }
14180 }
14181
14182 #[tokio::test]
14183 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
14184 crate::run::pin_test_home();
14185 let dir = tempfile::tempdir().unwrap();
14186 let mut runner = runner_at(RunStatus::Gating);
14187 runner.state =
14188 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
14189 runner.state.repo = dir.path().to_owned();
14190 runner.state.candidates[0].worktree = dir.path().to_owned();
14191 let mut author = spec("alpha");
14192 author.command = vec![
14193 "sh".into(),
14194 "-c".into(),
14195 "printf '%s' \"$REWRITE_REPLY\"".into(),
14196 ];
14197 author.env.insert(
14198 "REWRITE_REPLY".into(),
14199 format!(
14200 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
14201 "English sentence. ".repeat(600)
14202 ),
14203 );
14204 runner.state.config.agents = vec![author];
14205 let winner = runner.state.candidates[0].clone();
14206 assert!(
14207 runner
14208 .guarded_pr_message(&winner, None, true)
14209 .await
14210 .unwrap()
14211 .is_none()
14212 );
14213 let record = runner.state.github_text.clone().unwrap();
14214 let rewrite = record.rewrite.clone().unwrap();
14215 assert_eq!(rewrite.failed, ["body-language"]);
14216 assert!(
14217 rewrite
14218 .body
14219 .ends_with(&format!("magi:run/{}", runner.state.id))
14220 );
14221 let store = ask::Questions::open();
14222 let q = store.get(record.question.as_ref().unwrap()).unwrap();
14223 assert!(
14224 q.choices
14225 .contains(&crate::github_text::USE_REWRITE.to_owned())
14226 );
14227 for label in [
14228 "ORIGINAL",
14229 "FALLBACK",
14230 "REWRITE",
14231 "Rules still failed: body-language",
14232 "truncated",
14233 ] {
14234 assert!(q.detail.contains(label), "{label}");
14235 }
14236 let artifact = std::fs::read_to_string(crate::run::artifact_path(
14237 &runner.state,
14238 &crate::github_text::artifact_name(&record.fingerprint),
14239 ))
14240 .unwrap();
14241 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
14242 runner.state =
14244 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
14245 store
14246 .update(&q.id, |q| {
14247 q.say("まだ日本語")?;
14248 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
14249 })
14250 .unwrap();
14251 assert!(
14252 runner
14253 .guarded_pr_message(&winner, None, true)
14254 .await
14255 .unwrap()
14256 .is_none()
14257 );
14258 let retry = runner.state.github_text.as_ref().unwrap();
14259 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
14260 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
14261 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
14262 assert!(
14263 q.posting_comparison
14264 .as_ref()
14265 .unwrap()
14266 .matches_detail(&q.detail)
14267 );
14268 store
14269 .update(&q.id, |q| {
14270 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14271 })
14272 .unwrap();
14273 let message = runner
14274 .guarded_pr_message(&winner, None, true)
14275 .await
14276 .unwrap()
14277 .unwrap();
14278 assert_eq!(message.title, rewrite.title);
14279 assert_eq!(message.body, rewrite.body);
14280 assert_eq!(
14281 runner.state.github_text.as_ref().unwrap().choice,
14282 Some(GithubTextChoice::Rewrite)
14283 );
14284 let again = runner
14285 .guarded_pr_message(&winner, None, true)
14286 .await
14287 .unwrap()
14288 .unwrap();
14289 assert_eq!(again.title, message.title);
14290 assert_eq!(again.body, message.body);
14291 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14292 assert!(
14293 !runner
14294 .state
14295 .events
14296 .iter()
14297 .any(|e| e.message.contains(&rewrite.title)
14298 || e.message.contains("English sentence."))
14299 );
14300 }
14301
14302 #[tokio::test]
14303 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
14304 crate::run::pin_test_home();
14305 let dir = tempfile::tempdir().unwrap();
14306 let mut runner = runner_at(RunStatus::Gating);
14307 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
14308 runner.state.repo = dir.path().to_owned();
14309 runner.state.candidates[0].worktree = dir.path().to_owned();
14310 let winner = runner.state.candidates[0].clone();
14311 assert!(
14312 runner
14313 .guarded_pr_message(&winner, None, true)
14314 .await
14315 .unwrap()
14316 .is_none()
14317 );
14318 let record = runner.state.github_text.as_ref().unwrap();
14319 assert!(record.rewrite.is_none());
14320 let mut old = serde_json::to_value(record).unwrap();
14322 old.as_object_mut().unwrap().remove("rewrite");
14323 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
14324 assert!(old.rewrite.is_none());
14325 let id = record.question.clone().unwrap();
14326 ask::Questions::open()
14327 .update(&id, |q| {
14328 q.choices.push(crate::github_text::USE_REWRITE.into());
14329 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14330 })
14331 .unwrap();
14332 let message = runner
14333 .guarded_pr_message(&winner, None, true)
14334 .await
14335 .unwrap()
14336 .unwrap();
14337 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14338 assert_eq!(
14339 runner.state.github_text.as_ref().unwrap().choice,
14340 Some(GithubTextChoice::Fallback)
14341 );
14342 }
14343
14344 #[tokio::test]
14345 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
14346 crate::run::pin_test_home();
14347 let dir = tempfile::tempdir().unwrap();
14348 let mut runner = runner_at(RunStatus::Gating);
14349 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
14350 runner.state.repo = dir.path().to_owned();
14351 runner.state.candidates[0].worktree = dir.path().to_owned();
14352 let winner = runner.state.candidates[0].clone();
14353 assert!(
14354 runner
14355 .guarded_pr_message(&winner, None, true)
14356 .await
14357 .unwrap()
14358 .is_none()
14359 );
14360 let store = ask::Questions::open();
14361 let find = |run: &str| {
14362 store
14363 .list()
14364 .into_iter()
14365 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
14366 .unwrap()
14367 };
14368 let run = runner.state.id.clone();
14369 let q = find(&run);
14370 let fp = runner
14371 .state
14372 .github_text
14373 .as_ref()
14374 .unwrap()
14375 .fingerprint
14376 .clone();
14377 let name = crate::github_text::artifact_name(&fp);
14378 let path = crate::run::artifact_path(&runner.state, &name);
14379 assert!(q.detail.contains(&path.display().to_string()));
14380 assert!(q.detail.contains("title-language: title"));
14381 let saved = std::fs::read_to_string(&path).expect("artifact written");
14382 assert!(saved.contains("日本語のタイトル"));
14383 assert!(q.cwd.is_none());
14384 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
14385 let say = |id: &str, text: &str, choice: &str| {
14387 store
14388 .update(id, |q| {
14389 q.thread.push(ask::Turn {
14390 who: ask::Who::Operator,
14391 body: text.to_owned(),
14392 at: jiff::Timestamp::now(),
14393 note: None,
14394 });
14395 q.answer(ask::Answer::Choice(choice.to_owned()))
14396 })
14397 .unwrap();
14398 };
14399 say(&q.id, "まだ日本語", "use my text");
14400 assert!(
14401 runner
14402 .guarded_pr_message(&winner, None, true)
14403 .await
14404 .unwrap()
14405 .is_none()
14406 );
14407 let second = find(&run);
14408 assert_ne!(second.id, q.id);
14409 assert!(second.detail.contains("did not pass"));
14410 assert!(second.detail.contains("日本語のタイトル"));
14413 assert!(!second.detail.contains("まだ日本語"));
14414 assert!(second.detail.contains(&path.display().to_string()));
14415 say(&second.id, "fix: retry failed requests", "use my text");
14416 let message = runner
14417 .guarded_pr_message(&winner, None, true)
14418 .await
14419 .unwrap()
14420 .expect("a vetted replacement is posted");
14421 assert_eq!(message.title, "fix: retry failed requests");
14422 assert_eq!(
14423 runner
14424 .state
14425 .github_text
14426 .as_ref()
14427 .unwrap()
14428 .chosen_title
14429 .as_deref(),
14430 Some("fix: retry failed requests")
14431 );
14432 assert!(runner.state.github_text.as_ref().unwrap().resolved);
14433 let again = runner
14435 .guarded_pr_message(&winner, None, true)
14436 .await
14437 .unwrap()
14438 .unwrap();
14439 assert_eq!(again.title, message.title);
14440 assert_eq!(
14441 store
14442 .list()
14443 .into_iter()
14444 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
14445 .count(),
14446 2
14447 );
14448 }
14449
14450 #[test]
14451 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
14452 let mut state = state_with_summary(
14456 "add retries",
14457 "TITLE: fix(web): batch reads\n- reads run.json once",
14458 );
14459 state.config.graph.language = "ja".to_owned();
14460 let m = pr_message(&state, 'A');
14461 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
14462 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
14463
14464 let task = "今回やってほしいこと: results projector を直す";
14467 let mut state = state_with_summary(task, "- no title line");
14468 state.config.graph.language = "ja".to_owned();
14469 let m = pr_message(&state, 'A');
14470 assert_eq!(
14471 m.title,
14472 format!("chore: land candidate A of run {}", state.id)
14473 );
14474 assert!(
14475 m.body.contains(&format!(
14476 "<summary>Original task</summary>\n\n{task}\n\n</details>"
14477 )),
14478 "{}",
14479 m.body
14480 );
14481 }
14482
14483 #[test]
14484 fn pr_message_scrubs_home_paths_and_addresses() {
14485 let state = state_with_summary(
14486 "fix it in /Users/someone/src/x",
14487 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
14488 );
14489 let m = pr_message(&state, 'A');
14490 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
14491 assert!(!m.body.contains(leak), "{}", m.body);
14492 }
14493 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
14494 }
14495
14496 #[test]
14497 fn pr_message_survives_a_task_that_closes_details() {
14498 let state = state_with_summary("a </details> b", "TITLE: fix: x");
14499 let m = pr_message(&state, 'A');
14500 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
14501 }
14502
14503 #[test]
14504 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
14505 let cmd = manual_merge_command(
14506 MergeStyle::Squash,
14507 Path::new("/repo"),
14508 "b",
14509 "fix: \"quoted\" $(x) `y`\n\nbody",
14510 );
14511 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
14512 }
14513
14514 #[test]
14515 fn manual_merge_command_matches_the_configured_style() {
14516 let repo = Path::new("/repo");
14517 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
14518
14519 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
14520 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
14521
14522 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
14523 assert_eq!(
14524 squash,
14525 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
14526 \"Merge magi run 0832 (candidate A)\""
14527 );
14528
14529 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
14530 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
14531 }
14532
14533 #[test]
14534 fn a_nudge_gets_a_quarter_of_the_budget() {
14535 assert_eq!(retry_budget(secs(1200), true), secs(300));
14537 assert_eq!(retry_budget(secs(3600), true), secs(900));
14538 }
14539
14540 #[test]
14541 fn a_resent_prompt_keeps_the_whole_budget() {
14542 assert_eq!(retry_budget(secs(1200), false), secs(1200));
14545 assert_eq!(retry_budget(secs(60), false), secs(60));
14546 }
14547
14548 #[test]
14549 fn the_floor_never_exceeds_the_original_budget() {
14550 assert_eq!(retry_budget(secs(60), true), secs(60));
14554 assert_eq!(retry_budget(secs(480), true), secs(120));
14555 assert_eq!(retry_budget(secs(0), true), secs(0));
14556 }
14557
14558 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
14559 agent::CommandEvidence {
14560 id: "item1".to_owned(),
14561 description: "cargo test".to_owned(),
14562 exit_code,
14563 result_summary: String::new(),
14564 source: "codex".to_owned(),
14565 }
14566 }
14567
14568 #[test]
14569 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
14570 assert!(!has_unconfirmed_command(&[]));
14574 }
14575
14576 #[test]
14577 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
14578 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
14582 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
14583 assert!(!has_unconfirmed_command(&[
14584 evidence(Some(0)),
14585 evidence(Some(101))
14586 ]));
14587 }
14588
14589 #[test]
14590 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
14591 assert!(has_unconfirmed_command(&[
14592 evidence(Some(0)),
14593 evidence(None)
14594 ]));
14595 }
14596
14597 #[test]
14598 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
14599 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14600 assert_eq!(
14601 verified_noop_claim(true, &[], text).as_deref(),
14602 Some("already fixed by b32cfc4, on main.")
14603 );
14604 }
14605
14606 #[test]
14607 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
14608 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14611 assert!(verified_noop_claim(false, &[], text).is_none());
14612 }
14613
14614 #[test]
14615 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
14616 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14617 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
14618 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
14620 }
14621
14622 #[test]
14623 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
14624 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
14625 }
14626
14627 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
14630 runner.state.candidates = shape
14631 .iter()
14632 .enumerate()
14633 .map(|(i, &(empty, verified))| Candidate {
14634 index: i,
14635 label: (b'A' + i as u8) as char,
14636 agent: "sonnet".to_owned(),
14637 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
14638 worktree: PathBuf::from(format!("/wt/{i}")),
14639 summary: String::new(),
14640 stat: String::new(),
14641 files: 0,
14642 commits: 0,
14643 empty,
14644 failed: None,
14645 verified_noop: verified.map(str::to_owned),
14646 duration_ms: 0,
14647 folded: false,
14648 })
14649 .collect();
14650 }
14651
14652 #[test]
14653 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
14654 ask_test_home();
14655 let mut runner = runner_at(RunStatus::Implementing);
14656 set_candidates(
14657 &mut runner,
14658 &[
14659 (true, Some("already on main at b32cfc4")),
14660 (true, Some("same fix, see the existing test")),
14661 ],
14662 );
14663
14664 runner
14665 .after_implement()
14666 .expect("a verified no-op is not an error");
14667
14668 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
14669 }
14670
14671 #[test]
14672 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
14673 ask_test_home();
14674 let mut runner = runner_at(RunStatus::Implementing);
14675 set_candidates(
14679 &mut runner,
14680 &[(true, Some("already on main at b32cfc4")), (true, None)],
14681 );
14682
14683 let err = runner
14684 .after_implement()
14685 .expect_err("an unverified empty candidate must still fail the run");
14686
14687 assert!(
14688 err.to_string().contains("no candidate produced a change"),
14689 "{err}"
14690 );
14691 assert_eq!(runner.state.status, RunStatus::Failed);
14692 }
14693
14694 #[test]
14695 fn after_implement_still_fails_an_ordinary_all_empty_run() {
14696 ask_test_home();
14697 let mut runner = runner_at(RunStatus::Implementing);
14698 set_candidates(&mut runner, &[(true, None), (true, None)]);
14699
14700 let err = runner
14701 .after_implement()
14702 .expect_err("no candidate declared anything; this is an ordinary failure");
14703
14704 assert!(
14705 err.to_string().contains("no candidate produced a change"),
14706 "{err}"
14707 );
14708 assert_eq!(runner.state.status, RunStatus::Failed);
14709 }
14710}
14711
14712#[derive(Debug)]
14715struct GraftRestoreFailed(String);
14716
14717impl std::fmt::Display for GraftRestoreFailed {
14718 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14719 write!(
14720 f,
14721 "could not restore the winner to its pre-graft commit: {}",
14722 self.0
14723 )
14724 }
14725}
14726
14727impl std::error::Error for GraftRestoreFailed {}
14728
14729#[cfg(test)]
14733mod resource_wait_tests {
14734 use super::*;
14735 use std::collections::BTreeMap;
14736
14737 fn sh(id: &str, script: &str) -> AgentSpec {
14738 AgentSpec {
14739 id: id.to_owned(),
14740 kind: crate::config::AgentKind::Command,
14741 model: None,
14742 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
14743 extra_args: Vec::new(),
14744 env: BTreeMap::new(),
14745 prompt_delivery: None,
14746 }
14747 }
14748
14749 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
14750 SeatJob {
14751 seat: SeatState::new("impl-A", &spec.id, 1),
14752 spec,
14753 cwd: dir.to_path_buf(),
14754 prompt: "do it".to_owned(),
14755 timeout,
14756 allow_write: true,
14757 sessions: false,
14758 artifacts: dir.join("art"),
14759 stem: "impl-A".to_owned(),
14760 handover: None,
14761 }
14762 }
14763
14764 fn state(resource_wait_secs: u64) -> RunState {
14765 let mut config = Config::default();
14766 config.graph.timeout_resource_wait = resource_wait_secs;
14767 RunState::new(
14768 PathBuf::from("/repo"),
14769 "main".to_owned(),
14770 "abc1234".to_owned(),
14771 "task".to_owned(),
14772 config,
14773 )
14774 }
14775
14776 fn hold(cache: &Path) -> crate::cache::Guard {
14777 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
14778 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
14779 crate::cache::AcquireOutcome::Acquired(g) => g,
14780 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
14781 }
14782 }
14783
14784 async fn run(
14785 jobs: Vec<SeatJob>,
14786 sem: Arc<Semaphore>,
14787 cache: Option<&Path>,
14788 st: &mut RunState,
14789 ) -> Vec<(usize, SeatState, AgentOutcome)> {
14790 let prompts = Prompts::default();
14791 let ctx = WaveCtx {
14792 run: "r",
14793 node: "implement",
14794 prompts: &prompts,
14795 cache,
14796 round: None,
14797 carry_seats: false,
14798 pauses: None,
14799 };
14800 wave(jobs, sem, &ctx, st, 0).await
14801 }
14802
14803 #[tokio::test]
14804 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
14805 crate::run::pin_test_home();
14806 let tmp = tempfile::tempdir().unwrap();
14807 let cache = tmp.path().join("target-a");
14808 let guard = hold(&cache);
14809 tokio::spawn(async move {
14811 tokio::time::sleep(Duration::from_millis(1500)).await;
14812 guard.release();
14813 });
14814 let mut st = state(60);
14815 let results = run(
14816 vec![job(
14817 sh("alpha", "echo ok"),
14818 tmp.path(),
14819 Duration::from_secs(1),
14820 )],
14821 Arc::new(Semaphore::new(1)),
14822 Some(&cache),
14823 &mut st,
14824 )
14825 .await;
14826 assert!(
14827 matches!(results[0].2, AgentOutcome::Ok(_)),
14828 "the wait must not be charged to the agent"
14829 );
14830 assert!(st.resource_block.is_none());
14831 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
14832 assert!(st.resource_wait.is_none() && st.active.is_empty());
14833 }
14834
14835 #[tokio::test]
14836 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
14837 crate::run::pin_test_home();
14838 let tmp = tempfile::tempdir().unwrap();
14839 let cache = tmp.path().join("target-b");
14840 let _held = hold(&cache);
14841 let mut st = state(1);
14842 let results = run(
14843 vec![job(
14844 sh("alpha", "echo ok"),
14845 tmp.path(),
14846 Duration::from_secs(1),
14847 )],
14848 Arc::new(Semaphore::new(1)),
14849 Some(&cache),
14850 &mut st,
14851 )
14852 .await;
14853 let (_, seat, out) = &results[0];
14854 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
14855 assert!(wave_blocked(&results));
14856 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
14857 assert_eq!(seat.turns, 0, "nothing was asked");
14858 assert!(
14859 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
14860 "no agent was spawned"
14861 );
14862 assert!(FailClass::of(out).is_none(), "not a failure class");
14863 let block = st.resource_block.as_ref().expect("recorded for the resume");
14864 assert_eq!(block.kind, "shared build cache");
14865 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
14866 assert!(st.active.is_empty());
14867 }
14868
14869 #[tokio::test]
14870 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
14871 crate::run::pin_test_home();
14872 let tmp = tempfile::tempdir().unwrap();
14873 let mut st = state(1);
14874 let results = run(
14875 vec![job(
14876 sh("alpha", "echo ok"),
14877 tmp.path(),
14878 Duration::from_secs(1),
14879 )],
14880 Arc::new(Semaphore::new(0)),
14881 None,
14882 &mut st,
14883 )
14884 .await;
14885 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
14886 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
14887 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
14888 }
14889
14890 #[tokio::test]
14891 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
14892 crate::run::pin_test_home();
14893 let tmp = tempfile::tempdir().unwrap();
14894 let mut st = state(60);
14895 let results = run(
14896 vec![job(
14897 sh("alpha", "sleep 30"),
14898 tmp.path(),
14899 Duration::from_secs(1),
14900 )],
14901 Arc::new(Semaphore::new(1)),
14902 None,
14903 &mut st,
14904 )
14905 .await;
14906 let out = &results[0].2;
14907 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
14908 let class = FailClass::of(out).expect("a failure");
14909 assert_eq!(class, FailClass::Timeout);
14910 assert!(should_hand_over(None, &class), "existing handover policy");
14911 assert!(st.resource_block.is_none());
14912 }
14913}