1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35 AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36 ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51 GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52 MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, ParkCause,
53 QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory,
54 Tally, VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58 ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62const OUTPUT_TAIL: usize = 8_000;
64
65const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106const BASE_SYNC_ROUNDS: usize = 4;
119
120const MAX_FIX_CONTINUATIONS: usize = 2;
136
137#[derive(Clone)]
143struct SeatJob {
144 spec: AgentSpec,
145 seat: SeatState,
146 cwd: PathBuf,
147 prompt: String,
148 timeout: Duration,
149 allow_write: bool,
150 sessions: bool,
151 artifacts: PathBuf,
152 stem: String,
153 handover: Option<String>,
158}
159
160enum AgentOutcome {
172 Ok(AgentOutput),
174 Quota(AgentOutput),
176 Dropped(AgentOutput),
179 Failed(String),
181 ResourceBlocked(String),
187}
188
189#[derive(Debug, Clone, Default)]
216pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<ParkCause>>>);
217
218impl Pause {
219 #[must_use]
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 pub fn park(&self) {
227 self.0.store(true, Ordering::SeqCst);
228 }
229
230 pub fn park_because(&self, reason: impl Into<String>) {
236 self.park_for(ParkCause::Interrupt {
237 reason: reason.into(),
238 });
239 }
240
241 pub fn park_for(&self, cause: ParkCause) {
244 let mut reason_guard = self
245 .1
246 .lock()
247 .unwrap_or_else(std::sync::PoisonError::into_inner);
248 if reason_guard.is_none() {
249 *reason_guard = Some(cause);
250 }
251 drop(reason_guard);
252 self.park();
253 }
254
255 #[must_use]
257 pub fn parked(&self) -> bool {
258 self.0.load(Ordering::SeqCst)
259 }
260
261 #[must_use]
263 pub fn reason(&self) -> Option<String> {
264 self.cause().map(|c| c.label())
265 }
266
267 #[must_use]
269 pub fn cause(&self) -> Option<ParkCause> {
270 self.1
271 .lock()
272 .unwrap_or_else(std::sync::PoisonError::into_inner)
273 .clone()
274 }
275}
276
277pub struct Runner {
279 pub state: RunState,
281 roles: ResolvedRoles,
282 sem: Arc<Semaphore>,
283 pause: Pause,
287 interrupt: Pause,
293}
294
295async fn review_base(
300 repo: &Path,
301 remote: &str,
302 base_branch: &str,
303 base_commit: &str,
304 branch: &str,
305) -> String {
306 review_base_checked(repo, remote, base_branch, base_commit, branch)
307 .await
308 .0
309}
310
311async fn review_base_checked(
316 repo: &Path,
317 remote: &str,
318 base_branch: &str,
319 base_commit: &str,
320 branch: &str,
321) -> (String, bool) {
322 let tracking = format!("{remote}/{base_branch}");
323 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
324 if git::rev_exists(repo, &tracking).await
325 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
326 && !mb.is_empty()
327 {
328 return (mb, fresh);
329 }
330 let mb = git::merge_base(repo, base_commit, branch)
331 .await
332 .ok()
333 .filter(|mb| !mb.is_empty())
334 .unwrap_or_else(|| base_commit.to_owned());
335 (mb, false)
336}
337
338pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
342 if !is_review_run(state) {
343 return;
344 }
345 let base = review_base(
346 &state.repo,
347 &state.config.merge.remote,
348 &state.base_branch,
349 &state.base_commit,
350 branch,
351 )
352 .await;
353 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
354 && !subjects.is_empty()
355 && state.reviewed_commits.as_ref() != Some(&subjects)
356 {
357 state.reviewed_commits = Some(subjects);
358 state.save().ok();
359 }
360}
361
362async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
366 let (base, trusted) = review_base_checked(
367 &state.repo,
368 &state.config.merge.remote,
369 &state.base_branch,
370 &state.base_commit,
371 branch,
372 )
373 .await;
374 if !trusted {
375 return None;
376 }
377 git::subjects(&state.repo, &state.base_commit, &base)
378 .await
379 .ok()
380}
381
382pub(crate) fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
386 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
387 if is_leak(computed) {
388 return false;
389 }
390 let cur = current.trim();
391 cur.is_empty()
392 || cur.starts_with(REVIEW_PROMPT_OPENING)
393 || cur.starts_with("chore: land candidate")
394 || cur.starts_with("magi: candidate")
395 || is_leak(cur)
396}
397
398async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
427 let tracking = format!("{remote}/{branch}");
428 let fetched = git::fetch(repo, remote, branch).await;
429 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
430 let local_exists = git::branch_exists(repo, branch).await?;
431 if !fresh {
432 if !local_exists {
433 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
434 }
435 tracing::warn!(
436 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
437 );
438 return Ok(());
439 }
440 let remote_sha = git::rev_parse(repo, &tracking).await?;
441 if !local_exists {
442 git::git(repo, &["branch", branch, &tracking]).await?;
443 return Ok(());
444 }
445 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
446 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
447 return Ok(());
448 }
449 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
450 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
456 .await?
457 {
458 crate::reconcile::Reconciliation::Pushed => {
459 tracing::warn!(
460 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
461 short(&local_sha),
462 short(&remote_sha)
463 );
464 return Ok(());
465 }
466 crate::reconcile::Reconciliation::Placeholder => {}
467 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
468 }
469 }
470 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
471 if !out.ok() {
472 bail!(
473 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
474 short(&local_sha),
475 short(&remote_sha),
476 out.stderr
477 );
478 }
479 tracing::warn!(
480 "local `{branch}` was stale: fast-forwarded {} -> {}",
481 short(&local_sha),
482 short(&remote_sha)
483 );
484 Ok(())
485}
486
487async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
488 let tracking = format!("{remote}/{base_branch}");
489 let fetched = git::fetch(repo, remote, base_branch).await;
490 if let Ok(out) = &fetched
491 && out.ok()
492 && git::rev_exists(repo, &tracking).await
493 {
494 return git::rev_parse(repo, &tracking).await;
495 }
496 let why = match &fetched {
497 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
498 Ok(_) => format!("{remote} has no {base_branch}"),
499 Err(e) => e.to_string(),
500 };
501 bail!(
504 "cannot read {tracking} ({why}); refusing to branch off the local \
505 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
506 base / remote in magi.toml"
507 )
508}
509
510struct FixClaim {
527 path: PathBuf,
528}
529
530impl FixClaim {
531 fn acquire(dir: &Path) -> Result<Self> {
532 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
533 let path = dir.join("fix.lock");
534 match Self::create(&path) {
535 Ok(claim) => Ok(claim),
536 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
537 if Self::reclaim_if_dead(&path) {
538 Self::create(&path).with_context(|| format!("lock {}", path.display()))
539 } else {
540 bail!(
541 "another repair or `magi fix` is already running for this run ({} exists)",
542 path.display()
543 )
544 }
545 }
546 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
547 }
548 }
549
550 fn create(path: &Path) -> std::io::Result<Self> {
551 let mut f = std::fs::OpenOptions::new()
552 .write(true)
553 .create_new(true)
554 .open(path)?;
555 use std::io::Write as _;
556 writeln!(f, "{}", std::process::id())?;
558 Ok(Self {
559 path: path.to_owned(),
560 })
561 }
562
563 fn reclaim_if_dead(path: &Path) -> bool {
567 let dead = std::fs::read_to_string(path)
568 .ok()
569 .and_then(|body| body.trim().parse::<u32>().ok())
570 .is_some_and(|pid| !crate::proc::pid_alive(pid));
571 dead && std::fs::remove_file(path).is_ok()
572 }
573}
574
575impl Drop for FixClaim {
576 fn drop(&mut self) {
577 let _ = std::fs::remove_file(&self.path);
578 }
579}
580
581impl Runner {
582 pub async fn start(
584 repo: &Path,
585 instruction: String,
586 config: Config,
587 origin: Origin,
588 ) -> Result<Self> {
589 Self::start_naming(repo, instruction, "", config, origin).await
590 }
591
592 pub async fn start_naming(
596 repo: &Path,
597 instruction: String,
598 also_scan: &str,
599 config: Config,
600 origin: Origin,
601 ) -> Result<Self> {
602 let repo = git::toplevel(repo).await?;
603 let missing = agent::missing_programs(&config.agents);
604 if !missing.is_empty() {
605 bail!(
606 "these agent programs are not on PATH: {}. Fix the roster in \
607 magi.toml or install them.",
608 missing.join(", ")
609 );
610 }
611 let base_branch =
612 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
613 .await?;
614 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
615 let roles = config.resolve_roles()?;
616 let max_parallel = config.graph.max_parallel.max(1);
617 let seeds = refs::resolve(
620 &repo,
621 &base_commit,
622 &config.merge.remote,
623 &format!("{also_scan}\n{instruction}"),
624 )
625 .await;
626 refs::plan(&repo, &seeds).await?;
627 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
628 state.origin = Some(origin);
632 for seed in &seeds {
633 state.event(
634 "seed",
635 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
636 );
637 }
638 state.seeds = seeds;
639 state.event("start", format!("run {} created", state.id));
640 state.save()?;
641 Ok(Self {
642 state,
643 roles,
644 sem: Arc::new(Semaphore::new(max_parallel)),
645 pause: Pause::new(),
646 interrupt: Pause::new(),
647 })
648 }
649
650 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
664 Self::review_taking_over(repo, branch, config, None, origin).await
665 }
666
667 pub async fn review_taking_over(
674 repo: &Path,
675 branch: &str,
676 config: Config,
677 takeover: Option<crate::handover::Takeover>,
678 origin: Origin,
679 ) -> Result<Self> {
680 let repo = git::toplevel(repo).await?;
681 let missing = agent::missing_programs(&config.agents);
682 if !missing.is_empty() {
683 bail!(
684 "these agent programs are not on PATH: {}. Fix the roster in \
685 magi.toml or install them.",
686 missing.join(", ")
687 );
688 }
689 let base_branch =
690 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
691 .await?;
692 if base_branch == branch {
693 bail!("`{branch}` is the base branch; there is nothing to review against");
694 }
695 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
696
697 let roles = config.resolve_roles()?;
698 let max_parallel = config.graph.max_parallel.max(1);
699 let mut state = RunState::new(
700 repo.clone(),
701 base_branch,
702 base_commit.clone(),
703 String::new(),
704 config,
705 );
706 state.origin = Some(origin);
707
708 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
715 earlier: Vec::new(),
716 home: crate::run::home(),
717 choice: None,
718 task_instruction: None,
719 });
720 let wait = repository_wait(&state, None);
721 let coordination_config = state.config.coordination.clone();
722 let coordination =
723 crate::repo_lock::SeatLease::operation_with(&repo, coordination_config.as_ref(), &wait)
724 .await?;
725 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
726 if let Some(released) = &released {
727 state.event(
728 "release",
729 format!(
730 "took `{branch}` over from run {}: its worktree was released: {}",
731 crate::run::short_of(&released.old_id),
732 released.audit
733 ),
734 );
735 }
736 if let Some(choice) = takeover.choice.as_ref()
740 && let Err(e) =
741 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
742 .await
743 {
744 if let Some(released) = &released {
745 released.restore(&repo, branch).await;
746 }
747 return Err(e.context("applying the owner's answer about the diverged branch"));
748 }
749 for id in takeover.earlier.iter().rev() {
755 if let Ok(old) = RunState::load_under(id, &takeover.home)
756 && let Some(head) = old.pr_head.clone()
757 && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
758 {
759 state.pr_head = Some(head);
760 break;
761 }
762 }
763 drop(coordination);
764 let opened = Self::open_review(
765 &repo,
766 branch,
767 state,
768 roles,
769 max_parallel,
770 base_commit,
771 &takeover,
772 )
773 .await;
774 if opened.is_err()
775 && let Some(released) = &released
776 {
777 let _coordination = crate::repo_lock::SeatLease::operation_with(
778 &repo,
779 coordination_config.as_ref(),
780 &wait,
781 )
782 .await?;
783 released.restore(&repo, branch).await;
784 }
785 opened
786 }
787
788 pub async fn recover_pr(
790 repo: &Path,
791 task: &str,
792 selection: crate::pr_recovery::Selection,
793 config: Config,
794 ) -> Result<Self> {
795 selection.check_task(task, &selection.instruction)?;
796 let base_branch =
797 git::merge_base_branch(repo, &config.merge.remote, config.merge.base.as_deref())
798 .await?;
799 let base_commit = resolve_base(repo, &base_branch, &config.merge.remote).await?;
800 selection
801 .verify(repo, &config.merge.remote, &base_branch, &selection.head)
802 .await?;
803 let roles = config.resolve_roles()?;
804 let max_parallel = config.graph.max_parallel.max(1);
805 let mut state = RunState::new(
806 repo.to_path_buf(),
807 base_branch,
808 base_commit.clone(),
809 selection.instruction.clone(),
810 config,
811 );
812 state.origin = Some(crate::run::Origin::queue(task));
813 state.pr_head = Some(selection.branch.clone());
814 state.pr_recovery = Some(selection.clone());
815 crate::pr_head::check_predecessors(&state, &selection.branch).await?;
816 Self::open_selected(repo, state, roles, max_parallel, base_commit, selection).await
817 }
818
819 async fn open_selected(
820 repo: &Path,
821 state: RunState,
822 roles: ResolvedRoles,
823 max_parallel: usize,
824 base_commit: String,
825 selection: crate::pr_recovery::Selection,
826 ) -> Result<Self> {
827 let mut state = state;
828 let branch = state.branch_for('A');
829 let wait = repository_wait(&state, None);
830 let coordination = coordinate_operation(&mut state, repo, &wait).await?;
831 git::git(repo, &["branch", &branch, &selection.head]).await?;
832 drop(coordination);
833 state.event(
834 "recovery",
835 format!(
836 "selected public implementation {} from {}",
837 selection.head, selection.url
838 ),
839 );
840 let takeover = crate::handover::Takeover {
841 earlier: Vec::new(),
842 home: crate::run::home(),
843 choice: None,
844 task_instruction: Some(selection.instruction.clone()),
845 };
846 state.save()?;
847 let id = state.id.clone();
848 let opened = Self::open_review(
849 repo,
850 &branch,
851 state,
852 roles,
853 max_parallel,
854 base_commit,
855 &takeover,
856 )
857 .await;
858 match opened {
859 Ok(runner) => Ok(runner),
860 Err(e) => {
861 if let Ok(mut partial) = RunState::load(&id) {
862 partial.status = RunStatus::Blocked;
863 partial.driver_exited = true;
864 partial.event(
865 "recovery",
866 format!("startup failed; retained source and candidate {branch}: {e:#}"),
867 );
868 partial.save()?;
869 }
870 Err(crate::pr_recovery::StartupFailed {
871 run: id,
872 cause: format!("{e:#}"),
873 }
874 .into())
875 }
876 }
877 }
878
879 async fn inherit(
884 repo: &Path,
885 branch: &str,
886 base_commit: &str,
887 takeover: &crate::handover::Takeover,
888 ) -> Option<crate::recovery::RecoveryRecord> {
889 use crate::recovery::{Relation, assess};
890 let newest = takeover.earlier.last()?;
891 let mut chosen = None;
892 for id in takeover.earlier.iter().rev() {
893 if let Ok(old) = RunState::load_under(id, &takeover.home)
894 && !old.reviews.is_empty()
895 && (old.winner().is_some_and(|w| w.branch == branch)
896 || old.pr_head.as_deref() == Some(branch))
897 {
898 chosen = Some(old);
899 break;
900 }
901 }
902 let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
903 .await
904 .unwrap_or_default();
905 let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
906 Some(last) if last.head == tip => Relation::Same,
907 Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
908 _ => Relation::Diverged,
909 };
910 let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
911 Some(assess(from, chosen.as_ref(), base_commit, relation))
912 }
913
914 async fn open_review(
917 repo: &Path,
918 branch: &str,
919 mut state: RunState,
920 roles: ResolvedRoles,
921 max_parallel: usize,
922 base_commit: String,
923 takeover: &crate::handover::Takeover,
924 ) -> Result<Self> {
925 let wait = repository_wait(&state, None);
926 let _coordination = coordinate_operation(&mut state, repo, &wait).await?;
927 if state.pr_recovery.is_none() {
928 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
929 }
930 let start = review_base(
933 repo,
934 &state.config.merge.remote,
935 &state.base_branch,
936 &base_commit,
937 branch,
938 )
939 .await;
940 let log = git::log_oneline(repo, &start, branch)
941 .await
942 .unwrap_or_default();
943 let log_text = if log.trim().is_empty() {
944 "(no commit messages)"
945 } else {
946 log.trim()
947 };
948 let mut instruction = format!(
949 "Review the work already on branch `{branch}`. There is no task \
950 statement: what the change claims to do is whatever its commits \
951 say.\n\n{log_text}"
952 );
953 if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
957 if let Some(task) = takeover
958 .task_instruction
959 .as_deref()
960 .filter(|t| !t.trim().is_empty())
961 && rec.reviewed_head.is_some()
962 {
963 instruction = format!(
964 "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
965 task.trim()
966 );
967 rec.criteria_inherited = true;
968 }
969 state.event("recovery", rec.summary());
970 state.recovery = Some(rec);
971 }
972 if let Some(selected) = &state.pr_recovery {
973 instruction = selected.instruction.clone();
974 }
975 state.task_instruction = takeover.task_instruction.clone();
976 state.instruction = instruction;
977 state.reviewed_commits = Some(
978 git::subjects(repo, &start, branch)
979 .await
980 .unwrap_or_default(),
981 );
982
983 let worktree = state.worktree_root().join("under-review");
986 if let Some(parent) = worktree.parent() {
987 tokio::fs::create_dir_all(parent).await.ok();
988 }
989 let path = worktree.to_string_lossy().to_string();
990 git::git(repo, &["worktree", "add", &path, branch])
991 .await
992 .with_context(|| {
993 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
994 })?;
995
996 let setup = match ensure_setup_config(&mut state, repo).await {
997 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
998 Err(e) => Err(e),
999 };
1000 if let Err(e) = setup {
1001 git::worktree_remove(repo, &worktree).await.ok();
1002 return Err(e);
1003 }
1004 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
1005 .await
1006 .unwrap_or(0);
1007 if commits == 0 {
1008 git::worktree_remove(repo, &worktree).await.ok();
1009 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
1010 }
1011 let files = git::changed_files(&worktree, &base_commit, "HEAD")
1012 .await
1013 .map(|f| f.len())
1014 .unwrap_or(0);
1015 if files == 0
1016 && let (Ok(head_tree), Ok(base_tree)) = (
1017 git::tree_of(&worktree, "HEAD").await,
1018 git::tree_of(&worktree, &base_commit).await,
1019 )
1020 && head_tree == base_tree
1021 {
1022 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
1023 git::worktree_remove(repo, &worktree).await.ok();
1024 bail!(
1025 "`{branch}` at {} has a tree identical to base {}; this usually means \
1026 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
1027 against `{}/{branch}`) rather than an empty change",
1028 short(&head),
1029 short(&base_commit),
1030 state.config.merge.remote
1031 );
1032 }
1033 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
1034 .await
1035 .unwrap_or_default();
1036
1037 state.candidates.push(Candidate {
1038 index: 0,
1039 label: 'A',
1040 agent: EXISTING_BRANCH.to_owned(),
1043 branch: branch.to_owned(),
1044 worktree,
1045 summary: String::new(),
1046 stat,
1047 files,
1048 commits,
1049 empty: false,
1050 failed: None,
1051 verified_noop: None,
1052 duration_ms: 0,
1053 folded: false,
1054 });
1055 state.tally = Some(Tally {
1056 first_choice: BTreeMap::from([('A', 0)]),
1057 borda: BTreeMap::new(),
1058 winner: 'A',
1059 rankings: 0,
1060 unanimous_initial: false,
1061 deliberated: false,
1062 changed_votes: 0,
1063 unanimous_final: false,
1064 tie_break: None,
1065 judges: 0,
1069 present: 0,
1070 quorum: 0,
1071 met_quorum: true,
1072 uncontested: Some("review-only run: nothing competed".to_owned()),
1073 });
1074 state.status = RunStatus::Reviewing;
1075 state.prep_complete = Some(true);
1076 state.event(
1077 "start",
1078 format!(
1079 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
1080 state.id
1081 ),
1082 );
1083 state.save()?;
1084 Ok(Self {
1085 state,
1086 roles,
1087 sem: Arc::new(Semaphore::new(max_parallel)),
1088 pause: Pause::new(),
1089 interrupt: Pause::new(),
1090 })
1091 }
1092
1093 pub fn resume(id: &str) -> Result<Self> {
1095 let state = RunState::load(id)?;
1096 crate::pr_head::ensure_owner(&state)?;
1097 if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1098 bail!(
1099 "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1100 );
1101 }
1102 if let Some(to) = &state.released_to {
1103 bail!(
1104 "run {} cannot be resumed: its worktree was released to run {}",
1105 state.short(),
1106 crate::run::short_of(to)
1107 );
1108 }
1109 let roles = state.config.resolve_roles()?;
1110 if let Some(id) = &state.config.graph.selected_implementer
1111 && state.candidates.iter().any(|c| c.agent != *id)
1112 {
1113 bail!(
1114 "replacement implementer pin does not match the saved candidates; task must stay held"
1115 );
1116 }
1117 let max_parallel = state.config.graph.max_parallel.max(1);
1118 Ok(Self {
1119 state,
1120 roles,
1121 sem: Arc::new(Semaphore::new(max_parallel)),
1122 pause: Pause::new(),
1123 interrupt: Pause::new(),
1124 })
1125 }
1126
1127 pub async fn execute(&mut self) -> Result<()> {
1134 let _repair_claim = if self.state.config.graph.selected_implementer.is_some()
1137 || self.state.land_approval.is_some()
1138 || self.state.merge_decision.is_some()
1139 || !self.state.merge_repairs.is_empty()
1140 || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1141 {
1142 let claim = FixClaim::acquire(&self.state.dir())?;
1143 let disk = RunState::load(&self.state.id)?;
1144 let checkpoint = |s: &RunState| {
1145 (
1146 s.merge_repairs.len(),
1147 s.merge_repairs
1148 .last()
1149 .map(|r| (r.question.clone(), r.phase)),
1150 s.land_approval
1151 .as_ref()
1152 .map(|a| (a.question.clone(), a.head.clone())),
1153 )
1154 };
1155 if checkpoint(&disk) != checkpoint(&self.state) {
1156 bail!(
1157 "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1158 );
1159 }
1160 Some(claim)
1161 } else {
1162 None
1163 };
1164 let wait = self.repository_wait();
1165 let result = wait.scope(self.execute_graph()).await;
1166 let result = match result {
1167 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
1168 record_repository_block(&mut self.state, &e);
1169 self.park_here()?;
1170 Ok(())
1171 }
1172 other => other,
1173 };
1174 self.mark_driver_exited();
1175 let ended = if result.is_err() {
1176 Some(crate::notices::run_stopped(&self.state.id, &self.state))
1177 } else {
1178 crate::notices::run_ended(&self.state)
1179 };
1180 if let Some(notice) = ended {
1181 crate::notices::raise(notice);
1182 }
1183 result
1184 }
1185
1186 fn repository_wait(&self) -> crate::repo_lock::WaitPolicy {
1187 repository_wait(&self.state, Some((&self.pause, &self.interrupt)))
1188 }
1189
1190 async fn coordinate(&mut self, cwd: &Path) -> Result<Option<crate::repo_lock::SeatLease>> {
1191 let wait = self.repository_wait();
1192 coordinate_operation(&mut self.state, cwd, &wait).await
1193 }
1194
1195 fn mark_driver_exited(&mut self) {
1204 self.state.driver_exited = true;
1205 let pid = std::process::id();
1206 let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1207 return;
1208 };
1209 let Ok(mut disk) = RunState::load(&self.state.id) else {
1210 return;
1211 };
1212 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1213 return;
1214 }
1215 disk.driver_exited = true;
1216 if let Err(e) = disk.save() {
1217 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1218 }
1219 }
1220
1221 fn claim_driver(&mut self) -> Result<()> {
1223 let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1224 let disk = RunState::load(&self.state.id)?;
1225 crate::pr_head::ensure_owner(&disk)?;
1226 self.state.parked = false;
1231 self.state.resource_wait = None;
1232 self.state.resource_block = None;
1233 self.state.clear_active();
1240 if let Some(to) = &disk.released_to {
1258 bail!(
1259 "run {} cannot continue: its worktree was released to run {}",
1260 self.state.short(),
1261 crate::run::short_of(to)
1262 );
1263 }
1264 let pid = std::process::id();
1265 self.state.driver_pid = Some(pid);
1266 self.state.driver_started_at = crate::proc::process_started_at(pid);
1267 self.state.driver_exited = false;
1268 self.state.begin_pass();
1269 self.state.save()?;
1270 Ok(())
1271 }
1272
1273 async fn execute_graph(&mut self) -> Result<()> {
1274 self.claim_driver()?;
1275 if self
1276 .state
1277 .merge_decision
1278 .as_ref()
1279 .is_some_and(|decision| !decision.closed)
1280 && self.state.status != RunStatus::Landing
1281 {
1282 if crate::merge_decision::resumable_hold(&self.state) {
1283 self.state.merge = Some(MergeOutcome {
1284 mode: MergeMode::Pr,
1285 ok: false,
1286 detail: self
1287 .state
1288 .merge_decision
1289 .as_ref()
1290 .unwrap()
1291 .target
1292 .pr
1293 .clone(),
1294 empty: false,
1295 });
1296 self.run_land().await?;
1297 self.settle_questions();
1298 return Ok(());
1299 }
1300 bail!(
1301 "owner stop decision is held; inspect the existing PR and request an explicit fresh review/decision before further work"
1302 );
1303 }
1304 if self.state.config.graph.selected_implementer.is_some()
1305 && self.state.status == RunStatus::Blocked
1306 && self.state.tally.is_none()
1307 {
1308 bail!(
1309 "selected implementation is held; explicitly release the owning task to retry it"
1310 );
1311 }
1312
1313 if self.state.merge_repairs.last().is_some_and(|r| {
1314 matches!(
1315 r.phase,
1316 crate::run::MergeRepairPhase::Requested
1317 | crate::run::MergeRepairPhase::Fixing
1318 | crate::run::MergeRepairPhase::Reviewing
1319 )
1320 }) {
1321 if self.repair_merge_review().await? {
1322 self.run_land().await?;
1323 }
1324 self.settle_questions();
1325 return Ok(());
1326 }
1327 if self.state.pr_publication_pending {
1330 self.merge().await?;
1331 if self.resource_halt()? {
1332 return Ok(());
1333 }
1334 self.state.save()?;
1335 self.settle_questions();
1336 return Ok(());
1337 }
1338 if self.state.status == RunStatus::Stalled {
1351 if self.recover_stall().await? {
1352 self.finish_after_tally().await?;
1353 } else {
1354 self.state.save()?;
1356 }
1357 return Ok(());
1358 }
1359 if self.state.status == RunStatus::Landing {
1369 self.run_land().await?;
1370 self.settle_questions();
1375 return Ok(());
1376 }
1377 if self.state.github_text.is_some()
1382 && self.state.merge.is_none()
1383 && self.state.status == RunStatus::Gating
1384 {
1385 self.merge().await?;
1386 if self.resource_halt()? {
1387 return Ok(());
1388 }
1389 self.state.save()?;
1390 return Ok(());
1391 }
1392 if let Err(e) = self.prep().await {
1393 if e.is::<crate::repo_lock::ResourceBlocked>() {
1394 return Err(e);
1395 }
1396 self.state.status = RunStatus::Blocked;
1397 self.state
1398 .prep_failure
1399 .get_or_insert_with(|| format!("{e:#}"));
1400 self.state
1401 .event("prep", format!("preparation stopped: {e:#}"));
1402 self.state.save()?;
1403 return Err(e);
1404 }
1405 if self.park_here()? {
1406 return Ok(());
1407 }
1408 self.advise().await?;
1409 if self.park_here()? {
1410 return Ok(());
1411 }
1412 self.implement().await?;
1413 if self.park_here()? {
1414 return Ok(());
1415 }
1416 if self.state.status == RunStatus::VerifiedNoop {
1420 return Ok(());
1421 }
1422 self.judge().await?;
1423 if self.park_here()? {
1424 return Ok(());
1425 }
1426 self.deliberate().await?;
1427 if self.park_here()? {
1428 return Ok(());
1429 }
1430 self.vote().await?;
1431 if self.park_here()? {
1432 return Ok(());
1433 }
1434 self.tally()?;
1435 if self.state.status == RunStatus::Stalled {
1440 self.state.save()?;
1444 return Ok(());
1445 }
1446 self.finish_after_tally().await?;
1447 Ok(())
1448 }
1449
1450 fn stop_for_resource(&mut self) -> Result<()> {
1455 if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1457 {
1458 self.state.status = RunStatus::Blocked;
1459 }
1460 self.state.save()
1461 }
1462
1463 fn resource_halt(&mut self) -> Result<bool> {
1465 if self.state.resource_block.is_none() {
1466 return Ok(false);
1467 }
1468 self.stop_for_resource()?;
1469 Ok(true)
1470 }
1471
1472 fn park_here(&mut self) -> Result<bool> {
1479 let parking = self.pause.parked() || self.interrupt.parked();
1480 if !parking && self.state.resource_block.is_some() {
1481 self.stop_for_resource()?;
1482 return Ok(true);
1483 }
1484 if !self.pause.parked() && !self.interrupt.parked() {
1489 return Ok(false);
1490 }
1491 let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1492 let why = match cause.as_ref().map(ParkCause::label) {
1493 Some(reason) => format!(
1494 "parked after `{}` ({reason}) — resume to carry on from here",
1495 self.state.status.as_str()
1496 ),
1497 None => format!(
1498 "parked after `{}` — resume to carry on from here",
1499 self.state.status.as_str()
1500 ),
1501 };
1502 self.state.event("park", why);
1503 self.state
1504 .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1505 self.state.parked = true;
1506 self.state.save()?;
1507 Ok(true)
1508 }
1509
1510 pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1512 if self.state.prep_failure.is_none() {
1513 return Ok(());
1514 }
1515 let failed_at = self
1516 .state
1517 .events
1518 .iter()
1519 .rev()
1520 .find(|event| event.node == "prep")
1521 .map_or(self.state.updated_at, |event| event.at);
1522 anyhow::ensure!(
1523 approval_at > failed_at,
1524 "preparation retry requires a new explicit release after the failure"
1525 );
1526 self.state.prep_failure = None;
1527 self.state.prep_complete = Some(false);
1528 self.state
1529 .event("prep-retry", "operator approved retry of preparation");
1530 Ok(())
1531 }
1532
1533 pub fn on_pause(&mut self, pause: Pause) {
1535 self.pause = pause;
1536 }
1537
1538 pub fn watch_interrupt(&mut self, pause: Pause) {
1544 self.interrupt = pause;
1545 }
1546
1547 fn settle_questions(&mut self) {
1567 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1568 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1569 }
1570 }
1571
1572 async fn finish_after_tally(&mut self) -> Result<()> {
1575 self.graft().await?;
1578 if self.resource_halt()? {
1579 return Ok(());
1580 }
1581 self.fold_losers().await?;
1582 self.sync_to_base().await?;
1587 if self.state.status == RunStatus::AlreadyInBase {
1588 return Ok(());
1589 }
1590 self.review_loop().await?;
1591 if self.resource_halt()? {
1592 return Ok(());
1593 }
1594 self.sync_to_base().await?;
1595 if self.state.status == RunStatus::AlreadyInBase {
1596 return Ok(());
1597 }
1598 self.gate().await?;
1599 self.merge().await?;
1600 if self.resource_halt()? {
1601 return Ok(());
1602 }
1603 self.state.save()?;
1604 Ok(())
1605 }
1606
1607 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1614 let result = match ensure_setup_config(&mut self.state, repo).await {
1615 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1616 Err(e) => Err(e),
1617 };
1618 if let Err(e) = result {
1619 git::worktree_remove(repo, wt).await.ok();
1620 if in_prep {
1621 self.rollback_prep(repo).await;
1622 }
1623 self.state.status = RunStatus::Blocked;
1624 self.state.event(
1625 "setup",
1626 format!("worktree setup failed in {}: {e:#}", wt.display()),
1627 );
1628 self.state.save()?;
1629 return Err(e);
1630 }
1631 Ok(())
1632 }
1633
1634 async fn rollback_prep(&mut self, repo: &Path) {
1636 let root = self.state.worktree_root();
1637 for c in std::mem::take(&mut self.state.candidates) {
1638 git::worktree_remove(repo, &c.worktree).await.ok();
1639 git::branch_delete(repo, &c.branch).await.ok();
1640 }
1641 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1642 for j in 1..=seats {
1643 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1644 .await
1645 .ok();
1646 }
1647 for k in 1..=self.state.config.graph.advisors {
1648 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1649 .await
1650 .ok();
1651 }
1652 }
1653
1654 async fn prep(&mut self) -> Result<()> {
1655 if let Some(reason) = &self.state.prep_failure {
1656 bail!(
1657 "candidate preparation previously failed: {reason}; correct the source before retrying"
1658 );
1659 }
1660 if self
1661 .state
1662 .prep_complete
1663 .unwrap_or(!self.state.candidates.is_empty())
1664 {
1665 return Ok(());
1666 }
1667 let repo = self.state.repo.clone();
1668 let _coordination = self.coordinate(&repo).await?;
1671 self.rollback_prep(&repo).await;
1672 self.state.prep_complete = Some(false);
1673 self.state.status = RunStatus::Prep;
1674 self.state.save()?;
1675 let repo = self.state.repo.clone();
1676 let base = self.state.base_commit.clone();
1677 let plan = refs::plan(&repo, &self.state.seeds).await?;
1678 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1679 let root = self.state.worktree_root();
1680 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1681
1682 let hooks_dir = self.state.dir().join("hooks");
1685 if self.state.config.blind.commit_msg_hook {
1686 std::fs::create_dir_all(&hooks_dir)
1687 .with_context(|| format!("create {}", hooks_dir.display()))?;
1688 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1689 let path = hooks_dir.join("commit-msg");
1690 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1691 make_executable(&path)?;
1692 git::acquire_worktree_config(&repo).await?;
1700 self.state.enabled_worktree_config = true;
1701 }
1702
1703 ensure_setup_config(&mut self.state, &repo).await?;
1706
1707 for (index, (spec, label)) in self
1708 .roles
1709 .implementers
1710 .clone()
1711 .into_iter()
1712 .zip(labels)
1713 .enumerate()
1714 {
1715 let branch = self.state.branch_for(label);
1716 let worktree = root.join(format!("cand-{label}"));
1717 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1718 if self.state.config.blind.commit_msg_hook {
1719 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1720 }
1721 git::local_exclude(&worktree, "/.magi/").await?;
1722 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1723 git::worktree_remove(&repo, &worktree).await.ok();
1726 git::branch_delete(&repo, &branch).await.ok();
1727 self.rollback_prep(&repo).await;
1728 self.state.status = RunStatus::Blocked;
1729 self.state
1730 .event("prep", format!("worktree setup failed: {e:#}"));
1731 self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1732 self.state.save()?;
1733 return Err(e);
1734 }
1735 self.state.candidates.push(Candidate {
1736 index,
1737 label,
1738 agent: spec.id.clone(),
1739 branch,
1740 worktree,
1741 summary: String::new(),
1742 stat: String::new(),
1743 files: 0,
1744 commits: 0,
1745 empty: false,
1746 failed: None,
1747 verified_noop: None,
1748 duration_ms: 0,
1749 folded: false,
1750 });
1751 self.state.save()?;
1752 for pick in &plan.picks {
1753 if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1754 {
1755 self.state.status = RunStatus::Blocked;
1756 self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1757 self.state
1758 .event("prep", format!("cannot apply referenced commit: {e}"));
1759 self.state.save()?;
1760 return Err(e);
1761 }
1762 }
1763 }
1764
1765 for j in 1..=self.roles.judges.len() {
1766 let wt = root.join(format!("judge-{j}"));
1767 if !wt.exists() {
1768 git::worktree_add_detached(&repo, &wt, &base).await?;
1769 self.setup_seat_worktree(&repo, &wt, true).await?;
1770 }
1771 }
1772
1773 if self.state.config.graph.advise {
1781 for k in 1..=self.state.config.graph.advisors {
1782 let wt = root.join(format!("advisor-{k}"));
1783 if !wt.exists() {
1784 git::worktree_add_detached(&repo, &wt, &base).await?;
1785 self.setup_seat_worktree(&repo, &wt, true).await?;
1786 }
1787 }
1788 }
1789
1790 let authors: Vec<&str> = self
1795 .roles
1796 .implementers
1797 .iter()
1798 .map(|a| a.id.as_str())
1799 .collect();
1800 let overlap: Vec<String> = self
1801 .roles
1802 .judges
1803 .iter()
1804 .enumerate()
1805 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1806 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1807 .collect();
1808 if !overlap.is_empty() {
1809 let note = format!(
1810 "{} also authored a candidate; blind, but the panel is less \
1811 independent than {} distinct agents would be",
1812 overlap.join(", "),
1813 self.roles.judges.len()
1814 );
1815 self.state.event("prep", note);
1816 }
1817
1818 self.state.event(
1819 "prep",
1820 format!(
1821 "{} candidates, {} judges, base {} ({})",
1822 self.state.candidates.len(),
1823 self.roles.judges.len(),
1824 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1825 self.state.base_branch
1826 ),
1827 );
1828 self.state.prep_complete = Some(true);
1829 self.state.status = RunStatus::Implementing;
1830 self.state.save()?;
1831 Ok(())
1832 }
1833
1834 async fn advise(&mut self) -> Result<()> {
1867 let implement_untouched = self
1868 .state
1869 .candidates
1870 .iter()
1871 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1872 if !self.state.config.graph.advise || self.state.advise_attempted {
1873 return Ok(());
1874 }
1875 if !implement_untouched {
1876 self.state.event(
1877 "advise",
1878 "skipping the design-deliberation stage: at least one \
1879 candidate already shows implementation progress, so this \
1880 run is past the point the stage exists to run before"
1881 .to_owned(),
1882 );
1883 self.state.advise_attempted = true;
1884 self.state.save()?;
1885 return Ok(());
1886 }
1887 let run_id = self.state.id.clone();
1888 let prompts = self.state.config.prompts.clone();
1889 let instruction = self.state.instruction.clone();
1890 let language = self.state.config.graph.language.clone();
1891 let root = self.state.worktree_root();
1892 let n = self.state.config.graph.advisors;
1893 let where_recorded = self.state.dir().join("run.json");
1894
1895 let seats = match self.state.config.advisors() {
1896 Ok(seats) if !seats.is_empty() => seats,
1897 Ok(_) => {
1898 self.state.event(
1899 "advise",
1900 format!(
1901 "[graph] advisors is 0; skipping the design-deliberation \
1902 stage and continuing without a synthesis brief (see {})",
1903 where_recorded.display()
1904 ),
1905 );
1906 self.state.advise_attempted = true;
1907 self.state.save()?;
1908 return Ok(());
1909 }
1910 Err(e) => {
1911 self.state.event(
1912 "advise",
1913 format!(
1914 "could not resolve advisor seats ({e:#}); continuing \
1915 without a design-deliberation brief (see {})",
1916 where_recorded.display()
1917 ),
1918 );
1919 self.state.advise_attempted = true;
1920 self.state.save()?;
1921 return Ok(());
1922 }
1923 };
1924
1925 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1926 let artifacts = agent::artifacts_dir(&self.state.dir());
1927 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1928
1929 let mut jobs = Vec::new();
1930 for (i, spec) in seats.iter().cloned().enumerate() {
1931 let seat_key = format!("advisor-{}", i + 1);
1932 let seat = self.seat(&seat_key, &spec.id);
1933 jobs.push(SeatJob {
1934 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1935 spec,
1936 seat,
1937 cwd: worktrees[i % worktrees.len()].clone(),
1938 timeout,
1939 allow_write: false,
1940 sessions: false,
1941 artifacts: artifacts.clone(),
1942 stem: seat_key,
1943 handover: None,
1944 });
1945 }
1946
1947 self.state.event(
1948 "advise",
1949 format!(
1950 "{} advisor seat(s) sketching a design in parallel",
1951 jobs.len()
1952 ),
1953 );
1954 let mut quota_losses = Vec::new();
1955 let cache = self.state.config.cache_dir();
1956 let ctx = WaveCtx {
1957 carry_seats: false,
1958 pauses: Some((&self.pause, &self.interrupt)),
1959 run: &run_id,
1960 node: "advise",
1961 prompts: &prompts,
1962 cache: cache.as_deref(),
1963 round: None,
1964 };
1965 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1966 let results = ask_json_wave::<Proposal>(
1967 jobs,
1968 Arc::clone(&self.sem),
1969 self.state.config.graph.retries,
1970 &advisor_roster,
1971 &ctx,
1972 &mut quota_losses,
1973 &mut self.state,
1974 &|p: &Proposal| p.validate(),
1975 )
1976 .await;
1977 if self.state.resource_block.is_some() {
1978 return self.stop_for_resource();
1979 }
1980 self.state.quota.extend(quota_losses);
1981
1982 let mut records = Vec::with_capacity(results.len());
1983 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1984 let agent_id = seat.agent.clone();
1985 self.state.seats.insert(seat.key.clone(), seat);
1986 match res {
1987 Ok((proposal, out)) => {
1988 self.state
1989 .event("advise", format!("advisor-{} proposed a design", i + 1));
1990 records.push(advise::AdvisorRecord::proposed(
1991 i + 1,
1992 agent_id,
1993 proposal,
1994 out.duration_ms,
1995 ));
1996 }
1997 Err(e) => {
1998 self.state.event(
1999 "advise",
2000 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
2001 );
2002 records.push(advise::AdvisorRecord::failed(
2003 i + 1,
2004 agent_id,
2005 e.to_string(),
2006 ));
2007 }
2008 }
2009 }
2010
2011 let mut advice = advise::Advice {
2012 records,
2013 synthesis: None,
2014 };
2015 if advice.proposals().is_empty() {
2016 self.state.event(
2017 "advise",
2018 "no advisor produced a usable proposal; continuing without a \
2019 synthesis brief"
2020 .to_owned(),
2021 );
2022 } else {
2023 match self
2024 .synthesize_brief(
2025 &advice,
2026 &instruction,
2027 &language,
2028 &worktrees[0],
2029 &artifacts,
2030 &run_id,
2031 &prompts,
2032 cache.as_deref(),
2033 )
2034 .await
2035 {
2036 Ok(Some(text)) => {
2037 self.state.event(
2038 "advise",
2039 "synthesized a design brief for the implementer".to_owned(),
2040 );
2041 advice.synthesis = Some(text);
2042 }
2043 Ok(None) => {
2044 self.state.event(
2045 "advise",
2046 "the synthesis seat produced nothing usable; continuing \
2047 without a design brief"
2048 .to_owned(),
2049 );
2050 }
2051 Err(e) => {
2052 if e.is::<crate::repo_lock::ResourceBlocked>() {
2053 return Err(e);
2054 }
2055 self.state.event(
2056 "advise",
2057 format!("could not synthesize a design brief: {e:#}"),
2058 );
2059 }
2060 }
2061 }
2062 advise::apply_reflection(&mut advice);
2063
2064 self.state.advice = Some(advice);
2065 self.state.advise_attempted = true;
2066 self.state.save()?;
2067 Ok(())
2068 }
2069
2070 #[allow(clippy::too_many_arguments)]
2082 async fn synthesize_brief(
2083 &mut self,
2084 advice: &advise::Advice,
2085 instruction: &str,
2086 language: &str,
2087 cwd: &Path,
2088 artifacts: &Path,
2089 run_id: &str,
2090 prompts: &Prompts,
2091 cache: Option<&Path>,
2092 ) -> Result<Option<String>> {
2093 let chain = agent::pick_chain(
2094 &self.state.config.agents,
2095 self.state.config.roles.synthesizer.as_ref(),
2096 &agent::installed,
2097 "synthesizer",
2098 )?;
2099 let proposals = advice.proposals();
2100 let mut prompt = prompt::with_overlay(
2101 prompt::synthesize_brief(instruction, &proposals, language),
2102 prompts.overlay("advise"),
2103 );
2104 if cache.is_some() {
2105 prompt.push('\n');
2110 prompt.push_str(&prompt::build_cache_note("advise", false));
2111 }
2112 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2113 let mut last = None;
2118 for (n, spec) in chain.iter().enumerate() {
2119 if n > 0 {
2120 self.state
2121 .event("advise", format!("synthesis falling back to {}", spec.id));
2122 }
2123 let mut seat = self.seat("advise-synthesis", &spec.id);
2124 let wait = self.repository_wait();
2125 let outcome = wait
2126 .scope(agent::invoke(
2127 spec,
2128 &mut seat,
2129 &Invocation {
2130 coordination: self.state.config.coordination.as_ref(),
2131 cwd,
2132 prompt: &prompt,
2133 timeout,
2134 allow_write: false,
2135 unsandboxed: false,
2136 sessions: false,
2137 artifacts,
2138 stem: &if n == 0 {
2139 "advise-synthesis".to_owned()
2140 } else {
2141 format!("advise-synthesis-{}", spec.id)
2142 },
2143 run: run_id,
2144 node: "advise",
2145 cache_dir: None,
2146 attachments: &[],
2147 writable: &[],
2148 },
2149 ))
2150 .await;
2151 if outcome.is_ok() {
2152 self.state.seats.insert(seat.key.clone(), seat);
2153 }
2154 let advance = agent::chain_advances(&outcome);
2155 last = Some(outcome);
2156 if !advance {
2157 break;
2158 }
2159 }
2160 let out = last.expect("a chain holds at least one agent")?;
2163 if !out.usable() {
2164 return Ok(None);
2165 }
2166 let text =
2167 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2168 Ok((!text.trim().is_empty()).then_some(text))
2169 }
2170
2171 async fn implement(&mut self) -> Result<()> {
2174 let run_id = self.state.id.clone();
2179 let prompts = self.state.config.prompts.clone();
2180 let todo: Vec<usize> = self
2181 .state
2182 .candidates
2183 .iter()
2184 .enumerate()
2185 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2186 .map(|(i, _)| i)
2187 .collect();
2188 if todo.is_empty() {
2189 return self.after_implement();
2190 }
2191 self.state.status = RunStatus::Implementing;
2192
2193 let language = self.state.config.graph.language.clone();
2194 let github_language = self.state.config.graph.github_language.clone();
2195 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2196 let sessions = self.state.config.graph.sessions;
2197 let artifacts = agent::artifacts_dir(&self.state.dir());
2198 let brief = self
2202 .state
2203 .advice
2204 .as_ref()
2205 .and_then(|a| a.synthesis.as_deref())
2206 .map(str::to_owned);
2207 let attachments = self.state.attachments.clone();
2208
2209 let mut jobs = Vec::new();
2210 for &i in &todo {
2211 let (index, label, worktree) = {
2212 let c = &self.state.candidates[i];
2213 (c.index, c.label, c.worktree.clone())
2214 };
2215 let spec = self.roles.implementers[index].clone();
2216 let seat_key = format!("impl-{label}");
2217 let seat = self.seat(&seat_key, &spec.id);
2218 let instruction = seeded_instruction(&self.state);
2219 jobs.push(SeatJob {
2220 spec,
2221 seat,
2222 prompt: prompt::implement_in(
2223 &instruction,
2224 &worktree.to_string_lossy(),
2225 &language,
2226 &github_language,
2227 brief.as_deref(),
2228 &attachments,
2229 ),
2230 cwd: worktree,
2231 timeout,
2232 allow_write: true,
2233 sessions,
2234 artifacts: artifacts.clone(),
2235 stem: format!("impl-{label}"),
2236 handover: None,
2237 });
2238 }
2239
2240 self.state.event(
2241 "implement",
2242 format!("{} candidates in parallel", jobs.len()),
2243 );
2244 let mut sent = jobs.clone();
2250 let cache = self.state.config.cache_dir();
2251 let ctx = WaveCtx {
2252 carry_seats: false,
2253 pauses: Some((&self.pause, &self.interrupt)),
2254 run: &run_id,
2255 node: "implement",
2256 prompts: &prompts,
2257 cache: cache.as_deref(),
2258 round: None,
2259 };
2260 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2261 if !wave_blocked(&results) {
2262 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2263 .await;
2264 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2265 .await?;
2266 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2267 .await;
2268 }
2269 let resource_blocked = self.state.resource_block.is_some() || wave_blocked(&results);
2270
2271 let selected_failed = self.state.config.graph.selected_implementer.is_some()
2272 && results
2273 .iter()
2274 .any(|(_, _, out)| FailClass::of(out).is_some());
2275 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2276 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
2277 continue;
2278 }
2279 let seat_key = seat.key.clone();
2280 let agent = seat.agent.clone();
2289 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2290 self.state.seats.insert(seat.key.clone(), seat);
2291 let label = self.state.candidates[i].label;
2292 let worktree = self.state.candidates[i].worktree.clone();
2293 let base = self.state.base_commit.clone();
2294
2295 let (summary, duration, failed, verified_claim) = match out {
2296 AgentOutcome::Ok(o) => {
2297 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2298 let failed = (!o.usable()).then(|| {
2299 if o.timed_out {
2300 "agent timed out".to_owned()
2301 } else {
2302 format!("agent exited with {:?}", o.exit_code)
2303 }
2304 });
2305 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2306 (text, o.duration_ms, failed, verified_claim)
2307 }
2308 AgentOutcome::Dropped(o) => {
2314 let why = o
2315 .dropped
2316 .as_ref()
2317 .map(|d| d.why.as_str())
2318 .unwrap_or("the CLI ended the stream without delivering its answer");
2319 (
2320 String::new(),
2321 o.duration_ms,
2322 Some(format!("the CLI dropped the stream ({why})")),
2323 None,
2324 )
2325 }
2326 AgentOutcome::Quota(o) => {
2327 self.state.quota.push(QuotaLoss {
2328 seat: seat_key,
2329 node: "implement".to_owned(),
2330 at: Timestamp::now(),
2331 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2332 });
2333 (
2334 String::new(),
2335 o.duration_ms,
2336 Some("rate limited (quota); produced no change".to_owned()),
2337 None,
2338 )
2339 }
2340 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2341 (String::new(), 0, Some(e), None)
2342 }
2343 };
2344
2345 let wait = crate::repo_lock::WaitPolicy::new(
2350 Duration::from_secs(self.state.config.graph.timeout_resource_wait),
2351 || false,
2352 );
2353 let _coordination = coordinate_operation(&mut self.state, &worktree, &wait).await?;
2354 let rescued = match git::rescue_commit(
2355 &worktree,
2356 &format!("magi: candidate {label} (uncommitted work)"),
2357 )
2358 .await
2359 {
2360 Ok(r) => {
2361 self.state.note_withheld("implement", &r.withheld);
2362 r.committed
2363 }
2364 Err(_) => false,
2365 };
2366 let commits = git::commits_ahead(&worktree, &base, "HEAD")
2367 .await
2368 .unwrap_or(0);
2369 let patch = git::diff(&worktree, &base, "HEAD")
2370 .await
2371 .unwrap_or_default();
2372 let stat = git::diff_stat(&worktree, &base, "HEAD")
2373 .await
2374 .unwrap_or_default();
2375 let files = git::changed_files(&worktree, &base, "HEAD")
2376 .await
2377 .map(|f| f.len())
2378 .unwrap_or(0);
2379 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2380
2381 let c = &mut self.state.candidates[i];
2382 if !exhausted_the_fallback_chain {
2383 c.agent = agent;
2384 }
2385 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2386 c.stat = stat;
2387 c.files = files;
2388 c.commits = commits;
2389 c.duration_ms = duration;
2390 c.empty = commits == 0 || patch.trim().is_empty();
2391 c.failed = match failed {
2394 Some(_) if c.empty => failed,
2395 _ => None,
2396 };
2397 c.verified_noop = if c.empty { verified_claim } else { None };
2402 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2403 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2404 (None, true, Some(_), _) => {
2405 format!("candidate {label}: no change produced (agent-verified no-op)")
2406 }
2407 (None, true, None, _) => format!("candidate {label}: no change produced"),
2408 (None, false, _, true) => {
2409 format!(
2410 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2411 )
2412 }
2413 (None, false, _, false) => {
2414 format!("candidate {label}: {files} files, {commits} commits")
2415 }
2416 };
2417 self.state.event("implement", note);
2418 self.state.save()?;
2419 }
2420
2421 if resource_blocked {
2422 return self.stop_for_resource();
2423 }
2424 if selected_failed {
2425 self.state.status = RunStatus::Blocked;
2426 self.state.event("implement", "selected implementer failed or hit quota; holding without fallback, review or publication");
2427 self.state.save()?;
2428 bail!(
2429 "selected implementer unavailable or quota-limited; restore availability and explicitly release the task"
2430 );
2431 }
2432 self.after_implement()
2433 }
2434
2435 async fn resume_undelivered(
2463 &mut self,
2464 results: &mut [(usize, SeatState, AgentOutcome)],
2465 sent: &[SeatJob],
2466 prompts: &Prompts,
2467 run_id: &str,
2468 ) {
2469 for (wi, seat, out) in results.iter_mut() {
2470 let Some(dropped) = (match &*out {
2471 AgentOutcome::Dropped(o) => o.dropped.clone(),
2472 _ => None,
2473 }) else {
2474 continue;
2475 };
2476 let Some(job) = sent.get(*wi) else { continue };
2477 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2479 self.state.event(
2480 "implement",
2481 format!(
2482 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2483 work is in the tree",
2484 seat.key, dropped.output_tokens, dropped.why
2485 ),
2486 );
2487 continue;
2488 }
2489 if !has_context(&job.spec, seat, job.sessions) {
2497 self.state.event(
2498 "implement",
2499 format!(
2500 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2501 is no session left to resume",
2502 seat.key, dropped.output_tokens, dropped.why
2503 ),
2504 );
2505 continue;
2506 }
2507 self.state.event(
2508 "implement",
2509 format!(
2510 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2511 conversation",
2512 seat.key, dropped.output_tokens, dropped.why
2513 ),
2514 );
2515 let mut retry = job.clone();
2516 retry.seat = seat.clone();
2517 retry.prompt = prompt::resume_after_drop(&dropped.why);
2518 retry.timeout = retry_budget(job.timeout, true);
2519 retry.stem = format!("{}-resume", job.stem);
2520 let cache = self.state.config.cache_dir();
2521 let ctx = WaveCtx {
2522 carry_seats: false,
2523 pauses: Some((&self.pause, &self.interrupt)),
2524 run: run_id,
2525 node: "implement",
2526 prompts,
2527 cache: cache.as_deref(),
2528 round: None,
2529 };
2530 let (resumed_seat, resumed) =
2531 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2532 *seat = resumed_seat;
2533 *out = resumed;
2534 }
2535 }
2536
2537 async fn resume_seat_handovers(
2602 &mut self,
2603 results: &mut [(usize, SeatState, AgentOutcome)],
2604 sent: &mut [SeatJob],
2605 prompts: &Prompts,
2606 run_id: &str,
2607 ) -> Result<()> {
2608 let instruction = seeded_instruction(&self.state);
2609 let language = self.state.config.graph.language.clone();
2610 let github_language = self.state.config.graph.github_language.clone();
2611 let brief = self
2612 .state
2613 .advice
2614 .as_ref()
2615 .and_then(|a| a.synthesis.as_deref())
2616 .map(str::to_owned);
2617 let attachments = self.state.attachments.clone();
2618 for (wi, seat, out) in results.iter_mut() {
2619 let others: BTreeSet<String> = sent
2622 .iter()
2623 .enumerate()
2624 .filter(|(j, _)| j != wi)
2625 .map(|(_, j)| j.spec.id.clone())
2626 .collect();
2627 let Some(job) = sent.get_mut(*wi) else {
2628 continue;
2629 };
2630 let start = self
2635 .roles
2636 .implementer_roster
2637 .iter()
2638 .position(|s| s.id == job.spec.id)
2639 .unwrap_or(0);
2640 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2641 let mut fallback_attempt = 0usize;
2642 let mut prev: Option<FailClass> = None;
2643 while let Some(cur) = FailClass::of(&*out) {
2644 if !should_hand_over(prev.as_ref(), &cur) {
2645 break;
2646 }
2647 let Some(next) =
2648 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2649 .cloned()
2650 else {
2651 break;
2652 };
2653 tried.insert(next.id.clone());
2654 fallback_attempt += 1;
2655
2656 let coordination = self.coordinate(&job.cwd).await?;
2657 if let Ok(r) = git::rescue_commit(
2658 &job.cwd,
2659 &format!(
2660 "magi: candidate {} (uncommitted work before {} fallback)",
2661 seat.key,
2662 if cur == FailClass::Quota {
2663 "quota"
2664 } else {
2665 "handover"
2666 }
2667 ),
2668 )
2669 .await
2670 {
2671 self.state.note_withheld("implement", &r.withheld);
2672 }
2673 drop(coordination);
2674
2675 record_handover(
2676 &mut self.state,
2677 "implement",
2678 &seat.key,
2679 &seat.agent,
2680 &next.id,
2681 &cur,
2682 &fail_reason(&*out),
2683 );
2684 prev = Some(cur.clone());
2685
2686 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2687 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2688 job.spec = next.clone();
2696 let mut retry = job.clone();
2697 retry.seat = new_seat;
2698 retry.prompt = prompt::implement_in(
2699 &instruction,
2700 &job.cwd.to_string_lossy(),
2701 &language,
2702 &github_language,
2703 brief.as_deref(),
2704 &attachments,
2705 );
2706 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2707 let cache = self.state.config.cache_dir();
2708 let ctx = WaveCtx {
2709 carry_seats: false,
2710 pauses: Some((&self.pause, &self.interrupt)),
2711 run: run_id,
2712 node: "implement",
2713 prompts,
2714 cache: cache.as_deref(),
2715 round: None,
2716 };
2717 let (fallback_seat, fallback_out) = run_one(
2718 retry,
2719 Arc::clone(&self.sem),
2720 &ctx,
2721 &mut self.state,
2722 fallback_attempt,
2723 )
2724 .await;
2725 *seat = fallback_seat;
2726 *out = fallback_out;
2727 }
2728 }
2729 Ok(())
2730 }
2731
2732 async fn resume_unconfirmed_commands(
2756 &mut self,
2757 results: &mut [(usize, SeatState, AgentOutcome)],
2758 sent: &[SeatJob],
2759 prompts: &Prompts,
2760 run_id: &str,
2761 ) {
2762 for (wi, seat, out) in results.iter_mut() {
2763 let AgentOutcome::Ok(o) = &*out else {
2764 continue;
2765 };
2766 if !has_unconfirmed_command(&o.commands) {
2767 continue;
2768 }
2769 let Some(job) = sent.get(*wi) else { continue };
2770 if !has_context(&job.spec, seat, job.sessions) {
2771 self.state.event(
2772 "implement",
2773 format!(
2774 "{}: the reply named a command whose own CLI never confirmed the exit \
2775 status of, but there is no session left to resume",
2776 seat.key
2777 ),
2778 );
2779 continue;
2780 }
2781 self.state.event(
2782 "implement",
2783 format!(
2784 "{}: the reply named a command whose own CLI never confirmed the exit \
2785 status of; resuming the conversation",
2786 seat.key
2787 ),
2788 );
2789 let mut retry = job.clone();
2790 retry.seat = seat.clone();
2791 retry.prompt = prompt::resume_incomplete(
2792 "a command in your last reply had no confirmed exit status",
2793 );
2794 retry.timeout = retry_budget(job.timeout, true);
2795 retry.stem = format!("{}-confirm", job.stem);
2796 let cache = self.state.config.cache_dir();
2797 let ctx = WaveCtx {
2798 carry_seats: false,
2799 pauses: Some((&self.pause, &self.interrupt)),
2800 run: run_id,
2801 node: "implement",
2802 prompts,
2803 cache: cache.as_deref(),
2804 round: None,
2805 };
2806 let (resumed_seat, resumed) =
2807 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2808 *seat = resumed_seat;
2809 *out = resumed;
2810 }
2811 }
2812
2813 async fn continue_fix_report(
2834 &mut self,
2835 mut seat: SeatState,
2836 parse_err: String,
2837 job: &SeatJob,
2838 prompts: &Prompts,
2839 run_id: &str,
2840 round: usize,
2841 ) -> (
2842 SeatState,
2843 Option<FixReport>,
2844 Option<String>,
2845 ContinuationRecord,
2846 ) {
2847 let mut last_err = parse_err;
2848 let mut cumulative_wait_ms = 0u64;
2849 let mut attempts = 0usize;
2850 loop {
2851 if !has_context(&job.spec, &seat, job.sessions) {
2852 self.state.event(
2853 "fix",
2854 format!(
2855 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2856 session left to resume into"
2857 ),
2858 );
2859 let outcome = if attempts == 0 {
2860 ContinuationOutcome::NoSession
2861 } else {
2862 ContinuationOutcome::Exhausted
2863 };
2864 return (
2865 seat,
2866 None,
2867 Some(format!("unparsable fix report: {last_err}")),
2868 ContinuationRecord {
2869 attempts,
2870 cumulative_wait_ms,
2871 outcome,
2872 },
2873 );
2874 }
2875 if attempts >= MAX_FIX_CONTINUATIONS {
2876 self.state.event(
2877 "fix",
2878 format!(
2879 "round {round}: fixer's reply still had no adoption report after \
2880 {attempts} continuation(s) ({last_err}); giving up"
2881 ),
2882 );
2883 return (
2884 seat,
2885 None,
2886 Some(format!(
2887 "unparsable fix report after {attempts} continuation(s): {last_err}"
2888 )),
2889 ContinuationRecord {
2890 attempts,
2891 cumulative_wait_ms,
2892 outcome: ContinuationOutcome::Exhausted,
2893 },
2894 );
2895 }
2896 attempts += 1;
2897 self.state.event(
2898 "fix",
2899 format!(
2900 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2901 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2902 ),
2903 );
2904 let mut retry = job.clone();
2905 retry.seat = seat.clone();
2906 retry.prompt = prompt::resume_incomplete(&last_err);
2907 retry.timeout = retry_budget(job.timeout, true);
2908 retry.stem = format!("{}-continue{attempts}", job.stem);
2909 let cache = self.state.config.cache_dir();
2910 let ctx = WaveCtx {
2911 carry_seats: false,
2912 pauses: Some((&self.pause, &self.interrupt)),
2913 run: run_id,
2914 node: "fix",
2915 prompts,
2916 cache: cache.as_deref(),
2917 round: Some(round),
2918 };
2919 let (resumed_seat, resumed_out) = run_one(
2920 retry,
2921 Arc::clone(&self.sem),
2922 &ctx,
2923 &mut self.state,
2924 attempts,
2925 )
2926 .await;
2927 seat = resumed_seat;
2928 match resumed_out {
2929 AgentOutcome::Ok(o) => {
2930 cumulative_wait_ms += o.duration_ms;
2931 match verdict::extract_json::<FixReport>(&o.text) {
2932 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2933 self.state.event(
2934 "fix",
2935 format!(
2936 "round {round}: fixer's adoption report recovered after \
2937 {attempts} continuation(s)"
2938 ),
2939 );
2940 return (
2941 seat,
2942 Some(report),
2943 None,
2944 ContinuationRecord {
2945 attempts,
2946 cumulative_wait_ms,
2947 outcome: ContinuationOutcome::Resumed,
2948 },
2949 );
2950 }
2951 Ok(_) => {
2959 last_err = "the reply parsed, but it reported a command whose own CLI \
2960 never confirmed an exit status"
2961 .to_owned();
2962 }
2963 Err(e) => last_err = e.to_string(),
2964 }
2965 }
2966 AgentOutcome::Quota(o) => {
2967 cumulative_wait_ms += o.duration_ms;
2968 self.state.quota.push(QuotaLoss {
2969 seat: seat.key.clone(),
2970 node: "fix".to_owned(),
2971 at: Timestamp::now(),
2972 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2973 });
2974 self.state.event(
2975 "fix",
2976 format!(
2977 "round {round}: continuation rate limited (quota); not retrying now"
2978 ),
2979 );
2980 return (
2981 seat,
2982 None,
2983 Some("rate limited (quota) while recovering the fix report".to_owned()),
2984 ContinuationRecord {
2985 attempts,
2986 cumulative_wait_ms,
2987 outcome: ContinuationOutcome::QuotaLost,
2988 },
2989 );
2990 }
2991 AgentOutcome::Dropped(o) => {
2992 cumulative_wait_ms += o.duration_ms;
2993 let why = o
2994 .dropped
2995 .as_ref()
2996 .map(|d| d.why.as_str())
2997 .unwrap_or("the CLI ended the stream without delivering its answer");
2998 last_err = format!("the CLI dropped the stream ({why})");
2999 }
3000 AgentOutcome::Failed(e) => last_err = e,
3001 AgentOutcome::ResourceBlocked(e) => {
3004 return (
3005 seat,
3006 None,
3007 Some(format!("the continuation was not started: {e}")),
3008 ContinuationRecord {
3009 attempts: attempts.saturating_sub(1),
3010 cumulative_wait_ms,
3011 outcome: ContinuationOutcome::NoSession,
3012 },
3013 );
3014 }
3015 }
3016 }
3017 }
3018
3019 fn after_implement(&mut self) -> Result<()> {
3020 if self.state.leaks.is_empty() {
3022 let cfg = self.state.config.blind.clone();
3023 let mut leaks = Vec::new();
3024 for c in &self.state.candidates {
3025 let Some(patch) =
3026 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
3027 else {
3028 continue;
3029 };
3030 leaks.extend(blind::scan(
3031 &format!("candidate {} patch", c.label),
3032 &patch,
3033 &cfg.vendor_tokens,
3034 ));
3035 }
3036 if !leaks.is_empty() {
3037 let summary = leaks
3038 .iter()
3039 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
3040 .collect::<Vec<_>>()
3041 .join(", ");
3042 match cfg.on_leak {
3043 LeakPolicy::Fail => {
3044 self.state.status = RunStatus::Failed;
3045 self.state
3046 .event("blind", format!("vendor text in a patch: {summary}"));
3047 self.state.leaks = leaks;
3048 self.state.save()?;
3049 self.settle_questions();
3050 bail!(
3051 "blind.on_leak = \"fail\" and vendor text reached a \
3052 judged patch: {summary}"
3053 );
3054 }
3055 LeakPolicy::Redact => self.state.event(
3056 "blind",
3057 format!("redacting vendor text for judging: {summary}"),
3058 ),
3059 LeakPolicy::Warn => self.state.event(
3060 "blind",
3061 format!("vendor text present in a judged patch (shown as-is): {summary}"),
3062 ),
3063 }
3064 self.state.leaks = leaks;
3065 }
3066 }
3067
3068 if self.state.viable().is_empty() {
3069 if self.state.all_candidates_verified_noop() {
3070 self.state.status = RunStatus::VerifiedNoop;
3081 self.state.save()?;
3082 self.settle_questions();
3083 return Ok(());
3084 }
3085 self.state.status = RunStatus::Failed;
3086 self.state.save()?;
3087 self.settle_questions();
3088 bail!("no candidate produced a change; nothing to judge");
3089 }
3090 self.state.status = RunStatus::Judging;
3091 self.state.save()?;
3092 Ok(())
3093 }
3094
3095 async fn judge(&mut self) -> Result<()> {
3098 let run_id = self.state.id.clone();
3103 let prompts = self.state.config.prompts.clone();
3104 if !self.state.judgements.is_empty() || self.state.judge_skipped {
3105 return Ok(());
3106 }
3107 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3108 if viable.len() == 1 {
3109 self.state.judge_skipped = true;
3116 self.state.event(
3117 "judge",
3118 format!(
3119 "only candidate {} produced a change; judging skipped",
3120 viable[0].label
3121 ),
3122 );
3123 self.state.save()?;
3124 return Ok(());
3125 }
3126 self.state.status = RunStatus::Judging;
3127
3128 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3129 let language = self.state.config.graph.language.clone();
3130 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3131 let sessions = self.state.config.graph.sessions;
3132 let artifacts = agent::artifacts_dir(&self.state.dir());
3133 let root = self.state.worktree_root();
3134 let base_short = short(&self.state.base_commit);
3135
3136 let mut jobs = Vec::new();
3137 let mut orders = Vec::new();
3138 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3139 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3140 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3141 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3142 let seat_key = format!("judge-{}", j + 1);
3143 let seat = self.seat(&seat_key, &spec.id);
3144 jobs.push(SeatJob {
3145 prompt: prompt::judge(
3146 &self.state.instruction,
3147 &views,
3148 self.roles.judges.len(),
3149 &base_short,
3150 &language,
3151 ),
3152 spec,
3153 seat,
3154 cwd: root.join(format!("judge-{}", j + 1)),
3155 timeout,
3156 allow_write: false,
3157 sessions,
3158 artifacts: artifacts.clone(),
3159 stem: format!("judge-{}", j + 1),
3160 handover: None,
3161 });
3162 }
3163
3164 self.state.event(
3165 "judge",
3166 format!(
3167 "{} judges ranking {} candidates blind",
3168 jobs.len(),
3169 viable.len()
3170 ),
3171 );
3172 let labels_for_check = labels.clone();
3173 let mut quota_losses = Vec::new();
3174 let cache = self.state.config.cache_dir();
3175 let ctx = WaveCtx {
3176 carry_seats: false,
3177 pauses: Some((&self.pause, &self.interrupt)),
3178 run: &run_id,
3179 node: "judge",
3180 prompts: &prompts,
3181 cache: cache.as_deref(),
3182 round: None,
3183 };
3184 let results = ask_json_wave::<Ranking>(
3185 jobs,
3186 Arc::clone(&self.sem),
3187 self.state.config.graph.retries,
3188 &self.roles.judge_roster,
3189 &ctx,
3190 &mut quota_losses,
3191 &mut self.state,
3192 &move |r: &Ranking| r.validate(&labels_for_check),
3193 )
3194 .await;
3195 if self.state.resource_block.is_some() {
3196 return self.stop_for_resource();
3197 }
3198 self.state.quota.extend(quota_losses);
3199
3200 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3201 let agent_id = seat.agent.clone();
3202 self.state.seats.insert(seat.key.clone(), seat);
3203 let mut record = Judgement {
3204 judge: j + 1,
3205 seat: format!("judge-{}", j + 1),
3206 agent: agent_id,
3207 ranking: Vec::new(),
3208 reasons: BTreeMap::new(),
3209 confidence: None,
3210 strengths: BTreeMap::new(),
3211 order: orders[j].clone(),
3212 failed: None,
3213 duration_ms: 0,
3214 };
3215 match res {
3216 Ok((ranking, out)) => {
3217 record.ranking = ranking.normalized();
3218 record.reasons = ranking.reasons;
3219 record.strengths = ranking.strengths;
3220 record.confidence = ranking.confidence;
3221 record.duration_ms = out.duration_ms;
3222 self.state.event(
3223 "judge",
3224 format!(
3225 "judge {} ranked {}",
3226 j + 1,
3227 record.ranking.iter().collect::<String>()
3228 ),
3229 );
3230 }
3231 Err(e) => {
3232 record.failed = Some(e.to_string());
3233 self.state
3234 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3235 }
3236 }
3237 self.state.judgements.push(record);
3238 self.state.save()?;
3239 }
3240 Ok(())
3241 }
3242
3243 async fn deliberate(&mut self) -> Result<()> {
3246 let run_id = self.state.id.clone();
3251 let prompts = self.state.config.prompts.clone();
3252 if !self.state.deliberation.is_empty() {
3253 return Ok(());
3254 }
3255 let tops: Vec<char> = self
3256 .state
3257 .judgements
3258 .iter()
3259 .filter_map(|j| j.ranking.first().copied())
3260 .collect();
3261 let rounds = self.state.config.graph.deliberate_rounds;
3262 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3263 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3264 self.state.event(
3265 "deliberate",
3266 format!("judges agreed on {} outright; no deliberation", tops[0]),
3267 );
3268 }
3269 self.state.status = RunStatus::Voting;
3270 self.state.save()?;
3271 return Ok(());
3272 }
3273
3274 self.state.status = RunStatus::Deliberating;
3275 self.state.event(
3276 "deliberate",
3277 format!(
3278 "split: first choices were {} — opening {rounds} round(s)",
3279 tops.iter().collect::<String>()
3280 ),
3281 );
3282
3283 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3284 let language = self.state.config.graph.language.clone();
3285 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3286 let sessions = self.state.config.graph.sessions;
3287 let artifacts = agent::artifacts_dir(&self.state.dir());
3288 let root = self.state.worktree_root();
3289 let base_short = short(&self.state.base_commit);
3290
3291 for round in 1..=rounds {
3295 let mut turns: Vec<DeliberationTurn> = Vec::new();
3296 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3297 if self.state.judgements[j].failed.is_some() {
3298 continue;
3299 }
3300 let seat_key = format!("judge-{}", j + 1);
3301 let spec = self.occupant(&seat_key, spec);
3302 let mut seat = self.seat(&seat_key, &spec.id);
3303 let transcript = self.transcript(&turns, j);
3304 let build = |context: Option<&str>| {
3305 prompt::deliberate(
3306 &self.state.instruction,
3307 context,
3308 &transcript,
3309 round,
3310 rounds,
3311 &language,
3312 )
3313 };
3314 let block = self.candidate_block(&viable, &base_short);
3315 let full = build(Some(&block));
3316 let text = if has_context(&spec, &seat, sessions) {
3317 build(None)
3318 } else {
3319 full.clone()
3320 };
3321 let job = SeatJob {
3322 spec,
3323 seat: seat.clone(),
3324 prompt: text,
3325 cwd: root.join(format!("judge-{}", j + 1)),
3326 timeout,
3327 allow_write: false,
3328 sessions,
3329 artifacts: artifacts.clone(),
3330 stem: format!("delib-{round}-judge-{}", j + 1),
3331 handover: Some(full),
3332 };
3333 let cache = self.state.config.cache_dir();
3334 let ctx = WaveCtx {
3335 carry_seats: false,
3336 pauses: Some((&self.pause, &self.interrupt)),
3337 run: &run_id,
3338 node: "deliberate",
3339 prompts: &prompts,
3340 cache: cache.as_deref(),
3341 round: None,
3342 };
3343 let mut losses = Vec::new();
3347 let mut results = ask_wave_with::<String>(
3348 vec![job],
3349 Arc::clone(&self.sem),
3350 0,
3351 &self.roles.judge_roster,
3352 &ctx,
3353 &mut losses,
3354 &mut self.state,
3355 &|text: &str| {
3356 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3357 },
3358 )
3359 .await;
3360 self.state.quota.extend(losses);
3361 let (updated, res, _) = results.pop().expect("one job in, one result out");
3362 seat = updated;
3363 let agent_id = seat.agent.clone();
3364 self.state.seats.insert(seat.key.clone(), seat);
3365 let body = match res {
3366 Ok((body, _)) => body,
3367 Err(e) => {
3370 self.state
3371 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3372 continue;
3373 }
3374 };
3375 let tentative = verdict::extract_json::<Position>(&body)
3376 .ok()
3377 .and_then(|p| p.tentative)
3378 .and_then(|s| s.trim().chars().next())
3379 .map(|c| c.to_ascii_uppercase());
3380 self.state.event(
3381 "deliberate",
3382 format!(
3383 "round {round}: judge {} now favours {}",
3384 j + 1,
3385 tentative.map_or("—".to_owned(), |c| c.to_string())
3386 ),
3387 );
3388 turns.push(DeliberationTurn {
3389 judge: j + 1,
3390 agent: agent_id,
3391 body: blind::sanitize_prose(&body, &self.state.config.blind),
3392 tentative,
3393 });
3394 }
3395 self.state
3396 .deliberation
3397 .push(DeliberationRound { round, turns });
3398 self.state.save()?;
3399 }
3400
3401 self.state.status = RunStatus::Voting;
3402 self.state.save()?;
3403 Ok(())
3404 }
3405
3406 async fn vote(&mut self) -> Result<()> {
3409 let run_id = self.state.id.clone();
3414 let prompts = self.state.config.prompts.clone();
3415 if !self.state.votes.is_empty() {
3416 return Ok(());
3417 }
3418 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3419 if viable.len() == 1 {
3420 return Ok(());
3421 }
3422 self.state.status = RunStatus::Voting;
3423
3424 let language = self.state.config.graph.language.clone();
3425 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3426 let sessions = self.state.config.graph.sessions;
3427 let artifacts = agent::artifacts_dir(&self.state.dir());
3428 let root = self.state.worktree_root();
3429 let base_short = short(&self.state.base_commit);
3430 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3431
3432 let mut jobs = Vec::new();
3433 let mut seats_at = Vec::new();
3434 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3435 if self
3436 .state
3437 .judgements
3438 .get(j)
3439 .is_some_and(|r| r.failed.is_some())
3440 {
3441 continue;
3442 }
3443 let seat_key = format!("judge-{}", j + 1);
3444 let spec = self.occupant(&seat_key, spec);
3445 let seat = self.seat(&seat_key, &spec.id);
3446 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3447 let text = if has_context(&spec, &seat, sessions) {
3448 prompt::final_vote(&viable, &language)
3449 } else {
3450 full.clone()
3451 };
3452 jobs.push(SeatJob {
3453 spec,
3454 seat,
3455 prompt: text,
3456 cwd: root.join(format!("judge-{}", j + 1)),
3457 timeout,
3458 allow_write: false,
3459 sessions,
3460 artifacts: artifacts.clone(),
3461 stem: format!("vote-judge-{}", j + 1),
3462 handover: Some(full),
3463 });
3464 seats_at.push(j);
3465 }
3466
3467 self.state.event(
3468 "vote",
3469 format!(
3470 "collecting {} final votes one by one, privately",
3471 jobs.len()
3472 ),
3473 );
3474 let allowed = viable.clone();
3475 let mut quota_losses = Vec::new();
3476 let cache = self.state.config.cache_dir();
3477 let ctx = WaveCtx {
3478 carry_seats: false,
3479 pauses: Some((&self.pause, &self.interrupt)),
3480 run: &run_id,
3481 node: "vote",
3482 prompts: &prompts,
3483 cache: cache.as_deref(),
3484 round: None,
3485 };
3486 let results = ask_json_wave::<FinalVote>(
3487 jobs,
3488 Arc::clone(&self.sem),
3489 self.state.config.graph.retries,
3490 &self.roles.judge_roster,
3491 &ctx,
3492 &mut quota_losses,
3493 &mut self.state,
3494 &move |v: &FinalVote| match v.label() {
3495 Some(c) if allowed.contains(&c) => Ok(()),
3496 other => bail!("vote {other:?} is not one of {allowed:?}"),
3497 },
3498 )
3499 .await;
3500 if self.state.resource_block.is_some() {
3501 return self.stop_for_resource();
3502 }
3503 self.state.quota.extend(quota_losses);
3504
3505 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3506 let agent_id = seat.agent.clone();
3507 self.state.seats.insert(seat.key.clone(), seat);
3508 let initial = self
3509 .state
3510 .judgements
3511 .get(j)
3512 .and_then(|r| r.ranking.first().copied());
3513 let mut record = VoteRecord {
3514 judge: j + 1,
3515 agent: agent_id,
3516 vote: None,
3517 reason: String::new(),
3518 changed: false,
3519 };
3520 match res {
3521 Ok((v, _)) => {
3522 record.vote = v.label();
3523 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3524 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3525 self.state.event(
3526 "vote",
3527 format!(
3528 "judge {} voted {}{}",
3529 j + 1,
3530 record.vote.unwrap_or('?'),
3531 if record.changed { " (changed)" } else { "" }
3532 ),
3533 );
3534 }
3535 Err(e) => {
3536 self.state
3537 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3538 }
3539 }
3540 self.state.votes.push(record);
3541 self.state.save()?;
3542 }
3543 Ok(())
3544 }
3545
3546 fn tally(&mut self) -> Result<()> {
3549 if self.state.tally.is_some() {
3550 return Ok(());
3551 }
3552 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3553 let tops: Vec<char> = self
3554 .state
3555 .judgements
3556 .iter()
3557 .filter_map(|j| j.ranking.first().copied())
3558 .collect();
3559 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3560
3561 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3564 let mut cast: Vec<char> = Vec::new();
3565 for (i, j) in self.state.judgements.iter().enumerate() {
3566 let vote = self
3567 .state
3568 .votes
3569 .iter()
3570 .find(|v| v.judge == i + 1)
3571 .and_then(|v| v.vote)
3572 .or_else(|| j.ranking.first().copied());
3573 if let Some(v) = vote {
3574 *first_choice.entry(v).or_insert(0) += 1;
3575 cast.push(v);
3576 }
3577 }
3578
3579 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3580 for j in &self.state.judgements {
3581 let n = j.ranking.len();
3582 for (pos, label) in j.ranking.iter().enumerate() {
3583 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3584 }
3585 }
3586
3587 let best = first_choice.values().copied().max().unwrap_or(0);
3588 let mut leaders: Vec<char> = first_choice
3589 .iter()
3590 .filter(|(_, v)| **v == best)
3591 .map(|(k, _)| *k)
3592 .collect();
3593 let mut tie_break = None;
3594 if leaders.len() > 1 {
3595 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3596 let borda_leaders: Vec<char> = leaders
3597 .iter()
3598 .copied()
3599 .filter(|l| borda[l] == top_borda)
3600 .collect();
3601 tie_break = Some(if borda_leaders.len() == 1 {
3602 format!(
3603 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3604 leaders.len()
3605 )
3606 } else {
3607 format!(
3608 "{} way tie on both first-choice votes and Borda points, broken by label order",
3609 leaders.len()
3610 )
3611 });
3612 leaders = borda_leaders;
3613 leaders.sort_unstable();
3614 }
3615 let winner = *leaders
3616 .first()
3617 .or(viable.first())
3618 .context("no candidate to declare a winner from")?;
3619
3620 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3621 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3622 let deliberated = !self.state.deliberation.is_empty();
3623
3624 let quota_seats: std::collections::BTreeSet<&str> =
3628 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3629 let mut present = 0usize;
3630 for (i, j) in self.state.judgements.iter().enumerate() {
3631 if quota_seats.contains(j.seat.as_str()) {
3632 continue;
3633 }
3634 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3635 let voted = self
3636 .state
3637 .votes
3638 .iter()
3639 .any(|v| v.judge == i + 1 && v.vote.is_some());
3640 if ranked || voted {
3641 present += 1;
3642 }
3643 }
3644 let needs_quorum = viable.len() > 1;
3650 let judges_total = if needs_quorum {
3651 self.roles.judges.len()
3652 } else {
3653 0
3654 };
3655 let quorum = if needs_quorum {
3656 judges_total / 2 + 1
3657 } else {
3658 0
3659 };
3660 let met_quorum = !needs_quorum || present >= quorum;
3661 let uncontested = (!needs_quorum).then(|| {
3662 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3663 });
3664
3665 self.state.event(
3666 "tally",
3667 match &uncontested {
3668 Some(reason) => format!("winner {winner} — {reason}"),
3669 None => format!(
3670 "winner {winner} — votes {} | initial {} | {} changed | \
3671 {present}/{judges_total} judges{}",
3672 first_choice
3673 .iter()
3674 .map(|(k, v)| format!("{k}:{v}"))
3675 .collect::<Vec<_>>()
3676 .join(" "),
3677 if unanimous_initial {
3678 "unanimous"
3679 } else {
3680 "split"
3681 },
3682 changed_votes,
3683 if met_quorum {
3684 String::new()
3685 } else {
3686 format!(" — below quorum ({quorum} required)")
3687 },
3688 ),
3689 },
3690 );
3691 if !met_quorum {
3692 self.state.event(
3693 "stall",
3694 format!(
3695 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3696 the run stops here, resumable"
3697 ),
3698 );
3699 }
3700 self.state.tally = Some(Tally {
3701 first_choice,
3702 borda,
3703 winner,
3704 rankings: tops.len(),
3705 unanimous_initial,
3706 deliberated,
3707 changed_votes,
3708 unanimous_final,
3709 tie_break,
3710 judges: judges_total,
3711 present,
3712 quorum,
3713 met_quorum,
3714 uncontested,
3715 });
3716 self.state.status = if met_quorum {
3717 RunStatus::Reviewing
3718 } else {
3719 RunStatus::Stalled
3720 };
3721 self.state.save()?;
3722 Ok(())
3723 }
3724
3725 #[allow(clippy::too_many_lines)]
3746 async fn recover_stall(&mut self) -> Result<bool> {
3747 let run_id = self.state.id.clone();
3752 let prompts = self.state.config.prompts.clone();
3753 let quota_seats: BTreeSet<&str> =
3758 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3759 let absent: Vec<String> = self
3760 .state
3761 .judgements
3762 .iter()
3763 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3764 .map(|j| j.seat.clone())
3765 .collect();
3766 if absent.is_empty() {
3767 return Ok(false);
3768 }
3769 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3770 if viable.len() <= 1 {
3771 return Ok(false);
3772 }
3773 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3774 let language = self.state.config.graph.language.clone();
3775 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3776 let sessions = self.state.config.graph.sessions;
3777 let artifacts = agent::artifacts_dir(&self.state.dir());
3778 let root = self.state.worktree_root();
3779 let base_short = short(&self.state.base_commit);
3780 let candidates: Vec<Candidate> = viable.clone();
3781
3782 let mut positions: Vec<usize> = absent
3784 .iter()
3785 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3786 .collect();
3787 if positions.is_empty() {
3788 return Ok(false);
3789 }
3790 positions.sort_unstable();
3791 positions.dedup();
3792
3793 let mut judge_jobs = Vec::new();
3795 for &j in &positions {
3796 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3797 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3798 let seat_key = format!("judge-{}", j + 1);
3799 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3800 let seat = self.seat(&seat_key, &spec.id);
3801 judge_jobs.push(SeatJob {
3802 spec,
3803 seat,
3804 prompt: prompt::judge(
3805 &self.state.instruction,
3806 &views,
3807 self.roles.judges.len(),
3808 &base_short,
3809 &language,
3810 ),
3811 cwd: root.join(seat_key),
3812 timeout,
3813 allow_write: false,
3814 sessions,
3815 artifacts: artifacts.clone(),
3816 stem: format!("judge-{}-recover", j + 1),
3817 handover: None,
3818 });
3819 }
3820
3821 let labels_for_check = labels.clone();
3822 let mut judge_losses = Vec::new();
3823 let retries = self.state.config.graph.retries;
3824 let cache = self.state.config.cache_dir();
3825 let ctx = WaveCtx {
3826 carry_seats: false,
3827 pauses: Some((&self.pause, &self.interrupt)),
3828 run: &run_id,
3829 node: "judge",
3830 prompts: &prompts,
3831 cache: cache.as_deref(),
3832 round: None,
3833 };
3834 let results = ask_json_wave::<Ranking>(
3835 judge_jobs,
3836 Arc::clone(&self.sem),
3837 retries,
3838 &self.roles.judge_roster,
3839 &ctx,
3840 &mut judge_losses,
3841 &mut self.state,
3842 &move |r: &Ranking| r.validate(&labels_for_check),
3843 )
3844 .await;
3845 if self.state.resource_block.is_some() {
3846 self.stop_for_resource()?;
3847 return Ok(false);
3848 }
3849
3850 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3852 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3853 let agent_id = seat.agent.clone();
3854 self.state.seats.insert(seat.key.clone(), seat);
3855 let record = &mut self.state.judgements[j];
3856 match res {
3857 Ok((ranking, out)) => {
3858 record.agent = agent_id;
3859 record.ranking = ranking.normalized();
3860 record.reasons = ranking.reasons;
3861 record.strengths = ranking.strengths;
3862 record.confidence = ranking.confidence;
3863 record.failed = None;
3864 record.duration_ms = out.duration_ms;
3865 recovered.insert(j);
3866 self.state.event(
3867 "recover",
3868 format!("judge {} ranked again after the limit", j + 1),
3869 );
3870 }
3871 Err(e) => {
3872 self.state
3873 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3874 }
3875 }
3876 }
3877
3878 let mut vote_jobs = Vec::new();
3880 let mut vote_pos: Vec<usize> = Vec::new();
3881 for &j in &recovered {
3882 let seat_key = format!("judge-{}", j + 1);
3883 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3884 let seat = self.seat(&seat_key, &spec.id);
3885 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3886 let text = if has_context(&spec, &seat, sessions) {
3887 prompt::final_vote(&labels, &language)
3888 } else {
3889 full.clone()
3890 };
3891 vote_jobs.push(SeatJob {
3892 spec,
3893 seat,
3894 prompt: text,
3895 cwd: root.join(seat_key),
3896 timeout,
3897 allow_write: false,
3898 sessions,
3899 artifacts: artifacts.clone(),
3900 stem: format!("vote-judge-{}-recover", j + 1),
3901 handover: Some(full),
3902 });
3903 vote_pos.push(j);
3904 }
3905 let allowed = labels.clone();
3906 let mut vote_losses = Vec::new();
3907 let vote_retries = self.state.config.graph.retries;
3908 let vote_cache = self.state.config.cache_dir();
3909 let ctx = WaveCtx {
3910 carry_seats: false,
3911 pauses: Some((&self.pause, &self.interrupt)),
3912 run: &run_id,
3913 node: "vote",
3914 prompts: &prompts,
3915 cache: vote_cache.as_deref(),
3916 round: None,
3917 };
3918 let votes = ask_json_wave::<FinalVote>(
3919 vote_jobs,
3920 Arc::clone(&self.sem),
3921 vote_retries,
3922 &self.roles.judge_roster,
3923 &ctx,
3924 &mut vote_losses,
3925 &mut self.state,
3926 &move |v: &FinalVote| match v.label() {
3927 Some(c) if allowed.contains(&c) => Ok(()),
3928 other => bail!("vote {other:?} is not one of {allowed:?}"),
3929 },
3930 )
3931 .await;
3932 if self.state.resource_block.is_some() {
3933 self.stop_for_resource()?;
3934 return Ok(false);
3935 }
3936 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3937 let agent_id = seat.agent.clone();
3938 self.state.seats.insert(seat.key.clone(), seat);
3939 match res {
3940 Ok((v, _)) => {
3941 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3942 rec.vote = v.label();
3943 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3944 } else {
3945 self.state.votes.push(VoteRecord {
3946 judge: j + 1,
3947 agent: agent_id,
3948 vote: v.label(),
3949 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3950 changed: false,
3951 });
3952 }
3953 self.state.event(
3954 "recover",
3955 format!("judge {} voted again after the limit", j + 1),
3956 );
3957 }
3958 Err(e) => {
3959 self.state
3960 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3961 }
3962 }
3963 }
3964
3965 let recovered_keys: BTreeSet<String> = recovered
3969 .iter()
3970 .map(|&j| format!("judge-{}", j + 1))
3971 .collect();
3972 self.state
3973 .quota
3974 .retain(|q| !recovered_keys.contains(&q.seat));
3975 for loss in judge_losses.into_iter().chain(vote_losses) {
3979 if recovered_keys.contains(&loss.seat) {
3980 continue;
3981 }
3982 self.state.quota.retain(|q| q.seat != loss.seat);
3983 self.state.quota.push(loss);
3984 }
3985
3986 self.state.tally = None;
3988 self.tally()?;
3989 Ok(self
3990 .state
3991 .tally
3992 .as_ref()
3993 .map(|t| t.met_quorum)
3994 .unwrap_or(false))
3995 }
3996
3997 async fn graft(&mut self) -> Result<()> {
4014 let Err(e) = self.graft_inner().await else {
4015 return Ok(());
4016 };
4017 if e.is::<crate::repo_lock::ResourceBlocked>() {
4018 return Err(e);
4019 }
4020 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
4021 self.state.status = RunStatus::Blocked;
4022 self.state.event("graft", format!("blocked: {e:#}"));
4023 self.state.save()?;
4024 return Err(e);
4025 }
4026 if let Some(rec) = self.state.graft.clone()
4031 && !rec.outcome.settled()
4032 {
4033 if let Some(winner) = self.state.winner().cloned() {
4034 if let Err(re) = self.graft_restore(&winner.worktree, &rec.pre).await {
4035 if re.is::<crate::repo_lock::ResourceBlocked>() {
4036 return Err(re);
4037 }
4038 self.state.status = RunStatus::Blocked;
4039 self.state.event("graft", format!("blocked: {re:#}"));
4040 self.state.save()?;
4041 return Err(re);
4042 }
4043 }
4044 if let Some(g) = self.state.graft.as_mut() {
4045 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
4046 }
4047 }
4048 self.state.event("graft", format!("skipped: {e:#}"));
4049 if let Err(e) = self.state.save() {
4050 tracing::warn!("save after graft: {e:#}");
4051 }
4052 Ok(())
4053 }
4054
4055 async fn graft_restore(&mut self, wt: &Path, pre: &str) -> Result<()> {
4058 let _coordination = self.coordinate(wt).await?;
4059 crate::recovery::restore_repair(wt, pre)
4060 .await
4061 .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
4062 }
4063
4064 fn graft_skipped(&mut self, why: &str) {
4066 let message = format!("skipped: {why}");
4067 if !self
4068 .state
4069 .events
4070 .iter()
4071 .any(|e| e.node == "graft" && e.message == message)
4072 {
4073 self.state.event("graft", message);
4074 }
4075 }
4076
4077 async fn graft_inner(&mut self) -> Result<()> {
4078 if let Some(rec) = self.state.graft.clone()
4081 && !rec.outcome.settled()
4082 {
4083 if self.state.reviews.is_empty()
4087 && let Some(winner) = self.state.winner().cloned()
4088 {
4089 self.graft_restore(&winner.worktree, &rec.pre).await?;
4090 }
4091 if rec.outcome == GraftOutcome::WaitingForRestore {
4092 self.state.graft = None;
4093 self.state.save()?;
4094 } else {
4095 if let Some(g) = self.state.graft.as_mut() {
4096 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
4097 }
4098 self.state.event(
4099 "graft",
4100 "rolled back: the previous round was interrupted; the winner is untouched",
4101 );
4102 self.state.save()?;
4103 return Ok(());
4104 }
4105 }
4106 let plan = match graft::plan(&self.state) {
4107 Ok(plan) => plan,
4108 Err(skip) => {
4109 if skip != graft::Skip::AlreadyDone {
4110 self.graft_skipped(skip.reason());
4111 self.state.save()?;
4112 }
4113 return Ok(());
4114 }
4115 };
4116 let Some(winner) = self.state.winner().cloned() else {
4117 return Ok(());
4118 };
4119 let wt = winner.worktree.clone();
4120 if !git::is_clean(&wt).await? {
4121 self.graft_skipped("the winner's worktree has uncommitted changes");
4122 self.state.save()?;
4123 return Ok(());
4124 }
4125 let pre = git::rev_parse(&wt, "HEAD").await?;
4126 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
4127 else {
4128 self.graft_skipped("the winner's diff could not be measured");
4129 self.state.save()?;
4130 return Ok(());
4131 };
4132 let limit = graft::limit(winner_lines);
4133 self.state.graft = Some(GraftRecord {
4136 pre: pre.clone(),
4137 post: None,
4138 lines: None,
4139 limit: Some(limit),
4140 agent: None,
4141 outcome: GraftOutcome::Running,
4142 requested: graft::requested(&plan),
4143 adoption: None,
4144 adoption_error: None,
4145 });
4146 self.state.event(
4147 "graft",
4148 format!(
4149 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4150 plan.strengths
4151 .iter()
4152 .map(|(l, _, _)| l.to_string())
4153 .collect::<Vec<_>>()
4154 .join(", "),
4155 plan.winner
4156 ),
4157 );
4158 self.state.save()?;
4159
4160 let fix_prompt = prompt::graft(
4161 &self.state.instruction,
4162 plan.winner,
4163 &plan.strengths,
4164 limit,
4165 &self.state.config.graph.language,
4166 );
4167 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4168 let sessions = self.state.config.graph.sessions;
4169 let artifacts = agent::artifacts_dir(&self.state.dir());
4170 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4171 let (_, seat, out) = self
4172 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4173 SeatJob {
4174 prompt: fix_prompt.clone(),
4175 spec,
4176 seat,
4177 cwd: wt.clone(),
4178 timeout,
4179 allow_write: true,
4180 sessions,
4181 artifacts: artifacts.clone(),
4182 stem: "graft".to_owned(),
4183 handover: None,
4184 }
4185 })
4186 .await;
4187 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4188 self.state.graft.as_mut().expect("graft record").outcome =
4191 GraftOutcome::WaitingForRestore;
4192 return self.stop_for_resource();
4193 }
4194 let mut why: Option<String> = None;
4195 match &out {
4196 AgentOutcome::Ok(o) if agent::output_advances(o) => {
4197 why = Some("the fixer produced nothing usable".to_owned());
4198 }
4199 AgentOutcome::Ok(_) => {}
4200 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4201 AgentOutcome::Quota(o) => {
4202 self.state.quota.push(QuotaLoss {
4203 seat: seat.key.clone(),
4204 node: "graft".to_owned(),
4205 at: Timestamp::now(),
4206 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4207 });
4208 why = Some("rate limited (quota)".to_owned());
4209 }
4210 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4211 why = Some(format!(
4212 "the fixer failed: {}",
4213 e.lines()
4214 .next()
4215 .unwrap_or("")
4216 .chars()
4217 .take(160)
4218 .collect::<String>()
4219 ));
4220 }
4221 }
4222 let agent_id = seat.agent.clone();
4223 self.state.seats.insert(seat.key.clone(), seat);
4224
4225 let mut post = None;
4226 let mut lines = None;
4227 let _coordination = self.coordinate(&wt).await?;
4228 if why.is_none() {
4229 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4230 if let Ok(r) = &rescued {
4231 self.state.note_withheld("graft", &r.withheld);
4232 }
4233 let head = git::rev_parse(&wt, "HEAD").await?;
4234 let dirty = match &rescued {
4238 Ok(r) => git::status_porcelain(&wt)
4239 .await
4240 .map(|st| {
4241 st.lines().any(|l| {
4242 !r.withheld
4243 .iter()
4244 .any(|w| l.trim_end().ends_with(w.path.as_str()))
4245 })
4246 })
4247 .unwrap_or(true),
4248 Err(_) => true,
4249 };
4250 if dirty {
4251 why = Some("the fixer left changes that could not be committed".to_owned());
4252 } else if head == pre {
4253 why = Some("the fixer changed nothing".to_owned());
4254 } else {
4255 lines = git::changed_lines(&wt, &pre, &head).await;
4256 if graft::within_budget(lines, limit) {
4257 post = Some(head);
4258 } else {
4259 why = Some(format!(
4260 "{} changed lines against a limit of {limit}",
4261 lines.map_or_else(
4262 || "an unmeasurable number of".to_owned(),
4263 |l| l.to_string()
4264 )
4265 ));
4266 }
4267 }
4268 }
4269 let (adoption, adoption_error) = match &out {
4270 AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4271 _ => (None, None),
4272 };
4273 let requested = graft::requested(&plan);
4274 let outcome = match why {
4275 None => {
4276 self.state.event(
4277 "graft",
4278 format!(
4279 "applied: {} changed lines (limit {limit})",
4280 lines.unwrap_or(0)
4281 ),
4282 );
4283 GraftOutcome::Applied
4284 }
4285 Some(why) => {
4286 crate::recovery::restore_repair(&wt, &pre)
4289 .await
4290 .map_err(|e| GraftRestoreFailed(format!("{e:#}")))?;
4291 self.state.event(
4292 "graft",
4293 format!("rolled back: {why}; the winner is untouched"),
4294 );
4295 GraftOutcome::RolledBack(why)
4296 }
4297 };
4298 self.state.graft = Some(GraftRecord {
4299 pre,
4300 post,
4301 lines,
4302 limit: Some(limit),
4303 agent: Some(agent_id),
4304 outcome,
4305 requested,
4306 adoption,
4307 adoption_error,
4308 });
4309 self.state.save()?;
4310 Ok(())
4311 }
4312
4313 async fn fold_losers(&mut self) -> Result<()> {
4316 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4317 return Ok(());
4318 };
4319 let repo = self.state.repo.clone();
4320 let _coordination = self.coordinate(&repo).await?;
4321 let mut folded = Vec::new();
4322 for i in 0..self.state.candidates.len() {
4323 let c = &self.state.candidates[i];
4324 if c.label == winner || c.folded {
4325 continue;
4326 }
4327 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4328 git::worktree_remove(&repo, &wt).await.ok();
4329 git::branch_delete(&repo, &branch).await.ok();
4330 self.state.candidates[i].folded = true;
4331 folded.push(label.to_string());
4332 }
4333 let root = self.state.worktree_root();
4335 for j in 1..=self.roles.judges.len() {
4336 let wt = root.join(format!("judge-{j}"));
4337 if wt.exists() {
4338 git::worktree_remove(&repo, &wt).await.ok();
4339 }
4340 }
4341 if self.state.config.graph.advise {
4344 for k in 1..=self.state.config.graph.advisors {
4345 let wt = root.join(format!("advisor-{k}"));
4346 if wt.exists() {
4347 git::worktree_remove(&repo, &wt).await.ok();
4348 }
4349 }
4350 }
4351 if !folded.is_empty() {
4352 self.state
4353 .event("fold", format!("folded candidates {}", folded.join(", ")));
4354 self.state.save()?;
4355 }
4356 Ok(())
4357 }
4358
4359 async fn sync_to_base(&mut self) -> Result<()> {
4399 if self.state.status == RunStatus::AlreadyInBase {
4400 return Ok(());
4401 }
4402 let conflicted = self
4403 .state
4404 .base_sync
4405 .as_ref()
4406 .is_some_and(|s| s.conflict.is_some());
4407 let Some(winner) = self.state.winner().cloned() else {
4408 return Ok(());
4409 };
4410
4411 let repo = self.state.repo.clone();
4412 let remote = self.state.config.merge.remote.clone();
4413 let base_branch = self.state.base_branch.clone();
4414 let tracking = format!("{remote}/{base_branch}");
4415
4416 git::fetch(&repo, &remote, &base_branch).await.ok();
4417 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4422 return Ok(());
4423 };
4424
4425 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4426 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4427 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4428
4429 if (behind > 0 || conflicted || head == tip)
4438 && self
4439 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4440 .await?
4441 {
4442 return Ok(());
4443 }
4444 if conflicted {
4445 return Ok(());
4446 }
4447
4448 if behind == 0 {
4449 if let Some(from) = self
4458 .state
4459 .rebase_fixes
4460 .iter()
4461 .rev()
4462 .find_map(|r| r.from.clone())
4463 && from != head
4464 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4465 .await
4466 .is_ok_and(|o| o.ok())
4467 {
4468 git::sync_to_head(&winner.worktree).await?;
4469 }
4470 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4476 if let Some(why) = &conflict {
4477 self.state.status = RunStatus::Blocked;
4478 self.state.event("land", why.clone());
4479 }
4480 self.state.base_sync = Some(BaseSync {
4481 tip,
4482 behind: 0,
4483 attempts,
4484 conflict,
4485 already_in: None,
4486 });
4487 self.state.save()?;
4488 return Ok(());
4489 }
4490
4491 if attempts >= BASE_SYNC_ROUNDS {
4492 let why = format!(
4493 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4494 rebase(s); rebasing again would only race it",
4495 winner.branch
4496 );
4497 self.state.status = RunStatus::Blocked;
4498 self.state.base_sync = Some(BaseSync {
4499 tip,
4500 behind,
4501 attempts,
4502 conflict: Some(why.clone()),
4503 already_in: None,
4504 });
4505 self.state.event("land", why);
4506 self.state.save()?;
4507 return Ok(());
4508 }
4509
4510 self.state.event(
4511 "land",
4512 format!(
4513 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4514 winner.branch
4515 ),
4516 );
4517 self.state.save()?;
4518
4519 let branch_tracking = format!("{remote}/{}", winner.branch);
4525 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4526 let remote_tip = if self.state.task_pr_branch().is_none()
4527 && matches!(&fetched_branch, Ok(o) if o.ok())
4528 {
4529 git::rev_parse(&repo, &branch_tracking).await.ok()
4530 } else {
4531 None
4532 };
4533 if let Some(theirs) = &remote_tip
4537 && !git::is_ancestor(&repo, theirs, &head).await
4538 && !crate::reconcile::origin_missing(&repo, &head, theirs)
4539 .await
4540 .is_ok_and(|missing| missing.is_empty())
4541 {
4542 let why = format!(
4543 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4544 them",
4545 short(theirs),
4546 winner.branch
4547 );
4548 self.state.status = RunStatus::Blocked;
4549 self.state.base_sync = Some(BaseSync {
4550 tip,
4551 behind,
4552 attempts,
4553 conflict: Some(why.clone()),
4554 already_in: None,
4555 });
4556 self.state.event("land", why);
4557 self.state.save()?;
4558 return Ok(());
4559 }
4560
4561 let scratch = self.state.dir().join("base-sync");
4562 let rebased = match crate::rebase::rebase_with_fixer(
4563 &mut self.state,
4564 &scratch,
4565 &winner.branch,
4566 &tracking,
4567 )
4568 .await
4569 {
4570 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4571 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4572 Err(e) => Err(e),
4573 };
4574 let attempts = attempts + 1;
4575 match rebased {
4576 Ok(None) => {
4577 git::sync_to_head(&winner.worktree).await?;
4581 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4582 let mut conflict = None;
4583 if let Some(pinned) = &remote_tip {
4584 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4585 match pushed {
4586 Ok(o) if o.ok() => self.state.event(
4587 "land",
4588 format!("pushed rebased {} to {remote}", winner.branch),
4589 ),
4590 Ok(o) => {
4591 conflict = Some(format!(
4592 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4593 winner.branch,
4594 short(pinned),
4595 o.stderr.chars().take(600).collect::<String>()
4596 ));
4597 }
4598 Err(e) => {
4599 conflict = Some(format!(
4600 "rebased {} locally but could not push it: {e:#}",
4601 winner.branch
4602 ));
4603 }
4604 }
4605 }
4606 if let Some(why) = &conflict {
4607 self.state.status = RunStatus::Blocked;
4608 self.state.event("land", why.clone());
4609 }
4610 self.state.base_sync = Some(BaseSync {
4611 tip: tip.clone(),
4612 behind: 0,
4613 attempts,
4614 conflict,
4615 already_in: None,
4616 });
4617 self.state
4618 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4619 }
4620 Ok(Some(conflict)) => {
4621 let why = format!(
4622 "{} conflicts with {tracking} and did not rebase: {}",
4623 winner.branch,
4624 conflict.chars().take(600).collect::<String>()
4625 );
4626 self.state.status = RunStatus::Blocked;
4627 self.state.base_sync = Some(BaseSync {
4628 tip,
4629 behind,
4630 attempts,
4631 conflict: Some(why.clone()),
4632 already_in: None,
4633 });
4634 self.state.event("land", why);
4635 }
4636 Err(e) => {
4637 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4638 self.state.status = RunStatus::Blocked;
4639 self.state.base_sync = Some(BaseSync {
4640 tip,
4641 behind,
4642 attempts,
4643 conflict: Some(why.clone()),
4644 already_in: None,
4645 });
4646 self.state.event("land", why);
4647 }
4648 }
4649 self.state.save()?;
4650 Ok(())
4651 }
4652
4653 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4659 if self.state.task_pr_branch().is_some() {
4662 return None;
4663 }
4664 let repo = self.state.repo.clone();
4665 let remote = self.state.config.merge.remote.clone();
4666 let fetched = git::fetch(&repo, &remote, branch).await;
4667 if !matches!(&fetched, Ok(o) if o.ok()) {
4668 return None;
4669 }
4670 let branch_tracking = format!("{remote}/{branch}");
4671 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4672 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4673 return None;
4674 }
4675 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4676 .await
4677 .is_ok_and(|missing| missing.is_empty())
4678 {
4679 return Some(format!(
4680 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4681 them",
4682 short(&theirs)
4683 ));
4684 }
4685 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4686 Ok(o) if o.ok() => {
4687 self.state
4688 .event("land", format!("pushed rebased {branch} to {remote}"));
4689 None
4690 }
4691 Ok(o) => Some(format!(
4692 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4693 someone may have pushed): {}",
4694 short(&theirs),
4695 o.stderr.chars().take(600).collect::<String>()
4696 )),
4697 Err(e) => Some(format!(
4698 "{branch} is rebased locally but could not be pushed: {e:#}"
4699 )),
4700 }
4701 }
4702
4703 async fn settle_already_in(
4720 &mut self,
4721 branch: &str,
4722 tip: &str,
4723 head: &str,
4724 attempts: usize,
4725 behind: usize,
4726 ) -> Result<bool> {
4727 let repo = self.state.repo.clone();
4728 let remote = self.state.config.merge.remote.clone();
4729 let start = self.state.base_commit.clone();
4730 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4731 Ok(Some(e)) => e,
4732 Ok(None) => return Ok(false),
4733 Err(e) => {
4734 tracing::warn!("already-in-base check for {branch}: {e:#}");
4735 return Ok(false);
4736 }
4737 };
4738 let mut verified = vec![head.to_owned()];
4739 let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4740 let fetched = git::fetch(&repo, &remote, &public_branch).await;
4741 if matches!(&fetched, Ok(o) if o.ok())
4742 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4743 && theirs != head
4744 {
4745 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4746 Ok(Some(_)) => verified.push(theirs),
4747 _ => return Ok(false),
4748 }
4749 }
4750 let base_branch = self.state.base_branch.clone();
4751 let message = format!(
4752 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4753 land",
4754 evidence.names(),
4755 evidence.proof.as_str()
4756 );
4757 let closed =
4758 crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4759 .await;
4760 match closed {
4761 Ok(Ok(url)) => self
4762 .state
4763 .event("land", format!("closed {url}: superseded on {base_branch}")),
4764 Ok(Err(why)) => self
4765 .state
4766 .event("land", format!("did not close a pull request: {why}")),
4767 Err(e) => {
4768 let why = format!(
4769 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4770 not be closed ({e:#}); resume to retry"
4771 );
4772 self.state.status = RunStatus::Blocked;
4773 self.state.base_sync = Some(BaseSync {
4774 tip: tip.to_owned(),
4775 behind,
4776 attempts,
4777 conflict: Some(why.clone()),
4778 already_in: None,
4779 });
4780 self.state.event("land", why);
4781 self.state.save()?;
4782 return Ok(true);
4783 }
4784 }
4785 self.state.status = RunStatus::AlreadyInBase;
4786 self.state.base_sync = Some(BaseSync {
4787 tip: tip.to_owned(),
4788 behind,
4789 attempts,
4790 conflict: None,
4791 already_in: Some(evidence),
4792 });
4793 self.state.event("land", message);
4794 self.state.save()?;
4795 self.settle_questions();
4796 Ok(true)
4797 }
4798
4799 fn landing_base(&self) -> String {
4809 self.state
4810 .base_sync
4811 .as_ref()
4812 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4813 }
4814
4815 pub async fn fix_selected(
4848 &mut self,
4849 ids: &[String],
4850 reason: &str,
4851 allow_stale: bool,
4852 ) -> Result<()> {
4853 let reason = reason.trim();
4854 if reason.is_empty() {
4855 bail!("a fix request needs a reason — that is the operator's own record of why");
4856 }
4857 if ids.is_empty() {
4858 bail!("no finding id given");
4859 }
4860 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4861 bail!(
4862 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4863 has already concluded — can be given a targeted fix. A run still \
4864 in progress should simply be resumed; a `merged` run's branch has \
4865 already landed, so its answer is a fresh `magi review <branch>`, \
4866 not reopening this run's own record",
4867 self.state.id,
4868 self.state.status.as_str()
4869 );
4870 }
4871 let Some(winner) = self.state.winner().cloned() else {
4872 bail!("run {} has no winning candidate to fix", self.state.id);
4873 };
4874 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4875 bail!(
4876 "branch `{}` no longer exists; this run cannot be extended",
4877 winner.branch
4878 );
4879 }
4880 let home = crate::run::home();
4881 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4882 bail!(
4883 "run {} is currently being worked on by another magi process",
4884 self.state.id
4885 );
4886 }
4887 let _claim = FixClaim::acquire(&self.state.dir())?;
4893
4894 let mut seen = BTreeSet::new();
4898 let mut findings = Vec::new();
4899 let mut missing = Vec::new();
4900 for id in ids {
4901 if !seen.insert(id.clone()) {
4902 continue;
4903 }
4904 match self.state.finding(id) {
4905 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4906 id: f.id.clone(),
4907 severity: f.severity,
4908 reviewer_vote: rec.vote,
4909 round: round.round,
4910 round_head: round.head.clone(),
4911 reviewer: rec.reviewer,
4912 agent: rec.agent.clone(),
4913 file: f.file.clone(),
4914 line: f.line,
4915 title: f.title.clone(),
4916 detail: f.detail.clone(),
4917 outcome: OperatorFixOutcome::Pending,
4918 }),
4919 None => missing.push(id.clone()),
4920 }
4921 }
4922 if !missing.is_empty() {
4923 bail!(
4924 "unknown finding id(s): {}; nothing was changed",
4925 missing.join(", ")
4926 );
4927 }
4928
4929 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4930 let stale_details: Vec<(String, String)> = findings
4931 .iter()
4932 .filter(|f| f.round_head != head_at_request)
4933 .map(|f| (f.id.clone(), f.round_head.clone()))
4934 .collect();
4935 let stale = !stale_details.is_empty();
4936 if stale && !allow_stale {
4937 bail!(
4938 "the branch has moved since some finding(s) were raised — {} — now \
4939 at {}; pass --allow-stale to fix anyway, or re-run review first",
4940 stale_details
4941 .iter()
4942 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4943 .collect::<Vec<_>>()
4944 .join(", "),
4945 short(&head_at_request)
4946 );
4947 }
4948
4949 let request = OperatorFixRequest {
4950 requested_at: Timestamp::now(),
4951 reason: reason.to_owned(),
4952 findings,
4953 head_at_request: head_at_request.clone(),
4954 allow_stale,
4955 stale,
4956 fix: None,
4957 result_head: None,
4958 follow_up_review_run: None,
4959 };
4960 self.state.event(
4961 "fix",
4962 format!(
4963 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4964 request.findings.len(),
4965 request
4966 .findings
4967 .iter()
4968 .map(|f| f.id.as_str())
4969 .collect::<Vec<_>>()
4970 .join(", "),
4971 ),
4972 );
4973 self.state.operator_fixes.push(request);
4980 self.state.save()?;
4981 let request_index = self.state.operator_fixes.len() - 1;
4982
4983 let repo = self.state.repo.clone();
4992 let coordination = self.coordinate(&repo).await?;
4993 if winner.worktree.exists() {
4994 let dirty = git::git(
4997 &winner.worktree,
4998 &["status", "--porcelain", "--untracked-files=all"],
4999 )
5000 .await?;
5001 let only_withheld = dirty.lines().all(|l| {
5002 l.strip_prefix("?? ")
5003 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
5004 });
5005 if !only_withheld {
5006 bail!(
5007 "`{}` has uncommitted changes; refusing to touch it — commit or \
5008 discard them first",
5009 winner.worktree.display()
5010 );
5011 }
5012 git::worktree_remove(&self.state.repo, &winner.worktree)
5013 .await
5014 .ok();
5015 }
5016 let fix_worktree = self.state.worktree_root().join("operator-fix");
5017 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
5018 git::git(
5019 &self.state.repo,
5020 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
5021 )
5022 .await
5023 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
5024 if !git::is_clean(&fix_worktree).await? {
5025 git::worktree_remove(&self.state.repo, &fix_worktree)
5026 .await
5027 .ok();
5028 bail!(
5029 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
5030 winner.branch
5031 );
5032 }
5033 let repo_for_setup = self.state.repo.clone();
5034 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
5035 Ok(()) => {
5036 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
5037 }
5038 Err(e) => Err(e),
5039 };
5040 if let Err(e) = setup {
5041 git::worktree_remove(&self.state.repo, &fix_worktree)
5042 .await
5043 .ok();
5044 return Err(e);
5045 }
5046
5047 let run_id = self.state.id.clone();
5048 let prompts = self.state.config.prompts.clone();
5049 let language = self.state.config.graph.language.clone();
5050 let sessions = self.state.config.graph.sessions;
5051 let artifacts = agent::artifacts_dir(&self.state.dir());
5052 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
5053 .findings
5054 .iter()
5055 .map(|f| Finding {
5056 id: f.id.clone(),
5057 severity: f.severity,
5058 file: f.file.clone(),
5059 line: f.line,
5060 title: f.title.clone(),
5061 detail: f.detail.clone(),
5062 })
5063 .collect();
5064 let fix_prompt = prompt::operator_fix_in(
5065 &self.state.instruction,
5066 &finding_list,
5067 reason,
5068 &stale_details,
5069 &head_at_request,
5070 &language,
5071 &self.state.config.graph.github_language,
5072 );
5073 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5074 drop(coordination);
5075 let (job, seat, out) = self
5076 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
5077 prompt: fix_prompt.clone(),
5078 spec,
5079 seat,
5080 cwd: fix_worktree.clone(),
5081 timeout,
5082 allow_write: true,
5083 sessions,
5084 artifacts: artifacts.clone(),
5085 stem: "operator-fix".to_owned(),
5086 handover: None,
5087 })
5088 .await;
5089 let agent_id = seat.agent.clone();
5090
5091 let mut fix = FixRecord {
5092 agent: agent_id,
5093 addressed: Vec::new(),
5094 rejected: Vec::new(),
5095 notes: String::new(),
5096 committed: false,
5097 failed: None,
5098 duration_ms: 0,
5099 continuation: None,
5100 };
5101 let mut final_seat = seat.clone();
5102 match out {
5103 AgentOutcome::Ok(o) => {
5104 fix.duration_ms = o.duration_ms;
5105 let parsed = verdict::extract_json::<FixReport>(&o.text);
5106 let incomplete_reason = match &parsed {
5107 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5108 "the reply parsed, but it reported a command whose own CLI \
5109 never confirmed an exit status"
5110 .to_owned(),
5111 ),
5112 Ok(_) => None,
5113 Err(e) => Some(e.to_string()),
5114 };
5115 match incomplete_reason {
5116 None => {
5117 let report = parsed.expect("checked Ok above");
5118 fix.addressed = report.addressed;
5119 fix.rejected = report.rejected;
5120 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5121 }
5122 Some(reason) => {
5123 let (resumed_seat, resolved, failure, cont) = self
5124 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
5125 .await;
5126 fix.duration_ms += cont.cumulative_wait_ms;
5127 fix.continuation = Some(cont);
5128 final_seat = resumed_seat;
5129 match resolved {
5130 Some(report) => {
5131 fix.addressed = report.addressed;
5132 fix.rejected = report.rejected;
5133 fix.notes =
5134 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5135 }
5136 None => fix.failed = failure,
5137 }
5138 }
5139 }
5140 }
5141 AgentOutcome::Dropped(o) => {
5142 fix.duration_ms = o.duration_ms;
5143 let why = o
5144 .dropped
5145 .as_ref()
5146 .map(|d| d.why.as_str())
5147 .unwrap_or("the CLI ended the stream without delivering its answer");
5148 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5149 }
5150 AgentOutcome::Quota(o) => {
5151 self.state.quota.push(QuotaLoss {
5152 seat: final_seat.key.clone(),
5153 node: "fix".to_owned(),
5154 at: Timestamp::now(),
5155 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5156 });
5157 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5158 }
5159 AgentOutcome::Failed(e) => fix.failed = Some(e),
5160 AgentOutcome::ResourceBlocked(e) => {
5161 fix.failed = Some(format!("the fixer was not started: {e}"));
5162 }
5163 }
5164 if fix.continuation.is_none() {
5165 fix.continuation = Some(ContinuationRecord::not_needed());
5166 }
5167 self.state.seats.insert(final_seat.key.clone(), final_seat);
5168
5169 let rescue_message = format!(
5170 "magi: operator-selected fix ({}) (uncommitted work)",
5171 self.state.operator_fixes[request_index]
5172 .findings
5173 .iter()
5174 .map(|f| f.id.as_str())
5175 .collect::<Vec<_>>()
5176 .join(", ")
5177 );
5178 let repo = self.state.repo.clone();
5179 let coordination = self.coordinate(&repo).await?;
5180 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5181 self.state.note_withheld("fix", &r.withheld);
5182 }
5183 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5184 fix.committed = after != head_at_request;
5185 git::worktree_remove(&self.state.repo, &fix_worktree)
5186 .await
5187 .ok();
5188 drop(coordination);
5189
5190 self.state.event(
5191 "fix",
5192 match &fix.failed {
5193 Some(reason) => format!(
5194 "operator fix: adoption report was lost ({reason}); {}",
5195 if fix.committed {
5196 "committed"
5197 } else {
5198 "NO new commit"
5199 }
5200 ),
5201 None => format!(
5202 "operator fix: {} addressed, {} rejected, {}",
5203 fix.addressed.len(),
5204 fix.rejected.len(),
5205 if fix.committed {
5206 "committed"
5207 } else {
5208 "NO new commit"
5209 }
5210 ),
5211 },
5212 );
5213
5214 for f in &mut self.state.operator_fixes[request_index].findings {
5221 f.outcome = if fix.failed.is_some() {
5222 OperatorFixOutcome::Unreported
5223 } else if fix.addressed.contains(&f.id) {
5224 OperatorFixOutcome::Addressed
5225 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5226 OperatorFixOutcome::Rejected { why: r.why.clone() }
5227 } else {
5228 OperatorFixOutcome::Unreported
5229 };
5230 }
5231
5232 let committed = fix.committed;
5233 if committed {
5234 self.state.operator_fixes[request_index].result_head = Some(after.clone());
5235 }
5236 self.state.operator_fixes[request_index].fix = Some(fix);
5237 self.state.save()?;
5240
5241 if committed {
5242 self.state.event(
5243 "fix",
5244 format!(
5245 "operator fix committed {}; opening a follow-up review-only run",
5246 short(&after)
5247 ),
5248 );
5249 let origin =
5252 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5253 match Self::review(
5254 &self.state.repo,
5255 &winner.branch,
5256 self.state.config.clone(),
5257 origin,
5258 )
5259 .await
5260 {
5261 Ok(mut follow_up) => {
5262 follow_up.state.event(
5263 "start",
5264 format!(
5265 "requested by an operator fix on run {} for finding(s) {}",
5266 self.state.id,
5267 self.state.operator_fixes[request_index]
5268 .findings
5269 .iter()
5270 .map(|f| f.id.as_str())
5271 .collect::<Vec<_>>()
5272 .join(", "),
5273 ),
5274 );
5275 follow_up.state.save()?;
5276 let follow_up_id = follow_up.state.id.clone();
5277 let adopted = match crate::direct::adopt(
5280 &follow_up.state,
5281 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5282 ) {
5283 Ok(a) => a,
5284 Err(e) => {
5285 self.state.event(
5288 "fix",
5289 format!(
5290 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5291 ),
5292 );
5293 self.state.save()?;
5294 return Ok(());
5295 }
5296 };
5297 let executed = follow_up.execute().await;
5298 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5299 if let Some(a) = adopted {
5300 a.finish(&follow_up.state, executed);
5301 }
5302 if let Some(e) = failure {
5303 self.state.event(
5304 "fix",
5305 format!(
5306 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5307 ),
5308 );
5309 }
5310 self.state.operator_fixes[request_index].follow_up_review_run =
5311 Some(follow_up_id);
5312 }
5313 Err(e) => {
5314 self.state.event(
5315 "fix",
5316 format!("committed the fix but could not open a follow-up review: {e:#}"),
5317 );
5318 }
5319 }
5320 self.state.save()?;
5321 }
5322
5323 Ok(())
5324 }
5325
5326 async fn ask_fixer(
5346 &mut self,
5347 winner: &Candidate,
5348 node: &'static str,
5349 round: Option<usize>,
5350 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5351 ) -> (SeatJob, SeatState, AgentOutcome) {
5352 let attempts = fixer::attempts(&self.state, &self.roles, winner);
5353 self.ask_fixer_among(attempts, node, round, None, build)
5354 .await
5355 }
5356
5357 async fn ask_fixer_among(
5362 &mut self,
5363 attempts: Vec<(AgentSpec, String)>,
5364 node: &'static str,
5365 round: Option<usize>,
5366 restore: Option<(&Path, &str)>,
5367 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5368 ) -> (SeatJob, SeatState, AgentOutcome) {
5369 let run_id = self.state.id.clone();
5370 let prompts = self.state.config.prompts.clone();
5371 let cache = self.state.config.cache_dir();
5372 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5373 let mut last = None;
5374 for (i, (spec, key)) in attempts.into_iter().enumerate() {
5375 let seat = self.seat(&key, &spec.id);
5376 let mut job = build(spec.clone(), seat);
5377 if i > 0 {
5378 job.stem = format!("{}-{}", job.stem, spec.id);
5379 }
5380 let ctx = WaveCtx {
5381 carry_seats: false,
5382 pauses: Some((&self.pause, &self.interrupt)),
5383 run: &run_id,
5384 node,
5385 prompts: &prompts,
5386 cache: cache.as_deref(),
5387 round,
5388 };
5389 let (seat, out) =
5390 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5391 let advances = match &out {
5394 AgentOutcome::Ok(o) => agent::output_advances(o),
5395 AgentOutcome::ResourceBlocked(_) => false,
5396 _ => true,
5397 };
5398 if let Some(next) = ids.get(i + 1)
5399 && advances
5400 {
5401 self.state.seats.insert(seat.key.clone(), seat.clone());
5402 let class =
5403 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5404 let reason = match &out {
5405 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5406 other => fail_reason(other),
5407 };
5408 if let Some((wt, rev)) = restore
5412 && let Err(e) = self.graft_restore(wt, rev).await
5413 {
5414 let why =
5415 format!("could not restore the worktree before the next fixer: {e:#}");
5416 tracing::warn!("{why}");
5417 let out = if e.is::<crate::repo_lock::ResourceBlocked>() {
5418 AgentOutcome::ResourceBlocked(why)
5419 } else {
5420 AgentOutcome::Failed(why)
5421 };
5422 last = Some((job, seat, out));
5423 break;
5424 }
5425 record_handover(
5426 &mut self.state,
5427 node,
5428 &seat.key,
5429 &spec.id,
5430 next,
5431 &class,
5432 &reason,
5433 );
5434 continue;
5435 }
5436 last = Some((job, seat, out));
5437 break;
5438 }
5439 last.expect("the fixer chain always has an entry")
5440 }
5441
5442 async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5453 use crate::recovery::{Mode, Repair};
5454 if !self.state.reviews.is_empty() {
5455 return Ok(());
5456 }
5457 let Some(mut rec) = self.state.recovery.clone() else {
5458 return Ok(());
5459 };
5460 if rec.mode != Mode::VerifyRepair {
5461 return Ok(());
5462 }
5463 let wt = winner.worktree.clone();
5464 let mut head = git::rev_parse(&wt, "HEAD").await?;
5465 match rec.repair.clone() {
5466 Repair::Done { .. } => return Ok(()),
5467 Repair::WaitingForRestore { before } => {
5468 self.graft_restore(&wt, &before).await?;
5469 head = before;
5470 rec.repair = Repair::NotStarted;
5471 self.state.recovery = Some(rec.clone());
5472 self.state.save()?;
5473 }
5474 Repair::Started { before } => {
5475 if let Err(e) = self.graft_restore(&wt, &before).await {
5479 if e.is::<crate::repo_lock::ResourceBlocked>() {
5480 return Err(e);
5481 }
5482 return self
5483 .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5484 .await;
5485 }
5486 rec.repair = Repair::Done {
5487 before: before.clone(),
5488 after: before,
5489 committed: false,
5490 failed: Some("interrupted; rolled back".to_owned()),
5491 };
5492 self.state
5493 .event("recovery", "the repair was interrupted and rolled back");
5494 self.state.recovery = Some(rec);
5495 self.state.save()?;
5496 return Ok(());
5497 }
5498 Repair::NotStarted => {}
5499 }
5500 if crate::recovery::recheck(&mut rec, &head) {
5501 self.state.event("recovery", rec.summary());
5502 self.state.recovery = Some(rec);
5503 self.state.save()?;
5504 return Ok(());
5505 }
5506 rec.repair = Repair::Started {
5507 before: head.clone(),
5508 };
5509 self.state.recovery = Some(rec.clone());
5510 self.state.save()?;
5511
5512 let language = self.state.config.graph.language.clone();
5513 let sessions = self.state.config.graph.sessions;
5514 let artifacts = agent::artifacts_dir(&self.state.dir());
5515 let fix_prompt = prompt::recovery_fix(
5516 &self.state.instruction,
5517 &rec,
5518 self.state.config.graph.review_rounds,
5519 &language,
5520 );
5521 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5522 self.state.event(
5523 "recovery",
5524 format!(
5525 "repair: the fixer starts from the failed verification on {}",
5526 short(&head)
5527 ),
5528 );
5529 let (_job, seat, out) = self
5530 .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5531 prompt: fix_prompt.clone(),
5532 spec,
5533 seat,
5534 cwd: wt.clone(),
5535 timeout,
5536 allow_write: true,
5537 sessions,
5538 artifacts: artifacts.clone(),
5539 stem: "fix-recovery".to_owned(),
5540 handover: None,
5541 })
5542 .await;
5543 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5544 rec.repair = Repair::WaitingForRestore { before: head };
5545 self.state.recovery = Some(rec);
5546 return self.stop_for_resource();
5547 }
5548 let failed = match &out {
5549 AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5550 AgentOutcome::Quota(o) => {
5551 self.state.quota.push(QuotaLoss {
5552 seat: seat.key.clone(),
5553 node: "fix".to_owned(),
5554 at: Timestamp::now(),
5555 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5556 });
5557 Some("rate limited (quota); the fixer could not run".to_owned())
5558 }
5559 AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5560 AgentOutcome::Failed(e) => Some(e.clone()),
5561 };
5562 self.state.seats.insert(seat.key.clone(), seat);
5563 let coordination = self.coordinate(&wt).await?;
5564 let failed = match failed {
5565 Some(why) => {
5567 if let Err(e) = git::restore_to(&wt, &head).await {
5568 return self
5569 .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5570 .await;
5571 }
5572 Some(why)
5573 }
5574 None => {
5575 if let Ok(r) = git::rescue_commit(
5576 &wt,
5577 "magi: recovery repair of the failed verification (uncommitted work)",
5578 )
5579 .await
5580 {
5581 self.state.note_withheld("fix", &r.withheld);
5582 }
5583 None
5584 }
5585 };
5586 let after = git::rev_parse(&wt, "HEAD").await?;
5587 drop(coordination);
5588 self.state.event(
5589 "recovery",
5590 match &failed {
5591 Some(why) => format!("repair: nothing kept ({why})"),
5592 None if after != head => {
5593 format!("repair: committed {}", short(&after))
5594 }
5595 None => "repair: the fixer made no commit".to_owned(),
5596 },
5597 );
5598 rec.repair = Repair::Done {
5599 before: head.clone(),
5600 committed: after != head,
5601 after,
5602 failed,
5603 };
5604 self.state.recovery = Some(rec);
5605 self.state.save()?;
5606 Ok(())
5607 }
5608
5609 async fn stop_recovery(
5612 &mut self,
5613 mut rec: crate::recovery::RecoveryRecord,
5614 head: &str,
5615 why: String,
5616 ) -> Result<()> {
5617 rec.repair = crate::recovery::Repair::Started {
5619 before: head.to_owned(),
5620 };
5621 self.state.recovery = Some(rec);
5622 self.state
5623 .event("recovery", format!("repair could not be undone: {why}"));
5624 self.state.status = RunStatus::Blocked;
5625 self.state.save()?;
5626 bail!("recovery repair could not be rolled back: {why}")
5627 }
5628
5629 async fn review_loop(&mut self) -> Result<()> {
5630 if self
5635 .state
5636 .base_sync
5637 .as_ref()
5638 .is_some_and(|s| s.conflict.is_some())
5639 {
5640 return Ok(());
5641 }
5642 let run_id = self.state.id.clone();
5647 let prompts = self.state.config.prompts.clone();
5648 let Some(winner) = self.state.winner().cloned() else {
5649 return Ok(());
5650 };
5651 let max_rounds = self.state.config.graph.review_rounds;
5652 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5662 if status == RunStatus::Gating {
5665 self.record_contested_handoff();
5666 }
5667 self.state.status = status;
5668 self.state.save()?;
5669 return Ok(());
5670 }
5671 self.state.status = RunStatus::Reviewing;
5672 if self
5682 .state
5683 .reviews
5684 .last()
5685 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5686 {
5687 let shell = self.state.config.shell();
5688 return self
5689 .stop_reviewing(
5690 "the last round's own verification never resolved",
5691 &shell,
5692 &winner.worktree,
5693 )
5694 .await;
5695 }
5696
5697 self.recovery_repair(&winner).await?;
5698 if self.resource_halt()? {
5699 return Ok(());
5700 }
5701
5702 let repo = self.state.repo.clone();
5703 let root = self.state.worktree_root();
5704 let language = self.state.config.graph.language.clone();
5705 let sessions = self.state.config.graph.sessions;
5706 let artifacts = self.merge_repair_artifacts();
5707 let base = self.landing_base();
5708 let base_short = short(&base);
5709 let reviewers = self.roles.reviewers.clone();
5710 let shell = self.state.config.shell();
5711
5712 for round in (self.state.reviews.len() + 1)..=max_rounds {
5713 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5714 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5715 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5716 let prev_verification = self
5725 .state
5726 .reviews
5727 .last()
5728 .and_then(|r| r.verification_summary(&head));
5729 let prior_section = match (&self.state.recovery, round) {
5732 (Some(rec), 1) if self.state.reviews.is_empty() => {
5733 let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5734 (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5735 Some(crate::recovery::head_of(
5736 &git::diff(&winner.worktree, reviewed, "HEAD")
5737 .await
5738 .unwrap_or_default(),
5739 crate::recovery::SINCE_DIFF_MAX,
5740 ))
5741 }
5742 _ => None,
5743 };
5744 Some(prompt::recovery_review(rec, since.as_deref()))
5745 }
5746 _ => None,
5747 };
5748
5749 let mut jobs = Vec::new();
5753 let coordination = self.coordinate(&repo).await?;
5754 for (r, spec) in reviewers.iter().cloned().enumerate() {
5755 let wt = root.join(format!("review-{}", r + 1));
5756 if wt.exists() {
5759 git::reset_detached(&wt, &head).await?;
5760 } else {
5761 git::worktree_add_detached(&repo, &wt, &head).await?;
5762 }
5763 self.setup_seat_worktree(&repo, &wt, false).await?;
5764 let seat_key = format!("review-{}", r + 1);
5765 let spec = pick_start_spec(
5768 &self.roles.reviewer_roster,
5769 spec,
5770 self.state.seat_history.get(&seat_key),
5771 );
5772 let seat = self.seat(&seat_key, &spec.id);
5773 jobs.push(SeatJob {
5774 prompt: prompt::review(&prompt::ReviewCtx {
5775 instruction: &self.state.instruction,
5776 branch: &winner.branch,
5777 base_short: &base_short,
5778 stat: &stat,
5779 patch: &patch,
5780 verification: prev_verification.as_ref(),
5781 prior: prior_section.as_deref(),
5782 reviewers: reviewers.len(),
5783 round,
5784 rounds: max_rounds,
5785 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5788 lens: Lens::for_seat(r),
5789 language: &language,
5790 github_language: &self.state.config.graph.github_language,
5791 }),
5792 spec,
5793 seat,
5794 cwd: wt,
5795 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5796 allow_write: false,
5797 sessions,
5798 artifacts: artifacts.clone(),
5799 stem: format!("review-{round}-{}", r + 1),
5800 handover: None,
5801 });
5802 }
5803
5804 drop(coordination);
5805 self.state.event(
5806 "review",
5807 format!(
5808 "round {round}: {} reviewers on {}",
5809 jobs.len(),
5810 short(&head)
5811 ),
5812 );
5813 let mut quota_losses = Vec::new();
5814 let review_retries = self.state.config.graph.retries;
5815 let review_cache = self.state.config.cache_dir();
5816 let ctx = WaveCtx {
5817 carry_seats: true,
5818 pauses: Some((&self.pause, &self.interrupt)),
5819 run: &run_id,
5820 node: "review",
5821 prompts: &prompts,
5822 cache: review_cache.as_deref(),
5823 round: Some(round),
5824 };
5825 let results = ask_json_wave::<Review>(
5826 jobs,
5827 Arc::clone(&self.sem),
5828 review_retries,
5829 &self.roles.reviewer_roster,
5830 &ctx,
5831 &mut quota_losses,
5832 &mut self.state,
5833 &|_: &Review| Ok(()),
5834 )
5835 .await;
5836 if self.state.resource_block.is_some() {
5837 return self.stop_for_resource();
5838 }
5839 let round_quota_missing = quota_losses.len();
5843 self.state.quota.extend(quota_losses);
5844
5845 let mut records = Vec::new();
5846 let mut all_findings = Vec::new();
5847 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5848 let agent_id = seat.agent.clone();
5849 self.state.seats.insert(seat.key.clone(), seat);
5850 let mut record = ReviewRecord {
5851 reviewer: r + 1,
5852 agent: agent_id,
5853 summary: String::new(),
5854 findings: Vec::new(),
5855 vote: None,
5856 failed: None,
5857 duration_ms: 0,
5858 attempts,
5864 };
5865 match res {
5866 Ok((review, out)) => {
5867 record.summary =
5875 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5876 record.vote = Some(review.vote);
5877 record.duration_ms = out.duration_ms;
5878 for (n, mut f) in review.findings.into_iter().enumerate() {
5879 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5882 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5883 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5884 f.file = f
5890 .file
5891 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5892 all_findings.push(f.clone());
5893 record.findings.push(f);
5894 }
5895 self.state.event(
5896 "review",
5897 format!(
5898 "round {round}: reviewer {} voted {} with {} finding(s)",
5899 r + 1,
5900 review.vote.label(),
5901 record.findings.len()
5902 ),
5903 );
5904 }
5905 Err(e) => {
5906 record.failed = Some(e.to_string());
5907 self.state.event(
5908 "review",
5909 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5910 );
5911 }
5912 }
5913 records.push(record);
5914 }
5915
5916 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5923 let vote_split =
5924 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5925 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5926 if vote_split {
5927 self.state.event(
5928 "review",
5929 format!(
5930 "round {round}: votes split ({}) — one round of reconsideration",
5931 initial_votes
5932 .iter()
5933 .map(|v| v.label())
5934 .collect::<Vec<_>>()
5935 .join(", ")
5936 ),
5937 );
5938 let panel: Vec<ReviewSeatReport<'_>> = records
5941 .iter()
5942 .filter_map(|r| {
5943 r.vote.map(|vote| ReviewSeatReport {
5944 reviewer: r.reviewer,
5945 vote,
5946 summary: &r.summary,
5947 findings: &r.findings,
5948 })
5949 })
5950 .collect();
5951
5952 let mut jobs = Vec::new();
5953 let mut seats_at = Vec::new();
5954 for (r, spec) in reviewers.iter().cloned().enumerate() {
5955 if records[r].vote.is_none() {
5959 continue;
5960 }
5961 let wt = root.join(format!("review-{}", r + 1));
5962 let seat_key = format!("review-{}", r + 1);
5963 let spec = self.occupant(&seat_key, spec);
5964 let seat = self.seat(&seat_key, &spec.id);
5965 let build = |with_patch: bool| {
5973 prompt::review_reconsider(&ReviewReconsiderCtx {
5974 instruction: &self.state.instruction,
5975 reviewer: r + 1,
5976 lens: Lens::for_seat(r),
5977 panel: &panel,
5978 patch: with_patch.then_some(ReviewPatch {
5979 branch: &winner.branch,
5980 base_short: &base_short,
5981 stat: &stat,
5982 patch: &patch,
5983 }),
5984 round,
5985 rounds: max_rounds,
5986 language: &language,
5987 })
5988 };
5989 let full = build(true);
5990 let prompt = if has_context(&spec, &seat, sessions) {
5991 build(false)
5992 } else {
5993 full.clone()
5994 };
5995 jobs.push(SeatJob {
5996 prompt,
5997 spec,
5998 seat,
5999 cwd: wt,
6000 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
6001 allow_write: false,
6002 sessions,
6003 artifacts: artifacts.clone(),
6004 stem: format!("review-{round}-reconsider-{}", r + 1),
6005 handover: Some(full),
6006 });
6007 seats_at.push(r);
6008 }
6009
6010 let mut recon_quota_losses = Vec::new();
6011 let recon_cache = self.state.config.cache_dir();
6012 let recon_ctx = WaveCtx {
6013 carry_seats: true,
6014 pauses: Some((&self.pause, &self.interrupt)),
6015 run: &run_id,
6016 node: "review",
6017 prompts: &prompts,
6018 cache: recon_cache.as_deref(),
6019 round: Some(round),
6020 };
6021 let recon_results = ask_json_wave::<ReviewRevote>(
6022 jobs,
6023 Arc::clone(&self.sem),
6024 review_retries,
6025 &self.roles.reviewer_roster,
6026 &recon_ctx,
6027 &mut recon_quota_losses,
6028 &mut self.state,
6029 &|_: &ReviewRevote| Ok(()),
6030 )
6031 .await;
6032 if self.state.resource_block.is_some() {
6033 return self.stop_for_resource();
6034 }
6035 self.state.quota.extend(recon_quota_losses);
6036
6037 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
6038 let agent_id = seat.agent.clone();
6039 self.state.seats.insert(seat.key.clone(), seat);
6040 let mut rec = ReviewRevoteRecord {
6041 reviewer: r + 1,
6042 agent: agent_id,
6043 vote: None,
6044 reason: String::new(),
6045 failed: None,
6046 };
6047 match res {
6048 Ok((rv, _)) => {
6049 rec.vote = Some(rv.vote);
6050 rec.reason =
6051 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
6052 self.state.event(
6053 "review",
6054 format!(
6055 "round {round}: reviewer {} revoted {}",
6056 r + 1,
6057 rv.vote.label()
6058 ),
6059 );
6060 }
6061 Err(e) => {
6062 rec.failed = Some(e.to_string());
6063 self.state.event(
6064 "review",
6065 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
6066 );
6067 }
6068 }
6069 reconsideration.push(rec);
6070 }
6071 } else if initial_votes.len() > 1 {
6072 self.state.event(
6073 "review",
6074 format!(
6075 "round {round}: votes agreed ({}) — no reconsideration",
6076 initial_votes[0].label()
6077 ),
6078 );
6079 }
6080
6081 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
6082 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6083 let defer_e2e =
6094 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
6095 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
6096 let reason =
6097 format!("{blocking} blocking finding(s) already required a fix this round");
6098 self.state.event(
6099 "verify",
6100 format!(
6101 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
6102 {}); it will run once a round has none left",
6103 short(&head)
6104 ),
6105 );
6106 (Vec::new(), false, true, Some(reason))
6107 } else {
6108 let e2e_commands = self.state.config.verify.e2e.clone();
6109 let cache_dir = self.state.config.cache_dir();
6110 let context = format!("round {round}");
6111 let wait = self.repository_wait();
6112 let (e2e, verify_retried) = with_cache_lease(
6113 &mut self.state,
6114 cache_dir.as_deref(),
6115 "e2e",
6116 "e2e",
6117 &winner.worktree,
6118 &head,
6119 verify_timeout,
6120 &context,
6121 &wait,
6122 |state, budget| {
6123 let shell = shell.clone();
6124 let e2e_commands = e2e_commands.clone();
6125 let worktree = winner.worktree.clone();
6126 let context = context.clone();
6127 async move {
6128 run_e2e_with_retry(
6129 state,
6130 &shell,
6131 &e2e_commands,
6132 &worktree,
6133 budget,
6134 &context,
6135 )
6136 .await
6137 }
6138 },
6139 )
6140 .await?;
6141 (e2e, verify_retried, false, None)
6142 };
6143
6144 let expected = records.len();
6145 let answered = records.iter().filter(|r| r.failed.is_none()).count();
6146 let incomplete = answered < expected;
6147 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
6148 let policy = self.state.config.graph.incomplete_review;
6149 let clean = round_is_clean(
6150 blocking,
6151 e2e_ok,
6152 answered,
6153 expected,
6154 round_quota_missing,
6155 policy,
6156 );
6157
6158 let mut round_record = ReviewRound {
6159 round,
6160 head: head.clone(),
6161 verified_head: None,
6162 verified_at: None,
6163 reviews: records,
6164 e2e,
6165 verify_retried,
6166 e2e_deferred,
6167 e2e_defer_reason,
6168 fix: None,
6169 blocking,
6170 answered,
6171 expected,
6172 clean,
6173 progressed: false,
6174 vote_split,
6175 reconsideration,
6176 verdict: None,
6177 };
6178 round_record.verdict = ReviewVote::worst(
6182 round_record
6183 .final_votes()
6184 .into_iter()
6185 .map(|(_, _, vote)| vote),
6186 );
6187 if !matches!(
6198 round_record.e2e_status(),
6199 E2eStatus::Deferred | E2eStatus::NotConfigured
6200 ) {
6201 round_record.verified_head = Some(head.clone());
6202 round_record.verified_at = Some(Timestamp::now());
6203 }
6204 let this_round_verification = round_record.verification_summary(&head);
6205
6206 if incomplete {
6207 let missing: Vec<String> = round_record
6208 .reviews
6209 .iter()
6210 .filter(|r| r.failed.is_some())
6211 .map(|r| format!("review-{}", r.reviewer))
6212 .collect();
6213 self.state.event(
6214 "review",
6215 format!(
6216 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6217 missing.join(", ")
6218 ),
6219 );
6220 }
6221
6222 if clean {
6223 self.state.event(
6224 "review",
6225 if incomplete && policy == IncompleteReviewPolicy::Warn {
6226 format!(
6227 "round {round}: clean (warn policy, incomplete panel) — no \
6228 blocking findings from the seats that answered, verification green"
6229 )
6230 } else if incomplete {
6231 format!(
6232 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6233 quorum) — no blocking findings from the seats that answered, \
6234 verification green",
6235 expected - answered
6236 )
6237 } else {
6238 format!("round {round}: clean — no blocking findings, verification green")
6239 },
6240 );
6241 self.state.reviews.push(round_record);
6242 self.state.status = RunStatus::Gating;
6243 self.state.save()?;
6244 return Ok(());
6245 }
6246
6247 if incomplete && blocking == 0 && e2e_ok {
6255 self.state.reviews.push(round_record);
6256 self.state.save()?;
6257 if round == max_rounds {
6258 self.state.status = RunStatus::Blocked;
6259 self.state.event(
6260 "review",
6261 format!(
6262 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6263 refusing to call it clean",
6264 expected - answered
6265 ),
6266 );
6267 return Ok(());
6268 }
6269 continue;
6270 }
6271
6272 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6284 self.state.reviews.push(round_record);
6285 return self
6286 .stop_reviewing(
6287 "the round's own verification could not run",
6288 &shell,
6289 &winner.worktree,
6290 )
6291 .await;
6292 }
6293
6294 if round == max_rounds {
6295 self.state.reviews.push(round_record);
6296 return self
6297 .stop_reviewing(
6298 &format!(
6299 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6300 ),
6301 &shell,
6302 &winner.worktree,
6303 )
6304 .await;
6305 }
6306
6307 let blocking_findings: Vec<_> = all_findings
6310 .iter()
6311 .filter(|f| f.severity.blocks())
6312 .cloned()
6313 .collect();
6314 let fix_prompt = prompt::fix_in(
6315 &self.state.instruction,
6316 &blocking_findings,
6317 this_round_verification.as_ref(),
6318 round,
6319 max_rounds,
6320 &language,
6321 &self.state.config.graph.github_language,
6322 );
6323 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6324 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6325 let (job, seat, out) = self
6326 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6327 prompt: fix_prompt.clone(),
6328 spec,
6329 seat,
6330 cwd: winner.worktree.clone(),
6331 timeout,
6332 allow_write: true,
6333 sessions,
6334 artifacts: artifacts.clone(),
6335 stem: format!("fix-{round}"),
6336 handover: None,
6337 })
6338 .await;
6339 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6342 return self.stop_for_resource();
6343 }
6344 let agent_id = seat.agent.clone();
6345
6346 let mut fix = FixRecord {
6347 agent: agent_id,
6348 addressed: Vec::new(),
6349 rejected: Vec::new(),
6350 notes: String::new(),
6351 committed: false,
6352 failed: None,
6353 duration_ms: 0,
6354 continuation: None,
6355 };
6356 let mut continuation = ContinuationRecord::not_needed();
6357 let mut final_seat = seat.clone();
6358 match out {
6359 AgentOutcome::Ok(o) => {
6360 fix.duration_ms = o.duration_ms;
6361 let parsed = verdict::extract_json::<FixReport>(&o.text);
6362 let incomplete_reason = match &parsed {
6369 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6370 "the reply parsed, but it reported a command whose own CLI never \
6371 confirmed an exit status"
6372 .to_owned(),
6373 ),
6374 Ok(_) => None,
6375 Err(e) => Some(e.to_string()),
6376 };
6377 match incomplete_reason {
6378 None => {
6379 let report = parsed.expect("checked Ok above");
6380 fix.addressed = report.addressed;
6381 fix.rejected = report.rejected;
6382 fix.notes =
6383 blind::sanitize_prose(&report.notes, &self.state.config.blind);
6384 }
6385 Some(reason) => {
6386 let (resumed_seat, resolved, failure, cont) = self
6387 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6388 .await;
6389 fix.duration_ms += cont.cumulative_wait_ms;
6390 continuation = cont;
6391 final_seat = resumed_seat;
6392 match resolved {
6393 Some(report) => {
6394 fix.addressed = report.addressed;
6395 fix.rejected = report.rejected;
6396 fix.notes = blind::sanitize_prose(
6397 &report.notes,
6398 &self.state.config.blind,
6399 );
6400 }
6401 None => fix.failed = failure,
6402 }
6403 }
6404 }
6405 }
6406 AgentOutcome::Dropped(o) => {
6408 fix.duration_ms = o.duration_ms;
6409 let why = o
6410 .dropped
6411 .as_ref()
6412 .map(|d| d.why.as_str())
6413 .unwrap_or("the CLI ended the stream without delivering its answer");
6414 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6415 }
6416 AgentOutcome::Quota(o) => {
6417 self.state.quota.push(QuotaLoss {
6418 seat: final_seat.key.clone(),
6419 node: "fix".to_owned(),
6420 at: Timestamp::now(),
6421 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6422 });
6423 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6424 }
6425 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6426 }
6427 fix.continuation = Some(continuation);
6428 self.state.seats.insert(final_seat.key.clone(), final_seat);
6429 let coordination = self.coordinate(&winner.worktree).await?;
6430 if let Ok(r) = git::rescue_commit(
6431 &winner.worktree,
6432 &format!("magi: review round {round} fixes (uncommitted work)"),
6433 )
6434 .await
6435 {
6436 self.state.note_withheld("fix", &r.withheld);
6437 }
6438 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6439 fix.committed = after != before;
6440 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6448 drop(coordination);
6449 let progressed = diff_after != patch;
6450 let commit_note = if fix.committed {
6451 "committed"
6452 } else {
6453 "NO new commit"
6454 };
6455 let tree_note = if progressed {
6456 "changed vs base"
6457 } else {
6458 "unchanged vs base"
6459 };
6460 self.state.event(
6461 "fix",
6462 match &fix.failed {
6463 Some(reason) => {
6469 format!(
6470 "round {round}: fixer's adoption report was lost ({reason}); \
6471 {commit_note}, tree {tree_note}"
6472 )
6473 }
6474 None => format!(
6475 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6476 {tree_note}{}",
6477 fix.addressed.len(),
6478 fix.rejected.len(),
6479 if continuation.outcome == ContinuationOutcome::Resumed {
6480 format!(
6481 " (adoption report recovered after {} continuation(s))",
6482 continuation.attempts
6483 )
6484 } else {
6485 String::new()
6486 },
6487 ),
6488 },
6489 );
6490 round_record.fix = Some(fix);
6491 round_record.progressed = progressed;
6492 self.state.reviews.push(round_record);
6493 self.state.save()?;
6494
6495 if matches!(
6508 continuation.outcome,
6509 ContinuationOutcome::Exhausted
6510 | ContinuationOutcome::QuotaLost
6511 | ContinuationOutcome::NoSession
6512 ) {
6513 return self
6514 .stop_reviewing(
6515 "the fixer's adoption report never came back, even after resuming its \
6516 own seat; refusing to start another round against the same worktree \
6517 while that is unresolved",
6518 &shell,
6519 &winner.worktree,
6520 )
6521 .await;
6522 }
6523
6524 let streak = self
6525 .state
6526 .reviews
6527 .iter()
6528 .rev()
6529 .take_while(|r| !r.progressed)
6530 .count();
6531 if streak >= STAGNANT_LIMIT {
6532 return self
6533 .stop_reviewing(
6534 &format!(
6535 "the tree has not moved against base for {streak} round(s) in a row"
6536 ),
6537 &shell,
6538 &winner.worktree,
6539 )
6540 .await;
6541 }
6542 }
6543 Ok(())
6544 }
6545
6546 fn record_contested_handoff(&mut self) {
6580 if self.state.contested_handoff.is_some() {
6581 return;
6582 }
6583 let Some(contested) = self
6584 .state
6585 .reviews
6586 .last()
6587 .and_then(ReviewRound::contested_handoff)
6588 else {
6589 return;
6590 };
6591 self.state.event(
6592 "review",
6593 format!(
6594 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6595 wait for the owner's approval",
6596 contested.findings.len(),
6597 contested.rejecters.len()
6598 ),
6599 );
6600 self.state.contested_handoff = Some(contested);
6601 }
6602
6603 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6604 let round_idx = self.state.reviews.len() - 1;
6605 let needs_catchup_run = matches!(
6613 self.state.reviews[round_idx].e2e_status(),
6614 E2eStatus::Deferred | E2eStatus::ResourceBlocked
6615 );
6616 if needs_catchup_run {
6617 let round = self.state.reviews[round_idx].round;
6618 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6619 let commands = self.state.config.verify.e2e.clone();
6620 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6621 let cache_dir = self.state.config.cache_dir();
6622 let context = format!(
6623 "round {round}: verification unresolved, catching up before the final decision"
6624 );
6625 let wait = self.repository_wait();
6626 let (outcomes, verify_retried) = with_cache_lease(
6627 &mut self.state,
6628 cache_dir.as_deref(),
6629 "e2e",
6630 "e2e",
6631 worktree,
6632 &attempted_head,
6633 timeout,
6634 &context,
6635 &wait,
6636 |state, budget| {
6637 let shell = shell.to_vec();
6638 let commands = commands.clone();
6639 let context = context.clone();
6640 async move {
6641 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6642 .await
6643 }
6644 },
6645 )
6646 .await?;
6647 let last = &mut self.state.reviews[round_idx];
6648 last.e2e = outcomes;
6649 last.verify_retried = verify_retried;
6650 last.verified_head = Some(attempted_head);
6657 last.verified_at = Some(Timestamp::now());
6658 if verify_inconclusive(&last.e2e) {
6659 self.state.save()?;
6666 return Ok(());
6667 }
6668 last.e2e_deferred = false;
6669 }
6670 let last = &self.state.reviews[round_idx];
6671 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6672
6673 match last.e2e_status() {
6674 E2eStatus::Failed => {
6675 let red: Vec<String> = last
6676 .e2e
6677 .iter()
6678 .filter(|o| !o.ok())
6679 .map(|o| {
6680 format!(
6681 "`{}` -> {:?}\n{}",
6682 o.command,
6683 o.code,
6684 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6685 )
6686 })
6687 .collect();
6688 self.state
6689 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6690 self.state.status = RunStatus::Blocked;
6691 }
6692 E2eStatus::ResourceBlocked => {
6697 self.state.event(
6698 "review",
6699 format!(
6700 "{why}; e2e could not run (shared build cache unavailable); not \
6701 deciding yet"
6702 ),
6703 );
6704 }
6705 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6706 self.state.event(
6707 "review",
6708 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6709 );
6710 self.record_contested_handoff();
6711 self.state.status = RunStatus::Gating;
6712 }
6713 }
6714 self.state.save()?;
6715 Ok(())
6716 }
6717
6718 async fn gate(&mut self) -> Result<()> {
6721 if self.state.status == RunStatus::Failed
6733 || self
6734 .state
6735 .base_sync
6736 .as_ref()
6737 .is_some_and(|s| s.conflict.is_some())
6738 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6739 != Some(RunStatus::Gating)
6740 {
6741 return Ok(());
6742 }
6743 if self.state.gate_ran {
6744 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6755 self.state.status = RunStatus::Blocked;
6756 self.state.save()?;
6757 }
6758 return Ok(());
6759 }
6760 let Some(winner) = self.state.winner().cloned() else {
6761 return Ok(());
6762 };
6763 self.state.status = RunStatus::Gating;
6764 let mut outcomes = self.run_gate(&winner).await?;
6765 loop {
6766 if verify_inconclusive(&outcomes) {
6777 self.state.save()?;
6778 return Ok(());
6779 }
6780 if outcomes.iter().all(CommandOutcome::ok) {
6781 break;
6782 }
6783 match self.gate_fix_round(&winner, &outcomes).await? {
6784 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6785 GateFix::Stop => break,
6786 GateFix::Defer => {
6787 self.state.save()?;
6788 return Ok(());
6789 }
6790 }
6791 }
6792 let passed = outcomes.iter().all(CommandOutcome::ok);
6793 self.state.gate = outcomes;
6794 self.state.gate_ran = true;
6795 self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6796 if !passed {
6797 self.state.status = RunStatus::Blocked;
6798 let spent = self.state.gate_fixes.len();
6799 self.state.event(
6800 "gate",
6801 if spent == 0 {
6802 "gate failed; not merging".to_owned()
6803 } else {
6804 format!("gate failed after {spent} gate-fix round(s); not merging")
6805 },
6806 );
6807 }
6808 self.state.save()?;
6809 Ok(())
6810 }
6811
6812 async fn run_pre_gate(state: &mut RunState, winner: &Candidate) {
6822 let commands = state.config.verify.pre_gate.clone();
6823 if commands.is_empty() {
6824 return;
6825 }
6826 let shell = state.config.shell();
6827 let timeout = Duration::from_secs(state.config.graph.verify_timeout());
6828 let (outcomes, _) = run_commands(
6829 state,
6830 "pre_gate",
6831 "pre_gate",
6832 0,
6833 &shell,
6834 &commands,
6835 &winner.worktree,
6836 timeout,
6837 )
6838 .await;
6839 for o in &outcomes {
6840 if !o.ok() {
6841 tracing::warn!(
6842 "pre_gate `{}` failed ({:?}); the gate decides",
6843 o.command,
6844 o.code
6845 );
6846 }
6847 state.event(
6848 "pre_gate",
6849 format!(
6850 "`{}` -> {}",
6851 o.command,
6852 if o.ok() {
6853 "pass".to_owned()
6854 } else {
6855 format!(
6856 "FAIL ({:?})\n{}",
6857 o.code,
6858 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6859 )
6860 }
6861 ),
6862 );
6863 }
6864 state.pre_gate = outcomes;
6865 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6866 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6867 Ok(head) => {
6868 state.event("pre_gate", format!("committed mechanical fixes ({head})"));
6869 state.pre_gate_commit = Some(head);
6870 }
6871 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6872 },
6873 Ok(false) => {}
6874 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6875 }
6876 if let Err(e) = state.save() {
6877 tracing::warn!("could not persist the pre_gate record: {e:#}");
6878 }
6879 }
6880
6881 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6884 let shell = self.state.config.shell();
6885 let gate_commands = self.state.config.verify.gate.clone();
6886 let has_pre_gate = !self.state.config.verify.pre_gate.is_empty();
6887 let outcomes = if gate_commands.is_empty() && !has_pre_gate {
6888 Vec::new()
6889 } else {
6890 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6891 let cache_dir = self.state.config.cache_dir();
6892 let wait = self.repository_wait();
6893 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6894 let (outcomes, _) = with_cache_lease(
6895 &mut self.state,
6896 cache_dir.as_deref(),
6897 "gate",
6898 "gate",
6899 &winner.worktree,
6900 &head,
6901 timeout,
6902 "final gate",
6903 &wait,
6904 |state, budget| {
6905 let shell = shell.clone();
6906 let gate_commands = gate_commands.clone();
6907 let winner = winner.clone();
6908 async move {
6909 Self::run_pre_gate(state, &winner).await;
6910 let (outcomes, timed_out_pids) = run_commands(
6911 state,
6912 "gate",
6913 "gate",
6914 0,
6915 &shell,
6916 &gate_commands,
6917 &winner.worktree,
6918 budget,
6919 )
6920 .await;
6921 (outcomes, false, timed_out_pids)
6922 }
6923 },
6924 )
6925 .await?;
6926 outcomes
6927 };
6928 if outcomes.is_empty() {
6929 self.state.event(
6934 "gate",
6935 "no gate commands configured; nothing to check, passing",
6936 );
6937 }
6938 for o in &outcomes {
6939 self.state.event(
6940 "gate",
6941 format!(
6942 "`{}` -> {}",
6943 o.command,
6944 if o.ok() {
6945 "pass".to_owned()
6946 } else {
6947 format!(
6948 "FAIL ({:?})\n{}",
6949 o.code,
6950 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6951 )
6952 }
6953 ),
6954 );
6955 }
6956 Ok(outcomes)
6957 }
6958
6959 async fn gate_fix_round(
6972 &mut self,
6973 winner: &Candidate,
6974 outcomes: &[CommandOutcome],
6975 ) -> Result<GateFix> {
6976 let cap = self.state.config.graph.gate_fix_rounds;
6977 let spent = self.state.gate_fixes.len();
6978 if spent >= cap {
6979 if cap > 0 {
6980 self.state.event(
6981 "gate",
6982 format!("{spent} gate-fix round(s) spent and the gate still fails"),
6983 );
6984 }
6985 return Ok(GateFix::Stop);
6986 }
6987 if !gate_fixable(outcomes) {
6988 self.state.event(
6989 "gate",
6990 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6991 command or similar); not spending a fix round on it",
6992 );
6993 return Ok(GateFix::Stop);
6994 }
6995 let min_free = self.state.config.disk.min_free_bytes;
6996 if min_free > 0 {
6997 match crate::disk::free_bytes(&winner.worktree) {
6998 Ok(free) if crate::disk::enough_space(free, min_free) => {}
6999 Ok(free) => {
7000 self.state.event(
7001 "gate",
7002 format!(
7003 "only {free} bytes free ({min_free} required by `[disk] \
7004 min_free_bytes`); not spending a fix round on a failure the disk \
7005 may explain"
7006 ),
7007 );
7008 return Ok(GateFix::Stop);
7009 }
7010 Err(e) => {
7011 self.state.event(
7012 "gate",
7013 format!("free disk space could not be measured ({e:#}); no fix round"),
7014 );
7015 return Ok(GateFix::Stop);
7016 }
7017 }
7018 }
7019
7020 let attempt = spent + 1;
7021 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
7022 let base = self.landing_base();
7023 let fix_prompt = prompt::gate_fix_in(
7024 &self.state.instruction,
7025 &failed,
7026 attempt,
7027 cap,
7028 &self.state.config.graph.language,
7029 &self.state.config.graph.github_language,
7030 );
7031 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
7032 let sessions = self.state.config.graph.sessions;
7033 let artifacts = self.merge_repair_artifacts();
7034 self.state.event(
7035 "gate",
7036 format!("gate failed; gate-fix round {attempt} of {cap}"),
7037 );
7038 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
7039 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
7040 let (_, seat, out) = self
7041 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
7042 prompt: fix_prompt.clone(),
7043 spec,
7044 seat,
7045 cwd: winner.worktree.clone(),
7046 timeout,
7047 allow_write: true,
7048 sessions,
7049 artifacts: artifacts.clone(),
7050 stem: format!("gate-fix-{attempt}"),
7051 handover: None,
7052 })
7053 .await;
7054 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
7057 return Ok(GateFix::Defer);
7058 }
7059 let mut record = GateFixRecord {
7060 agent: seat.agent.clone(),
7061 failed,
7062 notes: String::new(),
7063 committed: false,
7064 error: None,
7065 };
7066 match out {
7067 AgentOutcome::Ok(o) => {
7068 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
7071 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
7072 }
7073 }
7074 AgentOutcome::Dropped(_) => {
7075 record.error = Some("the CLI dropped the stream".to_owned());
7076 }
7077 AgentOutcome::Quota(o) => {
7078 self.state.quota.push(QuotaLoss {
7079 seat: seat.key.clone(),
7080 node: "gate-fix".to_owned(),
7081 at: Timestamp::now(),
7082 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
7083 });
7084 record.error = Some("rate limited (quota); fixer could not run".to_owned());
7085 }
7086 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
7087 }
7088 self.state.seats.insert(seat.key.clone(), seat);
7089 let coordination = self.coordinate(&winner.worktree).await?;
7090 if let Ok(r) = git::rescue_commit(
7091 &winner.worktree,
7092 &format!("magi: gate fix {attempt} (uncommitted work)"),
7093 )
7094 .await
7095 {
7096 self.state.note_withheld("gate-fix", &r.withheld);
7097 }
7098 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
7099 record.committed = after != before;
7100 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
7101 let note = record.error.clone();
7102 self.state.gate_fixes.push(record);
7103 self.state.save()?;
7104 if !changed {
7105 self.state.event(
7106 "gate",
7107 match note {
7108 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
7109 None => format!("gate-fix round {attempt}: the tree did not change"),
7110 },
7111 );
7112 return Ok(GateFix::Stop);
7113 }
7114 self.state.event(
7115 "gate",
7116 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
7117 );
7118
7119 drop(coordination);
7120 let commands = self.state.config.verify.e2e.clone();
7121 if !commands.is_empty() {
7122 let shell = self.state.config.shell();
7123 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
7124 let cache_dir = self.state.config.cache_dir();
7125 let context = format!("gate-fix round {attempt}");
7126 let wait = self.repository_wait();
7127 let (e2e, _) = with_cache_lease(
7128 &mut self.state,
7129 cache_dir.as_deref(),
7130 "e2e",
7131 "e2e",
7132 &winner.worktree,
7133 &after,
7134 timeout,
7135 &context,
7136 &wait,
7137 |state, budget| {
7138 let shell = shell.clone();
7139 let commands = commands.clone();
7140 let context = context.clone();
7141 let worktree = winner.worktree.clone();
7142 async move {
7143 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
7144 .await
7145 }
7146 },
7147 )
7148 .await?;
7149 if verify_inconclusive(&e2e) {
7150 return Ok(GateFix::Defer);
7151 }
7152 if e2e.iter().any(|o| !o.ok()) {
7153 self.state.event(
7154 "gate",
7155 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7156 );
7157 return Ok(GateFix::Stop);
7158 }
7159 }
7160 Ok(GateFix::Retry)
7161 }
7162
7163 async fn guarded_pr_message(
7170 &mut self,
7171 winner: &Candidate,
7172 facts: Option<&BranchFacts>,
7173 posting: bool,
7174 ) -> Result<Option<PrMessage>> {
7175 let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7176 let head = match final_head.as_deref() {
7177 Some(h) => crate::pr_outcome::FinalHead::At(h),
7178 None => crate::pr_outcome::FinalHead::Unknown,
7179 };
7180 let summarized = if posting {
7182 Some(self.summarize_change(winner, final_head.as_deref()).await)
7183 } else {
7184 None
7185 };
7186 if self.state.resource_block.is_some() {
7191 self.state.event(
7192 "pr-summary",
7193 "a shared resource was busy; nothing was published, resume to summarize again",
7194 );
7195 return Ok(None);
7196 }
7197 if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7198 anyhow::bail!(
7199 "the final commit moved during public description generation; retry before publishing"
7200 );
7201 }
7202 let note = match (&summarized, &self.state.change_summary) {
7203 (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7204 (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7205 (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7206 (None, _) => ChangeNote::NotAsked,
7207 };
7208 let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7209 if !posting {
7210 let identity = crate::scrub::Identity::current();
7211 return Ok(Some(PrMessage {
7212 title: crate::scrub::scrub(&pr.title, &identity),
7213 body: crate::scrub::scrub(&pr.body, &identity),
7214 }));
7215 }
7216 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7219 if let Some(known) = self
7220 .state
7221 .github_text
7222 .clone()
7223 .filter(|g| g.fingerprint == raw_fp)
7224 {
7225 return self.settle_withheld(winner, &pr, known).await;
7226 }
7227 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7228 let mut violations =
7229 crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7230 let identity = crate::scrub::Identity::current();
7231 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7232 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7233 && !violations.contains(&crate::github_text::Violation::SensitiveData)
7234 {
7235 violations.push(crate::github_text::Violation::SensitiveData);
7236 }
7237 let has_language = violations
7241 .iter()
7242 .any(|v| *v != crate::github_text::Violation::SensitiveData);
7243 if self.state.config.graph.github_text_guard && !violations.is_empty() {
7244 self.state.event(
7245 "github-text",
7246 if has_language {
7247 format!("description rejected: {violations:?}; requesting one rewrite")
7248 } else {
7249 format!("description rejected: {violations:?}; asking the owner")
7250 },
7251 );
7252 let mut rewritten = false;
7253 let mut rejected_rewrite = None;
7254 if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7255 {
7256 let output = text;
7257 if let Some(title) = summary_title(&output) {
7258 let raw_body = summary_without_title(&output);
7261 let clean_title = crate::scrub::scrub(&title, &identity);
7262 let mut body = crate::scrub::scrub(&raw_body, &identity);
7263 let redacted = clean_title != title || body != raw_body;
7264 let title = clean_title;
7265 let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7266 if !complete {
7267 self.state.event(
7268 "github-text",
7269 "description rewrite omitted required findings",
7270 );
7271 }
7272 if !body.trim().is_empty() {
7273 body.push_str("\n\n");
7274 body.push_str(&public_pr_evidence(&self.state, head));
7275 }
7276 let marker = format!("magi:run/{}", self.state.id);
7277 if !body.trim().is_empty() && !body.contains(&marker) {
7278 body.push_str(&format!("\n\n{marker}"));
7279 }
7280 let rewrite = PrMessage {
7281 title: title.clone(),
7282 body: body.clone(),
7283 };
7284 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7285 let mut failed = crate::github_text::check_for(
7286 &self.state.config.graph,
7287 &title,
7288 &body,
7289 redecision,
7290 );
7291 if (!crate::github_text::shareable(&title)
7292 || !crate::github_text::shareable(&body))
7293 && !failed.contains(&crate::github_text::Violation::SensitiveData)
7294 {
7295 failed.push(crate::github_text::Violation::SensitiveData);
7296 }
7297 if complete
7298 && !title.trim().is_empty()
7299 && !body.trim().is_empty()
7300 && !failed.is_empty()
7301 {
7302 rejected_rewrite = Some(crate::run::GithubTextRewrite {
7303 title: title.clone(),
7304 body: body.clone(),
7305 failed: failed
7306 .iter()
7307 .map(|v| crate::github_text::category(*v).to_owned())
7308 .collect(),
7309 });
7310 }
7311 if complete
7312 && !title.trim().is_empty()
7313 && !raw_body.trim().is_empty()
7314 && failed.is_empty()
7315 {
7316 decision = redecision;
7317 pr = PrMessage { title, body };
7318 rewritten = true;
7319 self.state
7320 .event("github-text", "description rewrite passed");
7321 if redacted {
7322 self.state.event(
7323 "github-text",
7324 "sensitive data removed from description rewrite",
7325 );
7326 }
7327 }
7328 }
7329 }
7330 if !rewritten {
7331 self.state.event(
7332 "github-text",
7333 "description rewrite unavailable or rejected; asking the owner",
7334 );
7335 if let Some(w) =
7336 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7337 {
7338 self.retire_github_text_question("the withheld text changed");
7341 let record = crate::run::GithubTextAsk {
7342 rewrite: rejected_rewrite,
7343 fingerprint: raw_fp,
7344 title: w.title,
7345 body: w.body,
7346 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7347 question: None,
7348 asks: 0,
7349 resolved: false,
7350 chosen_title: None,
7351 choice: None,
7352 };
7353 return self.settle_withheld(winner, &pr, record).await;
7354 }
7355 }
7356 }
7357 let (title, body) =
7358 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7359 Ok(Some(PrMessage { title, body }))
7360 }
7361
7362 async fn summarize_change(
7373 &mut self,
7374 winner: &Candidate,
7375 head: Option<&str>,
7376 ) -> std::result::Result<(), String> {
7377 let Some(head) = head else {
7378 return Err("the final commit could not be read".to_owned());
7379 };
7380 let remote = self.state.config.merge.remote.clone();
7381 let tracking = format!("{remote}/{}", self.state.base_branch);
7382 if !git::rev_exists(&winner.worktree, &tracking).await {
7383 return Err(format!("the remote base `{tracking}` could not be read"));
7384 }
7385 let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7386 Ok(b) if !b.is_empty() => b,
7387 _ => return Err("the merge base with the remote base could not be read".to_owned()),
7388 };
7389 let evidence = crate::pr_outcome::summary_evidence(
7390 &self.state,
7391 crate::pr_outcome::FinalHead::At(head),
7392 );
7393 let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7394 if self
7395 .state
7396 .change_summary
7397 .as_ref()
7398 .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7399 {
7400 return Ok(());
7401 }
7402 if self.state.change_summary.take().is_some() {
7403 self.state.event(
7404 "pr-summary",
7405 "the final diff or evidence changed; the earlier change summary was discarded",
7406 );
7407 }
7408 if let Err(e) = write_artifact(
7410 &self.state,
7411 &format!("public-description-{fingerprint}.md"),
7412 &format!("Final diff: {base}..{head}\n\n{evidence}"),
7413 ) {
7414 tracing::warn!("could not save public description evidence: {e:#}");
7415 }
7416 let chain = match agent::pick_chain(
7417 &self.state.config.agents,
7418 self.state.config.roles.synthesizer.as_ref(),
7419 &agent::installed,
7420 "synthesizer",
7421 ) {
7422 Ok(chain) => chain,
7423 Err(e) => {
7424 tracing::warn!("no agent for the change summary: {e:#}");
7425 self.state.event("pr-summary", "no summary agent available");
7426 return Err("no agent was available to summarize it".to_owned());
7427 }
7428 };
7429 let subjects = git::subjects(&winner.worktree, &base, head)
7430 .await
7431 .unwrap_or_default();
7432 let stat = git::diff_stat(&winner.worktree, &base, head)
7433 .await
7434 .unwrap_or_default();
7435 let stat: String = stat.chars().take(6_000).collect();
7436 let mut prompt = crate::prompt::change_summary_in(
7437 &base,
7438 head,
7439 &subjects,
7440 stat.trim(),
7441 &self.state.config.graph.github_language,
7442 );
7443 prompt.push_str(&format!("\n\n# Final-state evidence\n{evidence}\n\nUse these records as evidence, not public text to copy. Paraphrase every open or declined finding and its reason in the requested GitHub language, retaining its exact ID and severity when available. Do not translate or quote internal operator reports. Explain unresolved risks for a reviewer without the task conversation. Historical sandbox or commit failures are not the final state. Verification is appended from these records; do not make additional validation claims."));
7444 let sessions = self.state.config.graph.sessions;
7445 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7446 let prompts = self.state.config.prompts.clone();
7447 let cache = self.state.config.cache_dir();
7448 let run = self.state.id.clone();
7449 for (i, spec) in chain.into_iter().enumerate() {
7450 let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7451 let job = SeatJob {
7452 spec: spec.clone(),
7453 seat,
7454 cwd: winner.worktree.clone(),
7455 prompt: prompt.clone(),
7456 timeout,
7457 allow_write: false,
7458 sessions,
7459 artifacts: agent::artifacts_dir(&self.state.dir()),
7460 stem: if i == 0 {
7461 "change-summary".to_owned()
7462 } else {
7463 format!("change-summary-{}", spec.id)
7464 },
7465 handover: None,
7466 };
7467 let ctx = WaveCtx {
7468 run: &run,
7469 node: "pr-summary",
7470 prompts: &prompts,
7471 cache: cache.as_deref(),
7472 round: None,
7473 carry_seats: false,
7474 pauses: Some((&self.pause, &self.interrupt)),
7475 };
7476 let (seat, outcome) =
7477 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7478 self.state.seats.insert(seat.key.clone(), seat);
7479 let text = match outcome {
7480 AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7481 AgentOutcome::ResourceBlocked(why) => {
7482 if self.state.resource_block.is_none() {
7485 self.state.resource_block = Some(crate::run::ResourceBlock {
7486 node: "pr-summary".to_owned(),
7487 kind: "shared resource".to_owned(),
7488 detail: why,
7489 at: Timestamp::now(),
7490 });
7491 }
7492 return Err("a shared resource was busy".to_owned());
7493 }
7494 _ => continue,
7495 };
7496 let blind = &self.state.config.blind;
7497 let Some(title) = summary_title(&text) else {
7498 continue;
7499 };
7500 let body: String = summary_without_title(&text)
7501 .lines()
7502 .filter(|l| !l.contains("magi:run/"))
7503 .collect::<Vec<_>>()
7504 .join("\n");
7505 let body = blind::sanitize_prose(body.trim(), blind);
7506 let title = blind::sanitize_prose(&title, blind);
7507 if body.trim().is_empty() || title.trim().is_empty() {
7508 continue;
7509 }
7510 if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7511 continue;
7512 }
7513 if git::rev_parse(&winner.worktree, "HEAD")
7515 .await
7516 .ok()
7517 .as_deref()
7518 != Some(head)
7519 {
7520 return Err("the branch moved while the summary was written".to_owned());
7521 }
7522 if git::merge_base(&winner.worktree, &tracking, head)
7523 .await
7524 .ok()
7525 .as_deref()
7526 != Some(base.as_str())
7527 || crate::pr_outcome::summary_evidence(
7528 &self.state,
7529 crate::pr_outcome::FinalHead::At(head),
7530 ) != evidence
7531 {
7532 return Err("the summary evidence changed while it was written".to_owned());
7533 }
7534 self.state.change_summary = Some(crate::run::ChangeSummary {
7535 head: head.to_owned(),
7536 base,
7537 evidence: fingerprint,
7538 title,
7539 body,
7540 });
7541 self.state.event(
7542 "pr-summary",
7543 format!("change summary written by {} for the final diff", spec.id),
7544 );
7545 if let Err(e) = self.state.save() {
7546 tracing::warn!("could not save the change summary: {e:#}");
7547 }
7548 return Ok(());
7549 }
7550 self.state
7551 .event("pr-summary", "no agent produced a change summary");
7552 Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7553 }
7554
7555 async fn rewrite_pr_text(
7563 &mut self,
7564 winner: &Candidate,
7565 pr: &PrMessage,
7566 violations: &[crate::github_text::Violation],
7567 ) -> Option<String> {
7568 let prompt = format!(
7569 "Rewrite only the following pull request description into English. The posting gate reported {violations:?}. Keep every substantive detail (file names, commands, numbers, findings, code blocks and <details> sections) and translate the prose. Remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the rewritten description. Include an explanation of every open or declined finding with its exact ID. Omit the generated Review rounds, Final verification and Open review findings sections, the Evidence line and run markers: magi appends those from its records.\n\nTITLE: {}\n{}",
7570 pr.title, pr.body
7571 );
7572 let sessions = self.state.config.graph.sessions;
7573 let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7574 let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7576 let key = format!("impl-{}", winner.label);
7577 if let Some(spec) = self
7578 .state
7579 .config
7580 .agents
7581 .iter()
7582 .find(|a| a.id == winner.agent)
7583 .cloned()
7584 && let Some(existing) = self.state.seats.get(&key)
7585 && existing.agent == spec.id
7586 && agent::has_session(spec.kind, existing, sessions)
7587 {
7588 attempts.push((spec, existing.clone(), true));
7589 } else {
7590 let chain = agent::pick_chain(
7591 &self.state.config.agents,
7592 self.state.config.roles.language_judge.as_ref(),
7593 &agent::installed,
7594 "language_judge",
7595 );
7596 match chain {
7597 Ok(chain) => {
7598 for spec in chain {
7599 let seat = crate::fixer::seat_for(
7600 &mut self.state,
7601 "github-text-rewrite",
7602 &spec.id,
7603 );
7604 attempts.push((spec, seat, false));
7605 }
7606 }
7607 Err(e) => {
7608 tracing::warn!("no agent for the description rewrite: {e:#}");
7609 self.state
7610 .event("github-text", "no rewrite agent available");
7611 return None;
7612 }
7613 }
7614 }
7615 let prompts = self.state.config.prompts.clone();
7616 let cache = self.state.config.cache_dir();
7617 let run = self.state.id.clone();
7618 for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7619 let job = SeatJob {
7620 spec: spec.clone(),
7621 seat,
7622 cwd: winner.worktree.clone(),
7623 prompt: prompt.clone(),
7624 timeout: retry_budget(timeout_full, resumed),
7625 allow_write: false,
7626 sessions,
7627 artifacts: agent::artifacts_dir(&self.state.dir()),
7628 stem: if i == 0 {
7629 "github-text-rewrite".to_owned()
7630 } else {
7631 format!("github-text-rewrite-{}", spec.id)
7632 },
7633 handover: None,
7634 };
7635 let ctx = WaveCtx {
7636 run: &run,
7637 node: "github-text",
7638 prompts: &prompts,
7639 cache: cache.as_deref(),
7640 round: None,
7641 carry_seats: false,
7642 pauses: Some((&self.pause, &self.interrupt)),
7643 };
7644 let (seat, outcome) =
7645 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7646 self.state.seats.insert(seat.key.clone(), seat);
7647 match outcome {
7648 AgentOutcome::Ok(o)
7649 if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7650 {
7651 return Some(o.text);
7652 }
7653 AgentOutcome::ResourceBlocked(_) => return None,
7654 _ => {}
7655 }
7656 }
7657 None
7658 }
7659
7660 fn retire_github_text_question(&mut self, why: &str) {
7662 let Some(id) = self
7663 .state
7664 .github_text
7665 .as_ref()
7666 .and_then(|g| g.question.clone())
7667 else {
7668 return;
7669 };
7670 let _ = ask::Questions::open().update(&id, |q| {
7671 q.abandon(why);
7672 Ok(())
7673 });
7674 }
7675
7676 async fn settle_withheld(
7680 &mut self,
7681 winner: &Candidate,
7682 pr: &PrMessage,
7683 mut g: crate::run::GithubTextAsk,
7684 ) -> Result<Option<PrMessage>> {
7685 use crate::github_text as gt;
7686 let store = ask::Questions::open();
7687 let timeout = self.state.config.graph.answer_timeout;
7688 if !g.resolved {
7689 let mut standing = g
7690 .question
7691 .as_ref()
7692 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7693 if let Some(q) = standing.as_ref()
7694 && q.status.open()
7695 && gt::expired(q, timeout)
7696 {
7697 let why = format!("no answer within {}s of asking", timeout.max(1));
7698 standing = store
7699 .update(&q.id, |q| {
7700 q.abandon(&why);
7701 Ok(())
7702 })
7703 .ok()
7704 .map(|(q, ())| q);
7705 }
7706 let mut ask_again: Option<Option<Vec<String>>> = None;
7707 match standing {
7708 None if g.asks <= 1 => {
7711 g.asks = 0;
7712 ask_again = Some(None);
7713 }
7714 None => {
7715 g.resolved = true;
7716 g.chosen_title = None;
7717 g.choice = Some(GithubTextChoice::Fallback);
7718 }
7719 Some(q) if q.status.open() => {
7720 self.park_on_github_text(&g)?;
7721 return Ok(None);
7722 }
7723 Some(q) => match gt::read_reply(&q) {
7724 gt::Reply::Fallback => {
7725 g.resolved = true;
7726 g.chosen_title = None;
7727 g.choice = Some(GithubTextChoice::Fallback);
7728 }
7729 gt::Reply::Original => {
7730 g.resolved = true;
7731 g.chosen_title = None;
7732 g.choice = Some(GithubTextChoice::Original);
7733 }
7734 gt::Reply::Rewrite => {
7735 g.resolved = true;
7736 g.chosen_title = None;
7737 g.choice = Some(if g.rewrite.is_some() {
7738 GithubTextChoice::Rewrite
7739 } else {
7740 GithubTextChoice::Fallback
7741 });
7742 }
7743 gt::Reply::Title(text) => {
7744 let first = text
7745 .lines()
7746 .map(str::trim)
7747 .find(|l| !l.is_empty())
7748 .unwrap_or("");
7749 let decision = if first.is_empty() {
7750 None
7751 } else {
7752 let candidate = PrMessage {
7753 title: first.to_owned(),
7754 body: String::new(),
7755 };
7756 self.judge_pr_language(&winner.worktree, &candidate).await
7757 };
7758 match gt::vet_title(
7759 &text,
7760 decision,
7761 self.state.config.graph.github_english(),
7762 ) {
7763 Ok(title) => {
7764 g.resolved = true;
7765 g.chosen_title = Some(title);
7766 g.choice = Some(GithubTextChoice::MyTitle);
7767 }
7768 Err(cats) if g.asks < 2 => {
7769 ask_again = Some(Some(
7770 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7771 ))
7772 }
7773 Err(_) => {
7774 g.resolved = true;
7775 g.chosen_title = None;
7776 g.choice = Some(GithubTextChoice::Fallback);
7777 }
7778 }
7779 }
7780 },
7781 }
7782 if let Some(rejected) = ask_again {
7783 let w = gt::Withheld {
7786 title: g.title,
7787 body: g.body,
7788 categories: g.categories.clone(),
7789 };
7790 let artifact = match crate::run::write_artifact(
7793 &self.state,
7794 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
7795 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7796 ) {
7797 Ok(p) => Some(p),
7798 Err(e) => {
7799 tracing::warn!("could not write the withheld-text artifact: {e:#}");
7800 None
7801 }
7802 };
7803 let (detail, comparison) = gt::question_content(
7804 &w,
7805 &pr.title,
7806 &pr.body,
7807 &self.state.id,
7808 rejected.as_deref(),
7809 artifact.as_deref(),
7810 g.rewrite.as_ref(),
7811 );
7812 let mut q = ask::Question::new(
7813 self.state.id.clone(),
7814 gt::ASK_NODE.to_owned(),
7815 gt::ASK_SEAT.to_owned(),
7816 gt::question_summary(&self.state.config.graph.language, &w),
7817 detail,
7818 gt::question_choices(&w, g.rewrite.as_ref()),
7819 );
7820 q.posting_comparison = Some(comparison);
7821 q.answer_timeout = timeout;
7822 g.question = Some(q.id.clone());
7825 g.asks += 1;
7826 self.state.github_text = Some(g.clone());
7827 self.state.event(
7828 "github-text",
7829 format!("asking the owner about the withheld text ({})", q.short()),
7830 );
7831 self.state.save()?;
7832 store
7833 .put(&mut q)
7834 .context("file the posting-gate question")?;
7835 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7836 tracing::warn!(
7837 "could not notify about posting-gate question {}: {e:#}",
7838 q.short()
7839 );
7840 }
7841 self.park_on_github_text(&g)?;
7842 return Ok(None);
7843 }
7844 }
7845 let resolved = g.chosen_title.clone();
7846 let original = g.choice == Some(GithubTextChoice::Original);
7847 self.state.event(
7849 "github-text",
7850 if original {
7851 format!(
7852 "posting the original text for: {}",
7853 match (g.title, g.body) {
7854 (true, true) => "title, description",
7855 (true, false) => "title",
7856 _ => "description",
7857 }
7858 )
7859 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7860 "posting the saved rewrite title and description".to_owned()
7861 } else if resolved.is_some() {
7862 "posting the owner's replacement title".to_owned()
7863 } else {
7864 "posting the neutral text for the withheld fields".to_owned()
7865 },
7866 );
7867 self.state.github_text = Some(g.clone());
7868 self.state.save()?;
7869 if g.choice == Some(GithubTextChoice::Rewrite)
7870 && let Some(r) = g.rewrite.as_ref()
7871 {
7872 return Ok(Some(PrMessage {
7873 title: r.title.clone(),
7874 body: r.body.clone(),
7875 }));
7876 }
7877 let w = gt::Withheld {
7878 title: g.title,
7879 body: g.body,
7880 categories: g.categories.clone(),
7881 };
7882 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7883 if let Some(t) = resolved {
7884 title = t;
7885 }
7886 let english = gt::LanguageDecision {
7888 title_english: true,
7889 body_english: true,
7890 body_complete: true,
7891 };
7892 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7893 if original {
7894 if g.title {
7897 title = pr.title.clone();
7898 }
7899 if g.body {
7900 body = pr.body.clone();
7901 }
7902 }
7903 Ok(Some(PrMessage { title, body }))
7904 }
7905
7906 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7907 self.state.github_text = Some(g.clone());
7908 self.state.end_pass_parked(ParkCause::Question);
7909 self.state.parked = true;
7910 self.state.event(
7911 "github-text",
7912 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7913 );
7914 self.state.save()
7915 }
7916
7917 async fn judge_pr_language(
7920 &mut self,
7921 cwd: &Path,
7922 pr: &PrMessage,
7923 ) -> Option<crate::github_text::LanguageDecision> {
7924 self.state.config.roles.language_judge.as_ref()?;
7925 if !self.state.config.graph.language_gate() {
7926 return None;
7927 }
7928 let decision =
7929 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7930 self.state.event(
7931 "github-text",
7932 if decision.is_some() {
7933 "language decided by the language judge"
7934 } else {
7935 "language judge unavailable; using built-in heuristics"
7936 },
7937 );
7938 decision
7939 }
7940
7941 async fn merge(&mut self) -> Result<()> {
7942 if self
7957 .state
7958 .base_sync
7959 .as_ref()
7960 .is_some_and(|s| s.conflict.is_some())
7961 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7962 != Some(RunStatus::Gating)
7963 || !self.state.gate_status().ok()
7972 {
7973 return Ok(());
7974 }
7975 if self.state.merge.is_some() && !self.state.pr_publication_pending {
7984 return Ok(());
7985 }
7986 let Some(winner) = self.state.winner().cloned() else {
7987 return Ok(());
7988 };
7989 let repo = self.state.repo.clone();
7990 let base = self.state.base_branch.clone();
7991 let mode = self.state.config.merge.mode;
7992 let style = self.state.config.merge.style;
7993 let facts = if is_review_run(&self.state) {
7994 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7995 let start = review_base(
7996 &repo,
7997 &self.state.config.merge.remote,
7998 &base,
7999 &self.state.base_commit,
8000 &winner.branch,
8001 )
8002 .await;
8003 branch_facts(&repo, &start, &winner.branch).await
8004 } else {
8005 None
8006 };
8007 let leaked = if is_review_run(&self.state) {
8010 leaked_subjects(&self.state, &winner.branch).await
8011 } else {
8012 Some(Vec::new())
8013 };
8014 let Some(pr) = self
8018 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
8019 .await?
8020 else {
8021 return Ok(());
8022 };
8023 let message = pr.commit_message();
8024
8025 let outcome = match mode {
8026 MergeMode::None => MergeOutcome {
8027 mode,
8028 ok: true,
8029 detail: manual_merge_command(style, &repo, &winner.branch, &message),
8030 empty: false,
8031 },
8032 MergeMode::Pr | MergeMode::Local
8033 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
8034 {
8035 MergeOutcome {
8036 mode,
8037 ok: false,
8038 detail: empty_candidate_detail(&self.state, &base),
8039 empty: true,
8040 }
8041 }
8042 MergeMode::Local => {
8043 let on = git::current_branch(&repo).await?;
8044 if on.as_deref() != Some(base.as_str()) {
8045 MergeOutcome {
8046 mode,
8047 ok: false,
8048 detail: format!(
8049 "{} has {} checked out, not the base branch {base}",
8050 repo.display(),
8051 on.unwrap_or_else(|| "a detached HEAD".to_owned())
8052 ),
8053 empty: false,
8054 }
8055 } else if !git::is_clean(&repo).await? {
8056 MergeOutcome {
8057 mode,
8058 ok: false,
8059 detail: format!("{} is dirty; refusing to merge", repo.display()),
8060 empty: false,
8061 }
8062 } else {
8063 let out = match style {
8064 MergeStyle::Merge => {
8065 git::merge_no_ff(&repo, &winner.branch, &message).await?
8066 }
8067 MergeStyle::Squash => {
8068 git::merge_squash(&repo, &winner.branch, &message).await?
8069 }
8070 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
8071 };
8072 MergeOutcome {
8073 mode,
8074 ok: out.ok(),
8075 detail: if out.ok() { out.stdout } else { out.stderr },
8076 empty: false,
8077 }
8078 }
8079 }
8080 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
8081 self.state.pr_publication_pending = true;
8082 self.state.save()?;
8083 let out =
8084 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
8085 self.state.pr_publication_pending = out.is_err();
8086 MergeOutcome {
8087 mode,
8088 ok: out.is_ok(),
8089 detail: match out {
8090 Ok(url) => url,
8091 Err(e) => format!("{e:#}"),
8092 },
8093 empty: false,
8094 }
8095 }
8096 MergeMode::Pr => {
8097 let remote = self.state.config.merge.remote.clone();
8098 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
8099 if !pushed.ok() {
8100 MergeOutcome {
8101 mode,
8102 ok: false,
8103 detail: pushed.stderr,
8104 empty: false,
8105 }
8106 } else {
8107 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
8113 let out = match pr_merge_plan(found) {
8114 PrPlan::Create => {
8115 gh_pr_create(
8116 &winner.worktree,
8117 &base,
8118 &winner.branch,
8119 &pr.title,
8120 &pr.body,
8121 )
8122 .await
8123 }
8124 PrPlan::Adopt { url, title } => {
8125 self.state
8126 .event("merge", format!("Pr: adopted open pull request {url}"));
8127 if self.state.github_text.as_ref().is_some_and(|g| {
8128 g.resolved
8129 && g.choice == Some(GithubTextChoice::Rewrite)
8130 && g.rewrite.is_some()
8131 }) {
8132 land::set_pr_text_verbatim(
8135 &winner.worktree,
8136 &url,
8137 &pr.title,
8138 &pr.body,
8139 )
8140 .await
8141 .map(|()| url)
8142 } else {
8143 if title != pr.title
8144 && (!is_review_run(&self.state)
8145 || leaked
8146 .as_deref()
8147 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
8148 && let Err(e) =
8149 if self.state.github_text.as_ref().is_some_and(|g| {
8150 g.resolved
8151 && g.title
8152 && g.choice == Some(GithubTextChoice::Original)
8153 }) {
8154 land::set_pr_title_verbatim(
8156 &winner.worktree,
8157 &url,
8158 &pr.title,
8159 )
8160 .await
8161 } else {
8162 land::set_pr_title(
8163 &mut self.state,
8164 &winner.worktree,
8165 &url,
8166 &pr.title,
8167 )
8168 .await
8169 }
8170 {
8171 tracing::warn!("could not refresh title of {url}: {e:#}");
8172 self.state
8173 .event("merge", format!("Pr: title refresh failed: {e:#}"));
8174 }
8175 Ok(url)
8176 }
8177 }
8178 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8179 };
8180 match out {
8181 Ok(url) => MergeOutcome {
8182 mode,
8183 ok: true,
8184 detail: url,
8185 empty: false,
8186 },
8187 Err(e) => MergeOutcome {
8188 mode,
8189 ok: false,
8190 detail: e.to_string(),
8191 empty: false,
8192 },
8193 }
8194 }
8195 }
8196 };
8197
8198 self.state.status = match (mode, outcome.ok) {
8199 (MergeMode::None, _) => RunStatus::Ready,
8200 (_, true) => RunStatus::Merged,
8201 (_, false) => RunStatus::Blocked,
8202 };
8203 self.state.event(
8204 "merge",
8205 format!(
8206 "{:?}: {}",
8207 mode,
8208 outcome.detail.lines().next().unwrap_or("")
8209 ),
8210 );
8211 self.state.merge = Some(outcome);
8212 self.state.save()?;
8213
8214 if self.state.config.graph.land
8220 && mode == MergeMode::Pr
8221 && self.state.status == RunStatus::Merged
8222 {
8223 self.run_land().await?;
8224 }
8225 self.settle_questions();
8230 Ok(())
8231 }
8232
8233 async fn run_land(&mut self) -> Result<()> {
8244 crate::pr_head::ensure_owner(&self.state)?;
8245 let url = self
8246 .state
8247 .merge
8248 .as_ref()
8249 .map(|m| m.detail.clone())
8250 .unwrap_or_default();
8251 let url = url.lines().next().unwrap_or("").trim().to_owned();
8252 if !url.starts_with("http") {
8253 return Ok(());
8254 }
8255 loop {
8258 match land::land(&mut self.state, &url).await {
8259 Ok(_)
8260 if self
8261 .state
8262 .merge_repairs
8263 .last()
8264 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8265 {
8266 if self.repair_merge_review().await? {
8267 continue;
8268 }
8269 }
8270 Ok(pr) if self.state.parked => {
8271 let _ = pr;
8275 }
8276 Ok(_) if self.state.merge_decision.as_ref().is_some_and(|d| d.closed) => {
8277 self.state.status = RunStatus::Superseded;
8278 self.state.save()?;
8279 }
8280 Ok(pr) => {
8281 self.state.status = match pr.state {
8282 land::PrLifecycle::Merged => RunStatus::Merged,
8283 _ => RunStatus::Blocked,
8284 };
8285 if bump::should_release_bump(self.state.status)
8292 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8293 {
8294 self.state
8300 .event("bump", format!("release bump skipped: {e:#}"));
8301 }
8302 if self.state.status == RunStatus::Merged {
8305 crate::followup::after_merge(&mut self.state, &pr.url).await;
8306 }
8307 self.state.save()?;
8308 }
8309 Err(e) => {
8310 if e.is::<crate::repo_lock::ResourceBlocked>() {
8311 return Err(e);
8312 }
8313 self.state.status = RunStatus::Blocked;
8314 self.state.event("land", format!("gave up: {e}"));
8315 self.state.save()?;
8316 }
8317 }
8318 break;
8319 }
8320 Ok(())
8321 }
8322
8323 async fn repair_merge_review(&mut self) -> Result<bool> {
8326 use crate::run::MergeRepairPhase as Phase;
8327 crate::pr_head::ensure_owner(&self.state)?;
8328 let i = self.state.merge_repairs.len() - 1;
8329 let request = self.state.merge_repairs[i].clone();
8330 if request.phase == Phase::Fixing {
8331 let restored = match self.state.winner().cloned() {
8332 Some(winner) => self.graft_restore(&winner.worktree, &request.head).await,
8333 None => Err(anyhow::anyhow!("repair has no winning branch")),
8334 };
8335 let why = match restored {
8336 Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8337 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
8338 Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8339 };
8340 self.hold_merge_repair(i, why)?;
8341 return Ok(false);
8342 }
8343 let failure = if i >= self.state.config.graph.land_rounds
8344 || self.state.config.graph.review_rounds == 0
8345 {
8346 Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8347 } else {
8348 None
8349 };
8350 if let Some(why) = failure {
8351 self.hold_merge_repair(i, why)?;
8352 return Ok(false);
8353 }
8354 if request.phase == Phase::Requested {
8355 let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8356 Ok(pr) => pr,
8357 Err(e) => {
8358 self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8359 return Ok(false);
8360 }
8361 };
8362 self.state.merge_repairs[i].phase = Phase::Fixing;
8363 self.state.land_approval = None;
8364 self.state.land_armed_head = None;
8365 self.state.save()?;
8366 let evidence = serde_json::to_string_pretty(&request)?;
8369 let reason = format!(
8370 "The owner requested fix findings and re-review, without merging. Address the unresolved Major findings and final rejecting votes in this exact review evidence. Do not merge or approve the PR.\n\n{evidence}"
8371 );
8372 let budget = self.state.config.graph.land_rounds;
8373 let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8374 match result {
8375 Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8376 Ok(land::Fixed::Failed(why)) => {
8377 self.hold_merge_repair(
8378 i,
8379 format!(
8380 "repair failed: {why}; inspect the branch and run an explicit review"
8381 ),
8382 )?;
8383 return Ok(false);
8384 }
8385 Err(e) => {
8386 self.hold_merge_repair(
8387 i,
8388 format!(
8389 "repair failed: {e:#}; inspect the branch and run an explicit review"
8390 ),
8391 )?;
8392 return Ok(false);
8393 }
8394 }
8395 self.begin_merge_repair_review(i)?;
8399 }
8400 self.review_loop().await?;
8401 self.gate().await?;
8402 if self.resource_halt()? || self.state.parked {
8403 return Ok(false);
8404 }
8405 if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8406 != Some(RunStatus::Gating)
8407 || !self.state.gate_status().ok()
8408 {
8409 self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8410 return Ok(false);
8411 }
8412 let winner = self
8415 .state
8416 .winner()
8417 .cloned()
8418 .context("repair has no winning branch")?;
8419 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8420 if self.state.gate_head.as_deref() != Some(after.as_str())
8421 || !git::is_clean(&winner.worktree).await?
8422 {
8423 self.hold_merge_repair(i, "repair branch changed after its recorded gate, or the verified head is unknown; nothing was published. Inspect the branch and run an explicit review before requesting a new merge decision".to_owned())?;
8424 return Ok(false);
8425 }
8426 let expected = self
8427 .state
8428 .pr_pushed_head
8429 .as_deref()
8430 .unwrap_or(&request.head)
8431 .to_owned();
8432 let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8433 let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8436 let refspec = format!("{after}:refs/heads/{public}");
8437 let push = git::git_raw(
8438 &winner.worktree,
8439 &["push", &lease, &self.state.config.merge.remote, &refspec],
8440 )
8441 .await;
8442 match push {
8443 Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8444 Ok(out) => {
8445 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8446 return Ok(false);
8447 }
8448 Err(e) => {
8449 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8450 return Ok(false);
8451 }
8452 }
8453 self.state.merge_repairs[i].phase = Phase::Complete;
8454 self.state.status = RunStatus::Landing;
8455 self.state.event(
8456 "repair",
8457 "new review and gate completed; fresh owner merge decision required",
8458 );
8459 self.state.save()?;
8460 Ok(true)
8461 }
8462
8463 fn merge_repair_artifacts(&self) -> PathBuf {
8464 let artifacts = agent::artifacts_dir(&self.state.dir());
8465 match self
8466 .state
8467 .merge_repairs
8468 .last()
8469 .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8470 {
8471 Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8472 None => artifacts,
8473 }
8474 }
8475
8476 fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8477 self.state.reviews.clear();
8478 self.state.gate.clear();
8479 self.state.gate_ran = false;
8480 self.state.gate_head = None;
8481 self.state.gate_fixes.clear();
8482 self.state.pre_gate.clear();
8483 self.state.pre_gate_commit = None;
8484 self.state.contested_handoff = None;
8485 self.state.land_reruns = None;
8486 self.state.land_approval = None;
8487 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8488 self.state.status = RunStatus::Reviewing;
8489 self.state.save()
8490 }
8491
8492 fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8493 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8494 self.state.merge_repairs[index].failure = Some(why.clone());
8495 self.state.status = RunStatus::Blocked;
8496 self.state.event("repair", why);
8497 self.state.save()
8498 }
8499
8500 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8504 if let Some(existing) = self.state.seats.get(key)
8505 && existing.agent == agent
8506 {
8507 return existing.clone();
8508 }
8509 let fresh = if self.state.seats.contains_key(key) {
8512 handover_seat(key, agent, self.state.next_seat_seed())
8513 } else {
8514 SeatState::new(key, agent, self.state.seed)
8515 };
8516 self.state.seats.insert(key.to_owned(), fresh.clone());
8517 fresh
8518 }
8519
8520 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8526 match self.state.seats.get(key) {
8527 Some(s) if s.agent != spec.id => {
8528 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8529 }
8530 _ => spec,
8531 }
8532 }
8533
8534 fn view(&self, c: &Candidate) -> CandidateView {
8536 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8537 .unwrap_or_default();
8538 let (patch, _) = blind::sanitize_patch(
8539 &format!("candidate {} patch", c.label),
8540 &raw,
8541 &self.state.config.blind,
8542 );
8543 CandidateView {
8544 label: c.label,
8545 branch: c.branch.clone(),
8546 summary: c.summary.clone(),
8547 stat: c.stat.clone(),
8548 patch,
8549 }
8550 }
8551
8552 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8554 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8555 prompt::judge(
8556 "(see above)",
8557 &views,
8558 self.roles.judges.len(),
8559 base_short,
8560 "en",
8561 )
8562 }
8563
8564 fn vote_prompt_full(
8570 &self,
8571 j: usize,
8572 labels: &[char],
8573 language: &str,
8574 candidates: &[Candidate],
8575 base_short: &str,
8576 ) -> String {
8577 let mut text = format!(
8578 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8579 prompt::final_vote(labels, language),
8580 self.state.instruction,
8581 self.candidate_block(candidates, base_short)
8582 );
8583 if let Some(own) = self
8584 .state
8585 .judgements
8586 .get(j)
8587 .filter(|r| !r.ranking.is_empty())
8588 {
8589 let reasons = own
8590 .reasons
8591 .iter()
8592 .map(|(k, v)| format!("- {k}: {v}"))
8593 .collect::<Vec<_>>()
8594 .join("\n");
8595 text.push_str(&format!(
8596 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8597 own.ranking.iter().collect::<String>(),
8598 if reasons.is_empty() {
8599 ""
8600 } else {
8601 ", because:\n"
8602 }
8603 ));
8604 }
8605 if !self.state.deliberation.is_empty() {
8606 text.push_str("\n# What was argued before this vote\n");
8607 for t in self.transcript(&[], j) {
8608 text.push_str(&format!(
8609 "\n## {}{}\n\n{}\n",
8610 t.who,
8611 if t.is_self { " (you)" } else { "" },
8612 t.body.trim()
8613 ));
8614 }
8615 }
8616 text
8617 }
8618
8619 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8626 let mut turns = Vec::new();
8627 for j in &self.state.judgements {
8628 if j.ranking.is_empty() {
8629 continue;
8630 }
8631 let reasons = j
8632 .reasons
8633 .iter()
8634 .map(|(k, v)| format!("- {k}: {v}"))
8635 .collect::<Vec<_>>()
8636 .join("\n");
8637 turns.push(Turn {
8638 who: format!("Judge {} (opening ranking)", j.judge),
8639 is_self: j.judge == self_idx + 1,
8640 body: format!(
8641 "Ranked {}{}{reasons}",
8642 j.ranking.iter().collect::<String>(),
8643 if reasons.is_empty() {
8644 ""
8645 } else {
8646 ", because:\n"
8647 }
8648 ),
8649 });
8650 }
8651 for t in self
8652 .state
8653 .deliberation
8654 .iter()
8655 .flat_map(|r| r.turns.iter())
8656 .chain(current)
8657 {
8658 turns.push(Turn {
8659 who: format!("Judge {}", t.judge),
8660 is_self: t.judge == self_idx + 1,
8661 body: t.body.clone(),
8662 });
8663 }
8664 turns
8665 }
8666}
8667
8668fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8670 agent::has_session(spec.kind, seat, sessions)
8671}
8672
8673fn next_untried_in_roster<'a>(
8694 roster: &'a [AgentSpec],
8695 start: usize,
8696 tried: &BTreeSet<String>,
8697) -> Option<&'a AgentSpec> {
8698 roster
8699 .get(start + 1..)?
8700 .iter()
8701 .find(|s| !tried.contains(&s.id))
8702}
8703
8704fn pick_successor<'a>(
8725 roster: &'a [AgentSpec],
8726 start: usize,
8727 tried: &BTreeSet<String>,
8728 carried: Option<&BTreeSet<String>>,
8729 others: &BTreeSet<String>,
8730) -> Option<&'a AgentSpec> {
8731 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8732 roster
8733 .get(start + 1..)
8734 .and_then(|tail| {
8735 tail.iter()
8736 .filter(free)
8737 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8738 })
8739 .or_else(|| {
8740 carried
8741 .is_some()
8742 .then(|| roster.iter().find(free))
8743 .flatten()
8744 })
8745 .or_else(|| match carried {
8746 Some(c) => next_for_seat(roster, start, tried, c),
8747 None => next_untried_in_roster(roster, start, tried),
8748 })
8749}
8750
8751fn next_for_seat<'a>(
8761 roster: &'a [AgentSpec],
8762 start: usize,
8763 round_tried: &BTreeSet<String>,
8764 carried_failed: &BTreeSet<String>,
8765) -> Option<&'a AgentSpec> {
8766 roster
8767 .get(start + 1..)?
8768 .iter()
8769 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8770 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8771}
8772
8773fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8779 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8780 return spec;
8781 };
8782 let ok = |id: &str| !h.failed.contains(id);
8783 if let Some(last) = h.last_ok.as_deref()
8784 && ok(last)
8785 && let Some(s) = roster.iter().find(|s| s.id == last)
8786 {
8787 return s.clone();
8788 }
8789 if ok(&spec.id) {
8790 return spec;
8791 }
8792 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8793 roster
8794 .get(from + 1..)
8795 .into_iter()
8796 .flatten()
8797 .chain(roster.iter())
8798 .find(|s| ok(&s.id))
8799 .cloned()
8800 .unwrap_or(spec)
8801}
8802
8803pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8807 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8808}
8809
8810const TIMED_OUT: &str = "timed out";
8813
8814impl FailClass {
8815 fn of(out: &AgentOutcome) -> Option<Self> {
8817 match out {
8818 AgentOutcome::Ok(_) => None,
8819 AgentOutcome::Quota(_) => Some(Self::Quota),
8820 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8821 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8822 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8823 AgentOutcome::ResourceBlocked(_) => None,
8826 }
8827 }
8828
8829 fn stem_word(&self) -> &'static str {
8831 match self {
8832 Self::Quota => "quota",
8833 _ => "handover",
8834 }
8835 }
8836}
8837
8838fn failure_signature(msg: &str) -> String {
8842 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8843 let mut out = Vec::new();
8844 for word in line.split_whitespace() {
8845 if word.contains('/') || word.contains('\\') {
8846 out.push("<path>".to_owned());
8847 continue;
8848 }
8849 let mut w = String::new();
8850 let mut in_digits = false;
8851 for c in word.chars() {
8852 if c.is_ascii_digit() {
8853 if !in_digits {
8854 w.push('#');
8855 }
8856 in_digits = true;
8857 } else {
8858 in_digits = false;
8859 w.push(c);
8860 }
8861 }
8862 out.push(w);
8863 }
8864 out.join(" ").chars().take(120).collect()
8865}
8866
8867fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8874 match cur {
8875 FailClass::Quota | FailClass::Timeout => true,
8876 FailClass::Other(_) => prev != Some(cur),
8877 }
8878}
8879
8880fn fail_reason(out: &AgentOutcome) -> String {
8882 match out {
8883 AgentOutcome::Ok(_) => String::new(),
8884 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8885 AgentOutcome::Dropped(o) => format!(
8886 "the CLI dropped the stream ({})",
8887 o.dropped
8888 .as_ref()
8889 .map(|d| d.why.as_str())
8890 .unwrap_or("it ended without delivering its answer")
8891 ),
8892 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8893 e.lines().next().unwrap_or("").chars().take(160).collect()
8894 }
8895 }
8896}
8897
8898pub(crate) fn record_handover(
8901 state: &mut RunState,
8902 node: &str,
8903 seat: &str,
8904 from: &str,
8905 to: &str,
8906 class: &FailClass,
8907 reason: &str,
8908) {
8909 let message = if *class == FailClass::Quota {
8910 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8911 } else {
8912 format!("{seat}: handed over {from} -> {to} ({reason})")
8913 };
8914 state.event(node, message);
8915 state.handovers.push(Handover {
8916 at: Timestamp::now(),
8917 node: node.to_owned(),
8918 seat: seat.to_owned(),
8919 from: from.to_owned(),
8920 to: to.to_owned(),
8921 reason: reason.to_owned(),
8922 });
8923}
8924
8925fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8939 commands.iter().any(|c| c.exit_code.is_none())
8940}
8941
8942fn verified_noop_claim(
8955 usable: bool,
8956 commands: &[agent::CommandEvidence],
8957 text: &str,
8958) -> Option<String> {
8959 (usable && !has_unconfirmed_command(commands))
8960 .then(|| verdict::verified_noop(text))
8961 .flatten()
8962}
8963
8964fn short(commit: &str) -> String {
8965 commit.chars().take(7).collect()
8966}
8967
8968fn make_executable(path: &Path) -> Result<()> {
8969 #[cfg(unix)]
8970 {
8971 use std::os::unix::fs::PermissionsExt as _;
8972 let mut perms = std::fs::metadata(path)?.permissions();
8973 perms.set_mode(0o755);
8974 std::fs::set_permissions(path, perms)?;
8975 }
8976 #[cfg(not(unix))]
8977 {
8978 let _ = path;
8979 }
8980 Ok(())
8981}
8982
8983struct WaveCtx<'a> {
8990 run: &'a str,
8993 node: &'a str,
8995 prompts: &'a Prompts,
8996 cache: Option<&'a Path>,
8998 round: Option<usize>,
9001 carry_seats: bool,
9005 pauses: Option<(&'a Pause, &'a Pause)>,
9008}
9009
9010fn repository_wait(
9012 state: &RunState,
9013 pauses: Option<(&Pause, &Pause)>,
9014) -> crate::repo_lock::WaitPolicy {
9015 let pauses = pauses.map(|(a, b)| (a.clone(), b.clone()));
9016 let inherited = crate::repo_lock::WaitPolicy::current();
9017 crate::repo_lock::WaitPolicy::new(
9018 Duration::from_secs(state.config.graph.timeout_resource_wait),
9019 move || {
9020 inherited.cancelled()
9021 || pauses
9022 .as_ref()
9023 .is_some_and(|(a, b)| a.parked() || b.parked())
9024 },
9025 )
9026}
9027
9028fn record_repository_block(state: &mut RunState, error: &anyhow::Error) {
9029 if error.is::<crate::repo_lock::ResourceBlocked>() {
9030 state.resource_block = Some(crate::run::ResourceBlock {
9031 node: "repository".into(),
9032 kind: "repository coordination".into(),
9033 detail: error.to_string(),
9034 at: Timestamp::now(),
9035 });
9036 state.event("resource", error.to_string());
9037 }
9038}
9039
9040async fn coordinate_operation(
9041 state: &mut RunState,
9042 cwd: &Path,
9043 wait: &crate::repo_lock::WaitPolicy,
9044) -> Result<Option<crate::repo_lock::SeatLease>> {
9045 let result =
9046 crate::repo_lock::SeatLease::operation_with(cwd, state.config.coordination.as_ref(), wait)
9047 .await;
9048 if let Err(e) = &result {
9049 record_repository_block(state, e);
9050 }
9051 result
9052}
9053
9054async fn run_one(
9056 job: SeatJob,
9057 sem: Arc<Semaphore>,
9058 ctx: &WaveCtx<'_>,
9059 state: &mut RunState,
9060 attempt: usize,
9061) -> (SeatState, AgentOutcome) {
9062 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
9063 .await
9064 .pop()
9065 .expect("one job in, one result out");
9066 (seat, out)
9067}
9068
9069async fn wave(
9075 jobs: Vec<SeatJob>,
9076 sem: Arc<Semaphore>,
9077 ctx: &WaveCtx<'_>,
9078 state: &mut RunState,
9079 attempt: usize,
9080) -> Vec<(usize, SeatState, AgentOutcome)> {
9081 let WaveCtx {
9082 run,
9083 node,
9084 prompts,
9085 cache,
9086 round,
9087 carry_seats: _,
9088 pauses,
9089 } = *ctx;
9090 for job in &jobs {
9091 state.seat_started(node, &job.seat.key, job.timeout, attempt);
9092 }
9093 if let Err(e) = state.save() {
9094 tracing::warn!("could not persist in-progress seats: {e:#}");
9099 }
9100 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
9116 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
9120 let cache_guard = if let Some(cache_dir) = cache {
9121 if jobs_had_a_writer {
9122 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
9123 let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
9124 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
9125 {
9126 Ok(Some(g)) => Some(g),
9127 Ok(None) => {
9130 state.resource_block = Some(crate::run::ResourceBlock {
9131 node: node.to_owned(),
9132 kind: "shared build cache".to_owned(),
9133 detail: "the run is parking".to_owned(),
9134 at: Timestamp::now(),
9135 });
9136 return block_wave(
9137 jobs,
9138 state,
9139 node,
9140 attempt,
9141 "parked while waiting for the shared build cache",
9142 );
9143 }
9144 Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
9147 Err(e) => {
9148 let why = format!("{e:#}");
9152 state.resource_block = Some(crate::run::ResourceBlock {
9153 node: node.to_owned(),
9154 kind: "shared build cache".to_owned(),
9155 detail: why.clone(),
9156 at: Timestamp::now(),
9157 });
9158 state.event(
9159 "resource",
9160 format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
9161 );
9162 return block_wave(jobs, state, node, attempt, &why);
9163 }
9164 }
9165 } else {
9166 None
9167 }
9168 } else {
9169 None
9170 };
9171 state.seats_spawning(node, attempt);
9174 if let Err(e) = state.save() {
9175 tracing::warn!("could not persist the start of a wave: {e:#}");
9176 }
9177 let slot_limit = resource_limit
9181 + jobs
9182 .iter()
9183 .map(|j| j.timeout)
9184 .max()
9185 .unwrap_or_default()
9186 .saturating_mul(jobs.len() as u32);
9187 let mut set = tokio::task::JoinSet::new();
9188 let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
9191 let overlay = prompts.overlay(node);
9192 let repository_wait = repository_wait(state, pauses);
9193 for (i, mut job) in jobs.into_iter().enumerate() {
9194 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
9195 if cache.is_some() {
9196 job.prompt.push('\n');
9197 job.prompt
9198 .push_str(&prompt::build_cache_note(node, job.allow_write));
9199 }
9200 let sem = Arc::clone(&sem);
9201 let run = run.to_owned();
9202 let node = node.to_owned();
9203 let attachments = if node == "implement" {
9206 state.attachments.clone()
9207 } else {
9208 Vec::new()
9209 };
9210 let cache = cache
9221 .filter(|_| job.allow_write && cache_guard.is_some())
9222 .map(Path::to_path_buf);
9223 let spawned_tx = spawned_tx.clone();
9224 let repository_wait = repository_wait.clone();
9225 let coordination = state.config.coordination.clone();
9226 set.spawn(async move {
9227 let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9231 Ok(p) => p,
9232 Err(_) => {
9233 return (
9234 i,
9235 job.seat,
9236 AgentOutcome::ResourceBlocked(format!(
9237 "no parallel slot became free within {}s",
9238 slot_limit.as_secs()
9239 )),
9240 );
9241 }
9242 };
9243 spawned_tx.send(job.seat.key.clone()).ok();
9244 let mut seat = job.seat;
9245 let out = repository_wait
9246 .scope(agent::invoke(
9247 &job.spec,
9248 &mut seat,
9249 &Invocation {
9250 coordination: coordination.as_ref(),
9251 cwd: &job.cwd,
9252 prompt: &job.prompt,
9253 timeout: job.timeout,
9254 allow_write: job.allow_write,
9255 unsandboxed: false,
9256 sessions: job.sessions,
9257 artifacts: &job.artifacts,
9258 stem: &job.stem,
9259 run: &run,
9260 node: &node,
9261 cache_dir: cache.as_deref(),
9262 attachments: &attachments,
9263 writable: &[],
9264 },
9265 ))
9266 .await;
9267 let out = match out {
9268 Ok(o) if o.usable() => AgentOutcome::Ok(o),
9269 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9270 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9278 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9279 Ok(o) => AgentOutcome::Failed(format!(
9280 "exited with {:?} and no usable output",
9281 o.exit_code
9282 )),
9283 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
9284 AgentOutcome::ResourceBlocked(e.to_string())
9285 }
9286 Err(e) => AgentOutcome::Failed(e.to_string()),
9287 };
9288 (i, seat, out)
9289 });
9290 }
9291 drop(spawned_tx);
9292 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9293 loop {
9294 let joined = tokio::select! {
9295 Some(key) = spawned_rx.recv() => {
9296 state.seat_spawned(&key);
9298 if let Err(e) = state.save() {
9299 tracing::warn!("could not persist a seat's start: {e:#}");
9300 }
9301 continue;
9302 }
9303 joined = set.join_next() => joined,
9304 };
9305 let Some(joined) = joined else {
9306 break;
9307 };
9308 let (i, seat, out) = match joined {
9309 Ok(v) => v,
9310 Err(e) => {
9314 tracing::error!("agent task panicked: {e}");
9315 continue;
9316 }
9317 };
9318 state.seat_finished(&seat.key);
9319 if let AgentOutcome::ResourceBlocked(why) = &out
9320 && state.resource_block.is_none()
9321 {
9322 state.resource_block = Some(crate::run::ResourceBlock {
9323 node: node.to_owned(),
9324 kind: if why.contains("repository coordination") {
9325 "repository coordination"
9326 } else {
9327 "parallel slot"
9328 }
9329 .to_owned(),
9330 detail: why.clone(),
9331 at: Timestamp::now(),
9332 });
9333 state.event("resource", format!("{node}: no agent was started - {why}"));
9334 }
9335 record_jobs(state, node, round, &seat.key, &out);
9336 if let Err(e) = state.save() {
9337 tracing::warn!("could not persist a seat's completion: {e:#}");
9338 }
9339 if collected.len() <= i {
9340 collected.resize_with(i + 1, || None);
9341 }
9342 collected[i] = Some((i, seat, out));
9343 }
9344 if state
9350 .active
9351 .values()
9352 .any(|a| a.node == node && a.attempt == attempt)
9353 {
9354 state
9355 .active
9356 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9357 if let Err(e) = state.save() {
9358 tracing::warn!("could not persist the end of a wave: {e:#}");
9359 }
9360 }
9361 if let Some(cache_dir) = cache
9368 && jobs_had_a_writer
9369 {
9370 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9371 }
9372 if let Some(guard) = cache_guard {
9373 guard.release();
9374 }
9375 collected.into_iter().flatten().collect()
9376}
9377
9378fn record_jobs(
9389 state: &mut RunState,
9390 node: &str,
9391 round: Option<usize>,
9392 seat: &str,
9393 out: &AgentOutcome,
9394) {
9395 let commands: &[agent::CommandEvidence] = match out {
9396 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9397 AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9398 };
9399 let checked_at = Timestamp::now();
9400 for c in commands {
9401 state.jobs.push(JobRecord {
9402 node: node.to_owned(),
9403 round,
9404 seat: seat.to_owned(),
9405 id: c.id.clone(),
9406 description: c.description.clone(),
9407 checked_at,
9408 status: match c.exit_code {
9409 Some(0) => JobStatus::Completed,
9410 Some(_) => JobStatus::Failed,
9411 None => JobStatus::Unknown,
9412 },
9413 exit_code: c.exit_code,
9414 result_summary: c.result_summary.clone(),
9415 source: c.source.clone(),
9416 });
9417 }
9418}
9419
9420fn round_is_clean(
9441 blocking: usize,
9442 e2e_ok: bool,
9443 answered: usize,
9444 expected: usize,
9445 quota_missing: usize,
9446 policy: IncompleteReviewPolicy,
9447) -> bool {
9448 if blocking != 0 || !e2e_ok {
9449 return false;
9450 }
9451 if answered == expected || policy == IncompleteReviewPolicy::Warn {
9452 return true;
9453 }
9454 answered > 0 && expected - answered <= quota_missing
9455}
9456
9457fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9481 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9482 return Some(RunStatus::Gating);
9483 }
9484 let last = reviews.last()?;
9485 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9486 if reviews.len() < max_rounds && !stagnant {
9487 return None;
9488 }
9489 if last.incomplete() && last.blocking == 0 {
9490 return Some(RunStatus::Blocked);
9491 }
9492 if last.e2e_status() == E2eStatus::ResourceBlocked {
9493 return None;
9494 }
9495 Some(if last.e2e.iter().all(CommandOutcome::ok) {
9496 RunStatus::Gating
9497 } else {
9498 RunStatus::Blocked
9499 })
9500}
9501
9502pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9517 if nudged {
9518 (full / 4).max(Duration::from_secs(120)).min(full)
9519 } else {
9520 full
9521 }
9522}
9523
9524#[allow(clippy::too_many_arguments)]
9553async fn ask_json_wave<T>(
9554 jobs: Vec<SeatJob>,
9555 sem: Arc<Semaphore>,
9556 retries: usize,
9557 roster: &[AgentSpec],
9558 ctx: &WaveCtx<'_>,
9559 losses: &mut Vec<QuotaLoss>,
9560 state: &mut RunState,
9561 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9562) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9563where
9564 T: serde::de::DeserializeOwned + Send + 'static,
9565{
9566 ask_wave_with(
9567 jobs,
9568 sem,
9569 retries,
9570 roster,
9571 ctx,
9572 losses,
9573 state,
9574 &|text: &str| {
9575 let v = verdict::extract_json::<T>(text)?;
9576 validate(&v)?;
9577 Ok(v)
9578 },
9579 )
9580 .await
9581}
9582
9583#[allow(clippy::too_many_arguments)]
9592async fn ask_wave_with<T>(
9593 jobs: Vec<SeatJob>,
9594 sem: Arc<Semaphore>,
9595 retries: usize,
9596 roster: &[AgentSpec],
9597 ctx: &WaveCtx<'_>,
9598 losses: &mut Vec<QuotaLoss>,
9599 state: &mut RunState,
9600 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9601) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9602where
9603 T: Send + 'static,
9604{
9605 let n = jobs.len();
9606 let originals: Vec<SeatJob> = jobs;
9607 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9608 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9609 let mut nudges: Vec<usize> = vec![0; n];
9616 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9620 let mut tried: Vec<BTreeSet<String>> = specs
9621 .iter()
9622 .map(|s| BTreeSet::from([s.id.clone()]))
9623 .collect();
9624 let starts: Vec<usize> = specs
9625 .iter()
9626 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9627 .collect();
9628 let carry = ctx.carry_seats && !roster.is_empty();
9629 let carried: Vec<BTreeSet<String>> = originals
9632 .iter()
9633 .map(|j| {
9634 state
9635 .seat_history
9636 .get(&j.seat.key)
9637 .filter(|_| carry)
9638 .map(|h| h.failed.clone())
9639 .unwrap_or_default()
9640 })
9641 .collect();
9642 let mut prev: Vec<Option<FailClass>> = originals
9643 .iter()
9644 .map(|j| {
9645 state
9646 .seat_history
9647 .get(&j.seat.key)
9648 .filter(|_| carry)
9649 .and_then(|h| h.last_fail.clone())
9650 })
9651 .collect();
9652 let next_agent =
9653 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9654 let others: BTreeSet<String> = specs
9655 .iter()
9656 .enumerate()
9657 .filter(|(j, _)| *j != i)
9658 .map(|(_, s)| s.id.clone())
9659 .collect();
9660 pick_successor(
9661 roster,
9662 starts[i],
9663 tried,
9664 carry.then(|| &carried[i]),
9665 &others,
9666 )
9667 .cloned()
9668 };
9669 let mut fresh: Vec<Option<String>> = vec![None; n];
9670 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9671 let mut pending: Vec<usize> = (0..n).collect();
9672
9673 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9678 for round in 0..max_rounds {
9679 if pending.is_empty() {
9680 break;
9681 }
9682 let mut batch = Vec::with_capacity(pending.len());
9683 let mut renudged: Vec<&str> = Vec::new();
9684 for &i in &pending {
9685 let src = &originals[i];
9686 let full: &str = match &src.handover {
9691 Some(h) if specs[i].id != src.spec.id => h,
9692 _ => &src.prompt,
9693 };
9694 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9697 (
9698 full.to_owned(),
9699 src.timeout,
9700 format!("{}-{word}-{}", src.stem, specs[i].id),
9701 )
9702 } else if nudges[i] == 0 {
9703 (full.to_owned(), src.timeout, src.stem.clone())
9704 } else {
9705 renudged.push(src.seat.key.as_str());
9706 let why = done[i]
9707 .as_ref()
9708 .and_then(|r| r.as_ref().err().map(ToString::to_string))
9709 .unwrap_or_else(|| "no parsable answer".to_owned());
9710 let nudge = prompt::nudge(&why);
9711 let nudged = has_context(&specs[i], &seats[i], src.sessions);
9712 let prompt = if nudged {
9713 nudge
9714 } else {
9715 format!("{full}\n\n---\n\n{nudge}")
9716 };
9717 (
9718 prompt,
9719 retry_budget(src.timeout, nudged),
9720 format!("{}-retry{}", src.stem, nudges[i]),
9721 )
9722 };
9723 batch.push(SeatJob {
9724 spec: specs[i].clone(),
9725 seat: seats[i].clone(),
9726 cwd: src.cwd.clone(),
9727 prompt,
9728 timeout,
9729 allow_write: src.allow_write,
9730 sessions: src.sessions,
9731 artifacts: src.artifacts.clone(),
9732 stem,
9733 handover: None,
9734 });
9735 }
9736
9737 if !renudged.is_empty() {
9738 state.event(
9739 ctx.node,
9740 format!("retry {round}: re-asking {}", renudged.join(", ")),
9741 );
9742 }
9743 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9744 let mut still = Vec::new();
9745 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9746 seats[i] = seat;
9747 if let AgentOutcome::ResourceBlocked(why) = &out {
9750 done[i] = Some(Err(anyhow::anyhow!("{why}")));
9751 continue;
9752 }
9753 let class = FailClass::of(&out);
9754 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9757 && nudges[i] < retries
9758 && !roster.is_empty();
9759 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9760 let next = should_hand_over(prev[i].as_ref(), &cur)
9761 .then(|| next_agent(i, &tried[i], &specs))
9762 .flatten();
9763 if carry {
9764 let h = state
9765 .seat_history
9766 .entry(originals[i].seat.key.clone())
9767 .or_default();
9768 h.failed.insert(specs[i].id.clone());
9769 h.last_fail = Some(cur.clone());
9770 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9771 h.last_ok = None;
9772 }
9773 if let Err(e) = state.save() {
9776 tracing::warn!("could not persist a seat's failure history: {e:#}");
9777 }
9778 }
9779 if let Some(next) = next {
9780 record_handover(
9781 state,
9782 ctx.node,
9783 &originals[i].seat.key,
9784 &specs[i].id,
9785 &next.id,
9786 &cur,
9787 &fail_reason(&out),
9788 );
9789 tried[i].insert(next.id.clone());
9790 prev[i] = Some(cur.clone());
9791 seats[i] =
9792 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9793 specs[i] = next;
9794 fresh[i] = Some(cur.stem_word().to_owned());
9795 nudges[i] = 0;
9796 done[i] = Some(Err(anyhow::anyhow!(
9797 "handed over after: {}",
9798 fail_reason(&out)
9799 )));
9800 still.push(i);
9801 continue;
9802 }
9803 }
9804 if carry
9805 && !nudge_first
9806 && let Some(cur) = class.clone()
9807 {
9808 let h = state
9810 .seat_history
9811 .entry(originals[i].seat.key.clone())
9812 .or_default();
9813 h.failed.insert(specs[i].id.clone());
9814 h.last_fail = Some(cur);
9815 }
9816 let parsed = match out {
9817 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9818 AgentOutcome::Quota(o) => {
9819 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9820 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9821 }
9822 AgentOutcome::Dropped(o) => {
9826 let why = o
9827 .dropped
9828 .as_ref()
9829 .map(|d| d.why.as_str())
9830 .unwrap_or("the CLI ended the stream without delivering its answer");
9831 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9832 }
9833 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9834 Err(anyhow::anyhow!(e))
9835 }
9836 };
9837 let quota = class == Some(FailClass::Quota);
9838 let failed = parsed.is_err();
9839 done[i] = Some(parsed);
9840 if carry && !failed {
9841 let h = state
9842 .seat_history
9843 .entry(originals[i].seat.key.clone())
9844 .or_default();
9845 h.failed.remove(&specs[i].id);
9846 h.last_ok = Some(specs[i].id.clone());
9847 h.last_fail = None;
9848 }
9849 let agent_failure = class.is_some()
9857 && !nudge_first
9858 && !roster.is_empty()
9859 && next_agent(i, &tried[i], &specs).is_some();
9860 if failed && !quota && !agent_failure && nudges[i] < retries {
9861 nudges[i] += 1;
9862 still.push(i);
9863 }
9864 }
9865 pending = still;
9866 }
9867
9868 for (i, q) in last_quota.into_iter().enumerate() {
9871 if let Some(reset) = q {
9872 losses.push(QuotaLoss {
9873 seat: originals[i].seat.key.clone(),
9874 node: ctx.node.to_owned(),
9875 at: Timestamp::now(),
9876 reset,
9877 });
9878 }
9879 }
9880
9881 seats
9882 .into_iter()
9883 .zip(done)
9884 .zip(nudges)
9885 .map(|((seat, res), attempts)| {
9886 (
9887 seat,
9888 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9889 attempts,
9890 )
9891 })
9892 .collect()
9893}
9894
9895fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9897 results
9898 .iter()
9899 .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9900}
9901
9902fn block_wave(
9906 jobs: Vec<SeatJob>,
9907 state: &mut RunState,
9908 node: &str,
9909 attempt: usize,
9910 why: &str,
9911) -> Vec<(usize, SeatState, AgentOutcome)> {
9912 state.resource_wait = None;
9913 state
9914 .active
9915 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9916 if let Err(e) = state.save() {
9917 tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9918 }
9919 jobs.into_iter()
9920 .enumerate()
9921 .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9922 .collect()
9923}
9924
9925async fn acquire_cache_lease(
9938 state: &mut RunState,
9939 cache_dir: &Path,
9940 owner: &crate::cache::Owner,
9941 budget: Duration,
9942 context: &str,
9943 cancel: &(dyn Fn() -> bool + Sync),
9944) -> Result<Option<crate::cache::Guard>> {
9945 let home = crate::run::home();
9946 let started = Instant::now();
9947 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9948 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9949 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9950 Err(e) => {
9951 state.event(
9952 "verify",
9953 format!("{context}: could not check the shared build cache: {e:#}"),
9954 );
9955 if let Err(e2) = state.save() {
9956 tracing::warn!("could not persist a cache-check failure: {e2:#}");
9957 }
9958 return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9959 }
9960 };
9961 state.event(
9962 "verify",
9963 format!(
9964 "{context}: waiting for the shared build cache at {} ({})",
9965 cache_dir.display(),
9966 busy.describe()
9967 ),
9968 );
9969 state.resource_wait = Some(crate::run::ResourceWait {
9972 node: context.to_owned(),
9973 kind: "shared build cache".to_owned(),
9974 holder: busy.describe(),
9975 since: Timestamp::now(),
9976 limit_secs: budget.as_secs(),
9977 });
9978 if let Err(e) = state.save() {
9979 tracing::warn!("could not persist a cache wait: {e:#}");
9980 }
9981 let remaining = budget.saturating_sub(started.elapsed());
9982 let waited = crate::cache::wait_for_unless(
9983 &home,
9984 cache_dir,
9985 owner,
9986 remaining,
9987 Duration::from_secs(5),
9988 cancel,
9989 )
9990 .await;
9991 state.resource_wait = None;
9992 match waited {
9993 Ok(g) => Ok(g),
9994 Err(e) => {
9995 state.event("verify", format!("{context}: {e:#}"));
9996 if let Err(e2) = state.save() {
9997 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9998 }
9999 Err(e)
10000 }
10001 }
10002}
10003
10004#[derive(Debug)]
10009struct CacheCheckError(String);
10010
10011impl std::fmt::Display for CacheCheckError {
10012 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10013 f.write_str(&self.0)
10014 }
10015}
10016
10017impl std::error::Error for CacheCheckError {}
10018
10019#[allow(clippy::too_many_arguments)]
10040async fn with_cache_lease<'s, F, Fut>(
10041 state: &'s mut RunState,
10042 cache_dir: Option<&Path>,
10043 node: &str,
10044 seat: &str,
10045 worktree: &Path,
10046 head: &str,
10047 budget: Duration,
10048 context: &str,
10049 wait: &crate::repo_lock::WaitPolicy,
10050 body: F,
10051) -> Result<(Vec<CommandOutcome>, bool)>
10052where
10053 F: FnOnce(&'s mut RunState, Duration) -> Fut,
10054 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
10055{
10056 let started = Instant::now();
10059 let home = crate::run::home();
10060 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
10061 let guard = if let Some(cache_dir) = cache_dir {
10062 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
10063 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, context, &|| {
10064 wait.cancelled()
10065 })
10066 .await
10067 .and_then(|g| {
10068 g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
10069 }) {
10070 Ok(g) => Some(g),
10071 Err(e) => {
10072 return Ok((
10073 vec![CommandOutcome {
10074 command: "(waiting for the shared build cache)".into(),
10075 code: None,
10076 output_tail: e.to_string(),
10077 duration_ms: started.elapsed().as_millis() as u64,
10078 resource_blocked: true,
10079 }],
10080 false,
10081 ));
10082 }
10083 }
10084 } else {
10085 None
10086 };
10087 let _coordination = coordinate_operation(state, worktree, wait).await?;
10088 if let Some(cache_dir) = cache_dir {
10089 let identity = crate::cache::Identity::new(worktree, head);
10090 if let Err(e) = crate::cache::ensure_fresh(
10091 &home,
10092 cache_dir,
10093 &identity,
10094 &state.config.verify.cargo_manifests(),
10095 ) {
10096 state.event(
10097 "verify",
10098 format!(
10099 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
10100 worktree.display(),
10101 short(head)
10102 ),
10103 );
10104 if let Some(guard) = guard {
10105 guard.release();
10106 }
10107 return Ok((
10108 vec![CommandOutcome {
10109 command: "(confirming the shared build cache is fresh)".into(),
10110 code: None,
10111 output_tail: e.to_string(),
10112 duration_ms: started.elapsed().as_millis() as u64,
10113 resource_blocked: true,
10114 }],
10115 false,
10116 ));
10117 }
10118 }
10119 let (outcomes, retried, timed_out_pids) = body(state, budget).await;
10120 if !timed_out_pids.is_empty() {
10121 wait_for_timed_out_children_to_die(&timed_out_pids).await;
10122 }
10123 drop(_coordination);
10126 if let Some(guard) = guard {
10127 guard.release();
10128 }
10129 Ok((outcomes, retried))
10130}
10131
10132async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
10144 wait_for_pids_with(
10145 pids,
10146 crate::proc::pid_alive,
10147 LEASE_RELEASE_POLL,
10148 LEASE_RELEASE_MAX_WAIT,
10149 )
10150 .await;
10151}
10152
10153async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10159 pids: &[u32],
10160 alive: F,
10161 poll: Duration,
10162 max_wait: Duration,
10163) {
10164 let deadline = Instant::now() + max_wait;
10165 loop {
10166 if pids.iter().all(|&pid| !alive(pid)) {
10167 return;
10168 }
10169 if Instant::now() >= deadline {
10170 return;
10171 }
10172 tokio::time::sleep(poll).await;
10173 }
10174}
10175
10176fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10183 outcomes.iter().any(|o| o.resource_blocked)
10184}
10185
10186enum GateFix {
10188 Retry,
10190 Stop,
10193 Defer,
10196}
10197
10198fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10206 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10207 red.peek().is_some()
10208 && red.all(|o| {
10209 !o.resource_blocked
10210 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10211 && !o.output_tail.trim().is_empty()
10212 })
10213}
10214
10215fn e2e_outcome_label(o: &CommandOutcome) -> String {
10219 if o.ok() {
10220 return "pass".to_owned();
10221 }
10222 let reason = if o.build_failed() {
10223 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10224 } else {
10225 format!("FAIL ({:?})", o.code)
10226 };
10227 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10228}
10229
10230async fn run_e2e_with_retry(
10238 state: &mut RunState,
10239 shell: &[String],
10240 commands: &[String],
10241 worktree: &Path,
10242 timeout: Duration,
10243 context: &str,
10244) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10245 let (mut e2e, mut timed_out_pids) = run_commands(
10246 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10247 )
10248 .await;
10249 for o in &e2e {
10250 state.event(
10251 "verify",
10252 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10253 );
10254 }
10255 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10259 if verify_retried {
10260 state.event(
10261 "verify",
10262 format!(
10263 "{context}: verify could not build/link, not a test result — retrying once \
10264 before concluding"
10265 ),
10266 );
10267 let retried = run_commands(
10268 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10269 )
10270 .await;
10271 e2e = retried.0;
10272 timed_out_pids.extend(retried.1);
10275 for o in &e2e {
10276 state.event(
10277 "verify",
10278 format!(
10279 "{context}: retry `{}` -> {}",
10280 o.command,
10281 e2e_outcome_label(o)
10282 ),
10283 );
10284 }
10285 }
10286 (e2e, verify_retried, timed_out_pids)
10287}
10288
10289#[allow(clippy::too_many_arguments)]
10305async fn run_commands(
10306 state: &mut RunState,
10307 node: &str,
10308 task: &str,
10309 attempt: usize,
10310 shell: &[String],
10311 commands: &[String],
10312 cwd: &Path,
10313 timeout: Duration,
10314) -> (Vec<CommandOutcome>, Vec<u32>) {
10315 if commands.is_empty() {
10316 return (Vec::new(), Vec::new());
10321 }
10322 let mut out = Vec::new();
10323 let mut timed_out_pids = Vec::new();
10324 let total = commands.len();
10325 for (idx, command) in commands.iter().enumerate() {
10326 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10327 if let Err(e) = state.save() {
10328 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10329 }
10330 let started = Instant::now();
10331 let mut cmd = tokio::process::Command::new(&shell[0]);
10332 cmd.quiet();
10333 cmd.args(&shell[1..])
10334 .arg(command)
10335 .current_dir(cwd)
10336 .stdin(std::process::Stdio::null())
10337 .stdout(std::process::Stdio::piped())
10338 .stderr(std::process::Stdio::piped())
10339 .kill_on_drop(true);
10340 let spawned = cmd.spawn();
10341 let (code, body) = match spawned {
10342 Ok(child) => {
10343 let pid = child.id();
10348 match tokio::time::timeout(timeout, child.wait_with_output()).await {
10349 Ok(Ok(o)) => {
10350 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10351 body.push_str(&String::from_utf8_lossy(&o.stderr));
10352 (o.status.code(), body)
10353 }
10354 Ok(Err(e)) => (None, format!("failed to run: {e}")),
10355 Err(_) => {
10356 if let Some(pid) = pid {
10357 timed_out_pids.push(pid);
10358 }
10359 (None, format!("timed out after {}s", timeout.as_secs()))
10360 }
10361 }
10362 }
10363 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10364 };
10365 out.push(CommandOutcome {
10366 command: command.clone(),
10367 code,
10368 output_tail: tail(&body, OUTPUT_TAIL),
10369 duration_ms: started.elapsed().as_millis() as u64,
10370 resource_blocked: false,
10371 });
10372 }
10373 state.task_finished(task);
10374 if let Err(e) = state.save() {
10375 tracing::warn!("could not persist the end of {task}: {e:#}");
10376 }
10377 (out, timed_out_pids)
10378}
10379
10380fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10392 let repo = repo.display();
10393 match style {
10394 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10395 MergeStyle::Squash => {
10396 let subject = message
10399 .lines()
10400 .next()
10401 .unwrap_or(branch)
10402 .replace(['\\', '"', '$', '`'], "");
10403 format!(
10404 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10405 )
10406 }
10407 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10408 }
10409}
10410
10411const PR_TITLE_MAX: usize = 240;
10422
10423fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10434 let line = queue::first_line(text)?;
10435 let chars: Vec<char> = line.chars().collect();
10436 let (kept, mark) = if chars.len() <= max {
10437 (line.to_owned(), "")
10438 } else if let Some(end) = sentence_end(&chars, max) {
10439 (chars[..end].iter().collect::<String>(), "")
10440 } else {
10441 let room = max.saturating_sub(3);
10442 let cut = if chars[room].is_whitespace() {
10445 room
10446 } else {
10447 chars[..room]
10448 .iter()
10449 .rposition(|c| c.is_whitespace())
10450 .unwrap_or(room)
10451 };
10452 let head: String = chars[..cut].iter().collect();
10453 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10454 (head.to_owned(), "...")
10455 };
10456 if kept.is_empty() {
10457 return None;
10458 }
10459 if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10460 return None;
10461 }
10462 if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10463 return None;
10464 }
10465 Some(format!("{kept}{mark}"))
10466}
10467
10468fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10472 const MIN: usize = 20;
10473 for i in MIN..max.min(chars.len()) {
10474 if !matches!(chars[i], '.' | '!' | '?') {
10475 continue;
10476 }
10477 let Some(&next) = chars.get(i + 1) else {
10478 continue;
10479 };
10480 if !next.is_whitespace() {
10481 continue;
10482 }
10483 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10484 if after.is_some_and(|c| c.is_ascii_lowercase()) {
10485 continue;
10486 }
10487 let word: String = chars[..i]
10488 .iter()
10489 .rev()
10490 .take_while(|c| !c.is_whitespace())
10491 .collect::<Vec<_>>()
10492 .into_iter()
10493 .rev()
10494 .collect();
10495 let word = word.to_ascii_lowercase();
10496 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10497 continue;
10498 }
10499 let end = chars[..i]
10500 .iter()
10501 .rposition(|c| !c.is_whitespace())
10502 .map_or(i, |p| p + 1);
10503 return Some(end);
10504 }
10505 None
10506}
10507
10508struct PrMessage {
10513 title: String,
10514 body: String,
10515}
10516
10517impl PrMessage {
10518 fn commit_message(&self) -> String {
10522 format!("{}\n\n{}", self.title, self.body)
10523 }
10524}
10525
10526fn title_marker(line: &str) -> Option<&str> {
10528 let line = line.trim();
10529 let head = line.get(..6)?;
10530 head.eq_ignore_ascii_case("title:")
10531 .then(|| line[6..].trim())
10532}
10533
10534fn summary_title(summary: &str) -> Option<String> {
10539 let first = summary.lines().find(|l| !l.trim().is_empty())?;
10540 let raw = title_marker(first)?;
10541 if raw.is_empty() {
10542 return None;
10543 }
10544 let title = queue::title_from(raw, PR_TITLE_MAX);
10545 let lower = title.to_ascii_lowercase();
10546 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10547 return None;
10548 }
10549 Some(title)
10550}
10551
10552const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10554
10555pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10557
10558fn is_review_run(state: &RunState) -> bool {
10562 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10563}
10564
10565fn review_title(state: &RunState) -> Option<String> {
10570 english_subject(
10571 state.reviewed_commits.as_ref()?.first()?,
10572 state.config.graph.github_english(),
10573 )
10574}
10575
10576fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10579 let raw = raw.trim();
10580 if raw.is_empty() {
10581 return None;
10582 }
10583 if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10584 return None;
10585 }
10586 if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10587 return None;
10588 }
10589 let title = queue::title_from(raw, PR_TITLE_MAX);
10590 let lower = title.to_ascii_lowercase();
10591 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10592 return None;
10593 }
10594 Some(title)
10595}
10596
10597#[derive(Debug, Clone, PartialEq, Eq)]
10600struct BranchFacts {
10601 commits: Vec<(String, String)>,
10603 stat: String,
10605}
10606
10607const STAT_FILE_LINES: usize = 25;
10609const COMMITS_MAX_CHARS: usize = 20_000;
10611
10612async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10615 let commits = git::commit_log(repo, base, branch).await.ok()?;
10616 if commits.is_empty() {
10617 return None;
10618 }
10619 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10620 let lines: Vec<&str> = stat.lines().collect();
10621 let stat = if lines.len() > STAT_FILE_LINES + 1 {
10622 let omitted = lines.len() - 1 - STAT_FILE_LINES;
10623 let more = format!(" ... {omitted} more file(s)");
10624 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10625 kept.push(&more);
10626 kept.push(lines[lines.len() - 1]);
10627 kept.join("\n")
10628 } else {
10629 lines.join("\n")
10630 };
10631 Some(BranchFacts { commits, stat })
10632}
10633
10634fn markdown_safe(text: &str) -> String {
10637 text.replace("</details>", "</details>")
10638 .replace("\x60\x60\x60", "~~~")
10639}
10640
10641fn neutral_title(state: &RunState, winner: char) -> String {
10642 format!(
10643 "chore: land candidate {} of run {}",
10644 winner.to_ascii_uppercase(),
10645 state.id
10646 )
10647}
10648
10649pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10655 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10656 ""
10657 } else {
10658 pr_title
10659 }
10660}
10661
10662pub fn landing_subject_source(state: &RunState) -> String {
10666 if is_review_run(state) {
10667 let winner = state.candidates.first().map_or('A', |c| c.label);
10668 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10669 }
10670 state.instruction.clone()
10671}
10672
10673fn summary_without_title(summary: &str) -> String {
10676 let mut lines = summary.trim().lines().peekable();
10677 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10678 lines.next();
10679 }
10680 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10681}
10682
10683#[cfg(test)]
10695fn pr_message(state: &RunState, winner: char) -> PrMessage {
10696 pr_message_with(state, winner, None)
10697}
10698
10699#[cfg(test)]
10702fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10703 let raw = pr_message_raw(
10704 state,
10705 winner,
10706 facts,
10707 crate::pr_outcome::FinalHead::Unreported,
10708 ChangeNote::NotAsked,
10709 );
10710 let id = crate::scrub::Identity::current();
10711 PrMessage {
10712 title: crate::scrub::scrub(&raw.title, &id),
10713 body: crate::scrub::scrub(&raw.body, &id),
10714 }
10715}
10716
10717#[derive(Debug, Clone, Copy)]
10719enum ChangeNote<'a> {
10720 NotAsked,
10722 Summary(&'a crate::run::ChangeSummary),
10724 Unavailable(&'a str),
10726}
10727
10728fn pr_message_raw(
10729 state: &RunState,
10730 winner: char,
10731 facts: Option<&BranchFacts>,
10732 head: crate::pr_outcome::FinalHead<'_>,
10733 change: ChangeNote<'_>,
10734) -> PrMessage {
10735 if !matches!(change, ChangeNote::NotAsked) {
10736 let (title, mut body) = match change {
10737 ChangeNote::Summary(c) => (
10738 c.title.clone(),
10739 format!("## Summary\n\n{}\n\n", c.body.trim()),
10740 ),
10741 ChangeNote::Unavailable(why) => (
10742 neutral_title(state, winner),
10743 format!(
10744 "## Summary\n\nNo description of what this change does could be generated ({why}). Review the final diff; internal reports remain in local records.\n\n"
10745 ),
10746 ),
10747 ChangeNote::NotAsked => unreachable!(),
10748 };
10749 body.push_str(&public_pr_evidence(state, head));
10750 body.push_str(&format!(
10751 "\n---\nmagi:run/{} magi:candidate-{}\n",
10752 state.id,
10753 winner.to_ascii_lowercase()
10754 ));
10755 return PrMessage { title, body };
10756 }
10757 pr_preview(state, winner, facts, head)
10758}
10759
10760fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10763 let mut body = crate::pr_outcome::review_rounds(state);
10764 body.push_str(&crate::pr_outcome::final_verification(state, head));
10765 let open = state.last_round_findings();
10766 if !open.is_empty() {
10767 body.push_str("## Open review findings\n\n");
10768 for f in open {
10769 body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10770 }
10771 body.push('\n');
10772 }
10773 if let Some(c) = state.change_summary.as_ref()
10774 && head == crate::pr_outcome::FinalHead::At(&c.head)
10775 {
10776 body.push_str(&format!(
10777 "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10778 c.base, c.head
10779 ));
10780 }
10781 body
10782}
10783
10784fn pr_preview(
10786 state: &RunState,
10787 winner: char,
10788 facts: Option<&BranchFacts>,
10789 head: crate::pr_outcome::FinalHead<'_>,
10790) -> PrMessage {
10791 let summary = state
10792 .candidates
10793 .iter()
10794 .find(|c| c.label == winner)
10795 .map(|c| c.summary.as_str())
10796 .unwrap_or_default();
10797 let review = is_review_run(state);
10800 let title = if review {
10801 facts
10802 .and_then(|f| {
10803 english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10804 })
10805 .or_else(|| review_title(state))
10806 .or_else(|| {
10807 state
10808 .candidates
10809 .iter()
10810 .find(|c| c.label == winner)
10811 .filter(|c| !c.branch.starts_with("magi/"))
10812 .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10813 })
10814 .unwrap_or_else(|| neutral_title(state, winner))
10815 } else {
10816 summary_title(summary).unwrap_or_else(|| {
10817 english_title(
10818 &state.instruction,
10819 PR_TITLE_MAX,
10820 state.config.graph.github_english(),
10821 )
10822 .unwrap_or_else(|| neutral_title(state, winner))
10823 })
10824 };
10825
10826 let mut body = String::new();
10827 let what = summary_without_title(summary);
10828 if !what.is_empty() {
10829 body.push_str("## Summary\n\n");
10830 body.push_str(&what);
10831 body.push_str("\n\n");
10832 }
10833
10834 body.push_str(&crate::pr_outcome::review_rounds(state));
10836 body.push_str(&crate::pr_outcome::final_verification(state, head));
10837 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10838
10839 let open = state.open_findings();
10840 if !open.is_empty() {
10841 body.push_str("## Open review findings\n\n");
10842 for f in &open {
10843 body.push_str(&format!(
10844 "- `{}` [{:?}] {}\n",
10845 f.id, f.severity, "Context retained in local review records."
10846 ));
10847 }
10848 body.push('\n');
10849 }
10850
10851 if let Some(fix) = fix
10852 && !fix.rejected.is_empty()
10853 {
10854 body.push_str("## Declined by the fixer\n\n");
10855 for r in &fix.rejected {
10856 body.push_str(&format!(
10857 "- `{}`: {}\n",
10858 r.id, "Reason retained in local review records."
10859 ));
10860 }
10861 body.push('\n');
10862 }
10863
10864 if review {
10865 body.push_str("## Commits under review\n\n");
10867 if let Some(facts) = facts {
10868 let mut left = COMMITS_MAX_CHARS;
10869 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10870 let mut entry = format!("- {}\n", markdown_safe(subject));
10871 for l in markdown_safe(text).lines() {
10872 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10873 }
10874 if left == 0 {
10875 body.push_str(&format!(
10876 "- ... {} more commit(s)\n",
10877 facts.commits.len() - i
10878 ));
10879 break;
10880 }
10881 if entry.len() > left {
10882 let mut end = left;
10885 while !entry.is_char_boundary(end) {
10886 end -= 1;
10887 }
10888 entry.truncate(end);
10889 entry.push_str("\n ... (truncated)\n");
10890 left = 0;
10891 } else {
10892 left -= entry.len();
10893 }
10894 body.push_str(&entry);
10895 }
10896 if !facts.stat.trim().is_empty() {
10897 body.push_str(&format!(
10898 "\n## Diff stat\n\n```\n{}\n```\n",
10899 markdown_safe(facts.stat.trim())
10900 ));
10901 }
10902 } else {
10903 match &state.reviewed_commits {
10904 Some(subjects) => {
10905 for s in subjects {
10906 body.push_str(&format!("- {}\n", s.trim()));
10907 }
10908 }
10909 None => {
10910 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10913 body.push_str(rest.trim());
10914 body.push('\n');
10915 }
10916 }
10917 }
10918 } else {
10919 let task = state.instruction.trim();
10920 let task = if task.is_empty() {
10921 "(empty task)"
10922 } else {
10923 task
10924 };
10925 body.push_str(&format!(
10926 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10927 task.replace("</details>", "</details>")
10928 ));
10929 }
10930
10931 body.push_str(&format!(
10932 "\n---\nmagi:run/{} magi:candidate-{}\n",
10933 state.id,
10934 winner.to_ascii_lowercase()
10935 ));
10936
10937 PrMessage { title, body }
10938}
10939
10940fn seeded_instruction(state: &RunState) -> String {
10944 match refs::describe(&state.seeds) {
10945 Some(facts) => format!(
10946 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10947 Candidates start from the unmerged branch named above, when there \
10948 is one, and carry any unmerged commit named by sha as a \
10949 cherry-pick. Check that this is what the task meant before \
10950 building on it.",
10951 state.instruction
10952 ),
10953 None => state.instruction.clone(),
10954 }
10955}
10956
10957async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10961 let base = &state.base_branch;
10962 let mut against = base.clone();
10963 if mode == MergeMode::Pr {
10964 let remote = &state.config.merge.remote;
10968 let tracking = format!("{remote}/{base}");
10969 let fetched = git::fetch(repo, remote, base).await;
10970 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10971 against = tracking;
10972 } else {
10973 return false;
10974 }
10975 }
10976 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10977}
10978
10979fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10982 let mut detail = format!(
10983 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10984 nothing to open a pull request for"
10985 );
10986 match refs::describe(&state.seeds) {
10987 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10988 None => detail.push_str(
10989 "\nThe task names no existing branch or commit; if it means to land work \
10990 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10991 ),
10992 }
10993 detail
10994}
10995
10996#[derive(Debug, PartialEq, Eq)]
10999enum PrPlan {
11000 Create,
11001 Adopt { url: String, title: String },
11002 Stop(String),
11003}
11004
11005fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
11008 match found {
11009 Ok(land::OpenPr::None) => PrPlan::Create,
11010 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
11011 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
11012 "several open pull requests exist for this branch, not picking one: {}",
11013 urls.join(" ")
11014 )),
11015 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
11016 }
11017}
11018
11019pub(crate) async fn gh_pr_create(
11021 cwd: &Path,
11022 base: &str,
11023 head: &str,
11024 title: &str,
11025 body: &str,
11026) -> Result<String> {
11027 let out = tokio::process::Command::new("gh")
11028 .args([
11029 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
11030 ])
11031 .current_dir(cwd)
11032 .quiet()
11033 .stdin(std::process::Stdio::null())
11034 .output()
11035 .await
11036 .context("spawn gh")?;
11037 if out.status.success() {
11038 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
11039 } else {
11040 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
11041 }
11042}
11043
11044pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
11053 if crate::pr_recovery::sources_retained(home, &state.id) {
11054 return Ok(Vec::new());
11055 }
11056 let repo = state.repo.clone();
11057 let wait = repository_wait(state, None);
11060 let _coordination = match coordinate_operation(state, &repo, &wait).await {
11061 Ok(lease) => lease,
11062 Err(e) => {
11063 state.save_under(home)?;
11064 return Err(e);
11065 }
11066 };
11067 let root = state.worktree_root();
11068 let winner = state.tally.as_ref().map(|t| t.winner);
11069 let mut removed = Vec::new();
11070
11071 for i in 0..state.candidates.len() {
11072 let c = state.candidates[i].clone();
11073 let is_winner = Some(c.label) == winner;
11074 if is_winner && !drop_winner {
11075 continue;
11076 }
11077 if c.worktree.exists() {
11078 git::worktree_remove(&repo, &c.worktree).await.ok();
11079 removed.push(c.worktree.to_string_lossy().into_owned());
11080 }
11081 let handed_over = state.released_branches.contains(&c.branch);
11084 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
11085 git::branch_delete(&repo, &c.branch).await.ok();
11086 removed.push(c.branch.clone());
11087 }
11088 state.candidates[i].folded = true;
11089 }
11090
11091 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
11092 let path = name.path();
11093 let keep = !drop_winner
11094 && winner.is_some_and(|w| {
11095 path.file_name()
11096 .is_some_and(|n| n == format!("cand-{w}").as_str())
11097 });
11098 if keep {
11099 continue;
11100 }
11101 git::worktree_remove(&repo, &path).await.ok();
11102 removed.push(path.to_string_lossy().into_owned());
11103 }
11104
11105 remove_if_empty(&root);
11114
11115 if state.enabled_worktree_config && drop_winner {
11116 git::release_worktree_config(&repo).await.ok();
11120 state.enabled_worktree_config = false;
11121 }
11122 state.save_under(home)?;
11123 Ok(removed)
11124}
11125
11126fn remove_if_empty(dir: &Path) {
11137 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
11138 std::fs::remove_dir(dir).ok();
11139 }
11140}
11141
11142pub fn worst_open(state: &RunState) -> Option<Severity> {
11144 state
11145 .reviews
11146 .last()?
11147 .reviews
11148 .iter()
11149 .flat_map(|r| r.findings.iter())
11150 .map(|f| f.severity)
11151 .max()
11152}
11153
11154async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
11158 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
11159 git::acquire_worktree_config(repo).await?;
11160 state.enabled_worktree_config = true;
11161 }
11162 Ok(())
11163}
11164
11165#[cfg(test)]
11166mod tests {
11167 #[test]
11168 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11169 let leaked = vec!["chore(deps): update a crate".to_owned()];
11170 let own = "fix(daemon): apply a chosen action";
11171 assert!(should_retitle("", own, &leaked));
11172 assert!(should_retitle(
11173 &format!("{REVIEW_PROMPT_OPENING} `x`"),
11174 own,
11175 &leaked
11176 ));
11177 assert!(should_retitle(
11178 "chore: land candidate A of run 1",
11179 own,
11180 &leaked
11181 ));
11182 assert!(should_retitle(
11183 "magi: candidate A (uncommitted work)",
11184 own,
11185 &leaked
11186 ));
11187 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11188 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11189 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11190 }
11191
11192 #[test]
11193 fn pr_merge_plan_creates_adopts_or_stops() {
11194 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11195 assert_eq!(
11196 pr_merge_plan(Ok(land::OpenPr::One {
11197 url: "u".into(),
11198 title: "t".into()
11199 })),
11200 PrPlan::Adopt {
11201 url: "u".into(),
11202 title: "t".into()
11203 }
11204 );
11205 let PrPlan::Stop(many) =
11206 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11207 else {
11208 panic!("many must stop");
11209 };
11210 assert!(many.contains('a') && many.contains('b'));
11211 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11212 panic!("a failed lookup must stop");
11213 };
11214 assert!(err.contains("bad token"));
11215 }
11216
11217 use super::*;
11218 use crate::run::GateStatus;
11219 use std::collections::BTreeMap;
11220 use std::time::Duration;
11221
11222 fn conductor() -> AgentSpec {
11223 AgentSpec {
11224 id: "conductor".to_owned(),
11225 kind: crate::config::AgentKind::Command,
11226 model: None,
11227 command: vec!["true".to_owned()],
11228 extra_args: Vec::new(),
11229 env: BTreeMap::new(),
11230 prompt_delivery: None,
11231 }
11232 }
11233
11234 fn spec(id: &str) -> AgentSpec {
11235 AgentSpec {
11236 id: id.to_owned(),
11237 kind: crate::config::AgentKind::Command,
11238 model: None,
11239 command: vec!["true".to_owned()],
11240 extra_args: Vec::new(),
11241 env: BTreeMap::new(),
11242 prompt_delivery: None,
11243 }
11244 }
11245
11246 fn ids(xs: &[&str]) -> BTreeSet<String> {
11247 xs.iter().map(|s| (*s).to_owned()).collect()
11248 }
11249
11250 #[test]
11251 fn pick_successor_skips_an_agent_another_seat_holds() {
11252 let roster = [spec("a"), spec("b"), spec("c")];
11254 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11255 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11256 }
11257
11258 #[test]
11259 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11260 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11262 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11263 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11264 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11265 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11266 }
11267
11268 #[test]
11269 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11270 let roster = [spec("a"), spec("b")];
11271 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11272 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11273 let carried = ids(&["b"]);
11274 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11275 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11276 }
11277
11278 #[test]
11279 fn pick_successor_returns_none_without_any_untried_successor() {
11280 let roster = [spec("a"), spec("b")];
11281 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11282 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11283 let carried = ids(&["a"]);
11284 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11285 }
11286
11287 #[test]
11288 fn pick_successor_rescue_avoids_another_seats_occupant() {
11289 let roster = [spec("a"), spec("b"), spec("c")];
11291 let carried = ids(&["b", "c"]);
11292 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11293 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11294 }
11295
11296 #[test]
11297 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11298 let roster = [spec("a"), spec("b"), spec("c")];
11299 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11300 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11301 }
11302
11303 #[test]
11304 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11305 let roster = [spec("a"), spec("b"), spec("c")];
11306 let failed = ids(&["a", "b", "c"]);
11307 let mut tried = ids(&["b"]);
11309 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11310 assert_eq!(first.id, "a");
11311 tried.insert(first.id.clone());
11312 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11313 assert_eq!(second.id, "c");
11314 tried.insert(second.id.clone());
11315 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11316 }
11317
11318 #[test]
11319 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11320 let roster = [spec("a"), spec("b")];
11321 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11322 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11323 }
11324
11325 #[test]
11326 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11327 let roster = [spec("a"), spec("b"), spec("c")];
11328 let h = SeatHistory {
11329 failed: ids(&["a"]),
11330 last_ok: Some("b".to_owned()),
11331 last_fail: None,
11332 };
11333 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11334 let gone = SeatHistory {
11336 last_ok: Some("zzz".to_owned()),
11337 ..h.clone()
11338 };
11339 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11340 let failed = SeatHistory {
11341 failed: ids(&["a", "b"]),
11342 last_ok: Some("b".to_owned()),
11343 last_fail: None,
11344 };
11345 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11346 }
11347
11348 #[test]
11349 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11350 let first = SeatState::new("review-1", "alpha", 7);
11351 let next = handover_seat("review-1", "gamma", 7);
11352 assert_ne!(first.claude_session, next.claude_session);
11353 }
11354
11355 #[test]
11356 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11357 let mut state = state_with_summary("x", "y");
11358 let a = handover_seat("review-1", "beta", state.next_seat_seed());
11359 let b = handover_seat("review-1", "beta", state.next_seat_seed());
11360 assert_ne!(a.claude_session, b.claude_session);
11361 }
11362
11363 #[test]
11364 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11365 let roster = [spec("a"), spec("b")];
11366 let h = SeatHistory {
11367 failed: ids(&["a", "b"]),
11368 ..SeatHistory::default()
11369 };
11370 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11371 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11372 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11373 }
11374
11375 #[test]
11382 fn failure_signature_ignores_numbers_and_paths() {
11383 assert_eq!(
11384 failure_signature("exited with Some(2) and no usable output"),
11385 failure_signature("exited with Some(137) and no usable output")
11386 );
11387 assert_eq!(
11388 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11389 failure_signature("cannot open /var/x.txt: denied")
11390 );
11391 assert_ne!(failure_signature("boom"), failure_signature("bang"));
11392 }
11393
11394 #[test]
11395 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11396 let other = FailClass::Other("x".into());
11397 assert!(should_hand_over(None, &FailClass::Quota));
11398 assert!(should_hand_over(Some(&other), &FailClass::Quota));
11399 assert!(should_hand_over(
11400 Some(&FailClass::Timeout),
11401 &FailClass::Timeout
11402 ));
11403 assert!(should_hand_over(None, &other));
11404 assert!(!should_hand_over(Some(&other), &other));
11405 assert!(should_hand_over(
11406 Some(&other),
11407 &FailClass::Other("y".into())
11408 ));
11409 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11411 assert!(should_hand_over(Some(&FailClass::Quota), &other));
11412 }
11413
11414 #[test]
11415 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11416 let a = SeatState::new("judge-1", "alpha", 7);
11417 let b = handover_seat("judge-1", "beta", 7);
11418 assert_ne!(a.claude_session, b.claude_session);
11419 assert_eq!(b.turns, 0);
11420 }
11421
11422 #[test]
11423 fn a_timeout_is_classified_apart_from_other_failures() {
11424 assert_eq!(
11425 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11426 Some(FailClass::Timeout)
11427 );
11428 assert!(matches!(
11429 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11430 Some(FailClass::Other(_))
11431 ));
11432 }
11433
11434 #[test]
11435 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11436 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11437 let tried = BTreeSet::from(["beta".to_owned()]);
11438 let next = next_untried_in_roster(&roster, 1, &tried);
11441 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11442 }
11443
11444 #[test]
11445 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11446 let roster = vec![spec("alpha"), spec("beta")];
11447 let tried = BTreeSet::from(["beta".to_owned()]);
11448 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11452 }
11453
11454 #[test]
11455 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11456 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11457 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11458 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11462 }
11463
11464 #[test]
11465 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11466 let roster = vec![spec("a"), spec("a"), spec("b")];
11467 let tried = BTreeSet::from(["a".to_owned()]);
11468 let next = next_untried_in_roster(&roster, 0, &tried);
11469 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11470 }
11471
11472 #[test]
11473 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11474 let roster = vec![spec("a"), spec("b")];
11475 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11476 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11477 }
11478
11479 #[test]
11480 fn remove_if_empty_only_ever_takes_a_bare_directory() {
11481 let dir = tempfile::tempdir().unwrap();
11482 let bay = dir.path().join("ffff");
11483
11484 remove_if_empty(&bay);
11486 assert!(!bay.exists());
11487
11488 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11491 remove_if_empty(&bay);
11492 assert!(bay.exists(), "non-empty directory must survive");
11493
11494 std::fs::remove_dir(bay.join("cand-A")).unwrap();
11496 remove_if_empty(&bay);
11497 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11498 }
11499
11500 #[test]
11509 fn a_full_panel_that_found_nothing_is_clean() {
11510 assert!(round_is_clean(
11511 0,
11512 true,
11513 2,
11514 2,
11515 0,
11516 IncompleteReviewPolicy::Block
11517 ));
11518 }
11519
11520 #[test]
11521 fn a_missing_seat_is_never_clean_under_the_default_policy() {
11522 assert!(!round_is_clean(
11523 0,
11524 true,
11525 1,
11526 2,
11527 0,
11528 IncompleteReviewPolicy::Block
11529 ));
11530 }
11531
11532 #[test]
11533 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11534 assert!(!round_is_clean(
11535 1,
11536 true,
11537 1,
11538 2,
11539 0,
11540 IncompleteReviewPolicy::Warn
11541 ));
11542 }
11543
11544 #[test]
11545 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11546 assert!(round_is_clean(
11547 0,
11548 true,
11549 1,
11550 2,
11551 0,
11552 IncompleteReviewPolicy::Warn
11553 ));
11554 }
11555
11556 #[test]
11557 fn a_full_panel_with_an_open_finding_is_not_clean() {
11558 assert!(!round_is_clean(
11559 1,
11560 true,
11561 2,
11562 2,
11563 0,
11564 IncompleteReviewPolicy::Block
11565 ));
11566 }
11567
11568 #[test]
11569 fn a_full_panel_with_a_red_e2e_is_not_clean() {
11570 assert!(!round_is_clean(
11571 0,
11572 false,
11573 2,
11574 2,
11575 0,
11576 IncompleteReviewPolicy::Block
11577 ));
11578 }
11579
11580 #[test]
11587 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11588 assert!(round_is_clean(
11591 0,
11592 true,
11593 1,
11594 2,
11595 1,
11596 IncompleteReviewPolicy::Block
11597 ));
11598 }
11599
11600 #[test]
11601 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11602 assert!(!round_is_clean(
11605 0,
11606 true,
11607 1,
11608 2,
11609 0,
11610 IncompleteReviewPolicy::Block
11611 ));
11612 }
11613
11614 #[test]
11615 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11616 assert!(!round_is_clean(
11617 1,
11618 true,
11619 1,
11620 2,
11621 1,
11622 IncompleteReviewPolicy::Block
11623 ));
11624 assert!(!round_is_clean(
11625 0,
11626 false,
11627 1,
11628 2,
11629 1,
11630 IncompleteReviewPolicy::Block
11631 ));
11632 }
11633
11634 #[test]
11635 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11636 assert!(!round_is_clean(
11640 0,
11641 true,
11642 0,
11643 2,
11644 2,
11645 IncompleteReviewPolicy::Block
11646 ));
11647 }
11648
11649 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11650 CommandOutcome {
11651 command: "test".to_owned(),
11652 code,
11653 output_tail: String::new(),
11654 duration_ms: 0,
11655 resource_blocked,
11656 }
11657 }
11658
11659 #[test]
11660 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11661 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11662 assert!(
11663 !verify_inconclusive(&[outcome(Some(1), false)]),
11664 "an ordinary failure is still evidence about the patch"
11665 );
11666 assert!(verify_inconclusive(&[outcome(None, true)]));
11667 assert!(
11668 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11669 "one inconclusive outcome taints the whole batch"
11670 );
11671 assert!(!verify_inconclusive(&[]));
11672 }
11673
11674 #[tokio::test]
11675 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11676 let calls = std::sync::atomic::AtomicUsize::new(0);
11680 let started = Instant::now();
11681 wait_for_pids_with(
11682 &[123],
11683 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11684 Duration::from_millis(5),
11685 Duration::from_secs(5),
11686 )
11687 .await;
11688 assert!(
11689 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11690 "must keep checking rather than deciding on the first answer"
11691 );
11692 assert!(
11693 started.elapsed() < Duration::from_secs(1),
11694 "must return the moment it is confirmed dead, not wait out the ceiling"
11695 );
11696 }
11697
11698 #[tokio::test]
11699 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11700 let started = Instant::now();
11701 wait_for_pids_with(
11702 &[123],
11703 |_| true, Duration::from_millis(5),
11705 Duration::from_millis(30),
11706 )
11707 .await;
11708 let elapsed = started.elapsed();
11709 assert!(
11710 elapsed >= Duration::from_millis(30),
11711 "must not give up before its own ceiling: {elapsed:?}"
11712 );
11713 assert!(
11714 elapsed < Duration::from_secs(1),
11715 "must not wait past its own ceiling either: {elapsed:?}"
11716 );
11717 }
11718
11719 #[tokio::test]
11720 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11721 let started = Instant::now();
11722 wait_for_pids_with(
11723 &[],
11724 |_| true,
11725 Duration::from_secs(5),
11726 Duration::from_secs(5),
11727 )
11728 .await;
11729 assert!(
11730 started.elapsed() < Duration::from_millis(200),
11731 "an empty pid list has nothing to confirm"
11732 );
11733 }
11734
11735 fn review_round(
11741 clean: bool,
11742 blocking: usize,
11743 answered: usize,
11744 expected: usize,
11745 progressed: bool,
11746 e2e_ok: bool,
11747 ) -> ReviewRound {
11748 ReviewRound {
11749 round: 1,
11750 head: "h".to_owned(),
11751 verified_head: None,
11752 verified_at: None,
11753 reviews: Vec::new(),
11754 e2e: vec![CommandOutcome {
11755 command: "test".to_owned(),
11756 code: Some(if e2e_ok { 0 } else { 1 }),
11757 output_tail: String::new(),
11758 duration_ms: 0,
11759 resource_blocked: false,
11760 }],
11761 verify_retried: false,
11762 e2e_deferred: false,
11763 e2e_defer_reason: None,
11764 fix: None,
11765 blocking,
11766 answered,
11767 expected,
11768 clean,
11769 progressed,
11770 vote_split: false,
11771 reconsideration: Vec::new(),
11772 verdict: None,
11773 }
11774 }
11775
11776 #[test]
11777 fn review_conclusion_is_none_when_nothing_has_run() {
11778 assert_eq!(review_conclusion(&[], 3), None);
11779 }
11780
11781 #[test]
11782 fn review_conclusion_is_none_while_rounds_remain() {
11783 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11784 assert_eq!(review_conclusion(&rounds, 3), None);
11785 }
11786
11787 #[test]
11788 fn review_conclusion_is_gating_once_a_round_is_clean() {
11789 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11790 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11791 }
11792
11793 #[test]
11794 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11795 let rounds = vec![
11796 review_round(false, 1, 2, 2, true, true),
11797 review_round(false, 1, 2, 2, true, true),
11798 ];
11799 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11800 }
11801
11802 #[test]
11803 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11804 let rounds = vec![
11805 review_round(false, 1, 2, 2, true, true),
11806 review_round(false, 1, 2, 2, true, false),
11807 ];
11808 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11809 }
11810
11811 #[test]
11812 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11813 let mut blocked = review_round(false, 1, 2, 2, true, false);
11820 blocked.e2e[0].resource_blocked = true;
11821 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11822 assert_eq!(review_conclusion(&rounds, 2), None);
11823 }
11824
11825 #[test]
11826 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11827 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11829 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11830 }
11831
11832 #[test]
11833 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11834 let rounds = vec![
11835 review_round(false, 1, 2, 2, false, true),
11836 review_round(false, 1, 2, 2, false, true),
11837 ];
11838 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11839 }
11840
11841 fn secs(n: u64) -> Duration {
11842 Duration::from_secs(n)
11843 }
11844
11845 #[tokio::test]
11848 async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11849 ask_test_home();
11850 let tmp = tempfile::tempdir().unwrap();
11851 let repo = tmp.path().join("repo");
11852 std::fs::create_dir_all(&repo).unwrap();
11853 init_repo(&repo);
11854 let g = |args: &[&str]| {
11855 let out = std::process::Command::new("git")
11856 .args(args)
11857 .current_dir(&repo)
11858 .quiet()
11859 .output()
11860 .unwrap();
11861 assert!(
11862 out.status.success(),
11863 "{}",
11864 String::from_utf8_lossy(&out.stderr)
11865 );
11866 };
11867 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11868 g(&["checkout", "-b", "historical"]);
11869 std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11870 g(&["add", "."]);
11871 g(&["commit", "-m", "old alternative"]);
11872 let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11873 g(&["checkout", "-b", "magi/task-selected", &base]);
11874 std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11875 g(&["add", "."]);
11876 g(&["commit", "-m", "measurement infrastructure"]);
11877 let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11878 std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11879 g(&["add", "."]);
11880 g(&["commit", "-m", "dependent implementation"]);
11881 let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11882 g(&["checkout", "main"]);
11883 std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11884 g(&["add", "."]);
11885 g(&["commit", "-m", "unrelated base change"]);
11886 let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11887 let origin = tmp.path().join("origin.git");
11888 g(&[
11889 "clone",
11890 "--bare",
11891 repo.to_str().unwrap(),
11892 origin.to_str().unwrap(),
11893 ]);
11894 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11895 g(&["fetch", "origin"]);
11896 let selection = crate::pr_recovery::Selection {
11897 url: "https://example.test/project/pull/1".into(),
11898 branch: "magi/task-selected".into(),
11899 head: head.clone(),
11900 instruction: "preserve measured behavior".into(),
11901 approved_at: jiff::Timestamp::now(),
11902 };
11903 let mut template = runner_at(RunStatus::Reviewing);
11904 template.state.repo = repo.clone();
11905 template.state.base_commit = current.clone();
11906 template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11907 template.state.origin = Some(crate::run::Origin::queue("selected"));
11908 template.state.pr_head = Some(selection.branch.clone());
11909 template.state.pr_recovery = Some(selection.clone());
11910 let mut runner =
11911 Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11912 .await
11913 .unwrap();
11914 let wt = runner.state.winner().unwrap().worktree.clone();
11915 assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11916 assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11917 assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11918 assert!(runner.state.seeds.is_empty());
11919 assert_eq!(runner.state.instruction, "preserve measured behavior");
11920 runner.sync_to_base().await.unwrap();
11921 assert_eq!(
11922 std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11923 "prerequisite"
11924 );
11925 assert_eq!(
11926 std::fs::read_to_string(wt.join("implementation")).unwrap(),
11927 "measured implementation"
11928 );
11929 assert_eq!(
11930 std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11931 "current main change"
11932 );
11933 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11934 assert_eq!(
11935 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11936 head
11937 );
11938
11939 let mut partial = runner_at(RunStatus::Reviewing);
11941 partial.state.repo = repo.clone();
11942 partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11943 partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11944 run: Some("exit 1".into()),
11945 ..Default::default()
11946 }];
11947 let selection = runner.state.pr_recovery.clone().unwrap();
11948 partial.state.pr_recovery = Some(selection.clone());
11949 partial.state.pr_head = Some(selection.branch.clone());
11950 partial.state.origin = Some(crate::run::Origin::queue("selected"));
11951 let id = partial.state.id.clone();
11952 let error = match Runner::open_selected(
11953 &repo,
11954 partial.state,
11955 partial.roles,
11956 1,
11957 runner.state.base_commit.clone(),
11958 selection,
11959 )
11960 .await
11961 {
11962 Ok(_) => panic!("setup must fail"),
11963 Err(e) => e,
11964 };
11965 assert_eq!(
11966 error
11967 .downcast_ref::<crate::pr_recovery::StartupFailed>()
11968 .unwrap()
11969 .run,
11970 id
11971 );
11972 let failed = RunState::load(&id).unwrap();
11973 assert_eq!(failed.status, RunStatus::Blocked);
11974 assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11975 assert!(Runner::resume(&id).is_err());
11976 runner.state.status = RunStatus::Ready;
11977 runner.state.driver_exited = true;
11978 runner.state.save().unwrap();
11979 let mut retry = runner_at(RunStatus::Reviewing);
11980 retry.state.repo = repo.clone();
11981 retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11982 let mut reselected = runner.state.pr_recovery.clone().unwrap();
11983 reselected.approved_at = jiff::Timestamp::now();
11984 retry.state.origin = Some(crate::run::Origin::queue("selected"));
11985 retry.state.pr_head = Some(reselected.branch.clone());
11986 retry.state.pr_recovery = Some(reselected.clone());
11987 crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11988 .await
11989 .unwrap();
11990 let restarted = Runner::open_selected(
11991 &repo,
11992 retry.state,
11993 retry.roles,
11994 1,
11995 runner.state.base_commit.clone(),
11996 reselected,
11997 )
11998 .await
11999 .unwrap();
12000 assert_eq!(
12001 git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
12002 .await
12003 .unwrap(),
12004 head
12005 );
12006 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
12007 assert_eq!(
12008 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
12009 head
12010 );
12011 }
12012
12013 #[tokio::test]
12014 #[cfg(unix)]
12015 async fn fallback_and_interrupted_restoration_require_coordination() {
12016 use std::os::fd::AsRawFd as _;
12017 ask_test_home();
12018 let tmp = tempfile::tempdir().unwrap();
12019 let mut runner = registered_runner(tmp.path()).await;
12020 runner.state.config.graph.timeout_resource_wait = 1;
12021 let winner = runner.state.candidates[0].clone();
12022 let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12023 let leftover = winner.worktree.join("leftover");
12024 std::fs::write(&leftover, "untrusted edits").unwrap();
12025 let next = tmp.path().join("next-fixer");
12026 let mut failed = spec("first");
12027 failed.command = vec!["sh".into(), "-c".into(), "exit 1".into()];
12028 let mut successor = spec("second");
12029 successor.command = vec![
12030 "sh".into(),
12031 "-c".into(),
12032 format!("touch '{}'; echo done", next.display()),
12033 ];
12034 let attempts = vec![
12035 (failed.clone(), "first".into()),
12036 (successor.clone(), "second".into()),
12037 ];
12038 let lock_path = runner.state.repo.join(".git/updater.lock");
12039 let external = std::fs::File::create(&lock_path).unwrap();
12040 assert_eq!(
12041 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12042 0
12043 );
12044 let artifacts = tmp.path().join("artifacts");
12045 let build = |spec, seat| SeatJob {
12046 spec,
12047 seat,
12048 cwd: winner.worktree.clone(),
12049 prompt: "fixture".into(),
12050 timeout: Duration::from_secs(10),
12051 allow_write: true,
12052 sessions: false,
12053 artifacts: artifacts.clone(),
12054 stem: "fix".into(),
12055 handover: None,
12056 };
12057 let (_, _, out) = runner
12058 .ask_fixer_among(
12059 attempts.clone(),
12060 "graft",
12061 None,
12062 Some((&winner.worktree, &head)),
12063 build,
12064 )
12065 .await;
12066 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
12067 assert!(leftover.exists(), "contention must prevent clean");
12068 assert!(!next.exists());
12069 assert!(runner.state.handovers.is_empty());
12070 runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12072 from_run: "earlier".into(),
12073 generation: 1,
12074 mode: crate::recovery::Mode::VerifyRepair,
12075 reasons: Vec::new(),
12076 reviewed_head: None,
12077 rounds: Vec::new(),
12078 open_findings: Vec::new(),
12079 evidence: None,
12080 criteria_inherited: true,
12081 repair: crate::recovery::Repair::Started {
12082 before: head.clone(),
12083 },
12084 });
12085 assert!(
12086 runner
12087 .recovery_repair(&winner)
12088 .await
12089 .unwrap_err()
12090 .is::<crate::repo_lock::ResourceBlocked>()
12091 );
12092 assert!(matches!(
12093 runner.state.recovery.as_ref().unwrap().repair,
12094 crate::recovery::Repair::Started { .. }
12095 ));
12096 assert!(leftover.exists());
12097 drop(external);
12098 runner.recovery_repair(&winner).await.unwrap();
12099 assert!(!leftover.exists());
12100 assert!(matches!(
12101 runner.state.recovery.as_ref().unwrap().repair,
12102 crate::recovery::Repair::Done { .. }
12103 ));
12104 std::fs::remove_file(&lock_path).unwrap();
12106 std::fs::create_dir(&lock_path).unwrap();
12107 std::fs::write(&leftover, "untrusted edits").unwrap();
12108 let (_, _, out) = runner
12109 .ask_fixer_among(
12110 attempts,
12111 "graft",
12112 None,
12113 Some((&winner.worktree, &head)),
12114 build,
12115 )
12116 .await;
12117 assert!(matches!(out, AgentOutcome::Failed(_)));
12118 assert!(leftover.exists());
12119 assert!(!next.exists());
12120 assert_eq!(
12121 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12122 head
12123 );
12124 }
12125
12126 #[cfg(unix)]
12127 async fn registered_runner(root: &Path) -> Runner {
12128 ask_test_home();
12129 let repo = root.join("repo");
12130 std::fs::create_dir_all(&repo).unwrap();
12131 init_repo(&repo);
12132 std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12133 let mut runner = runner_at(RunStatus::Prep);
12134 runner.state.repo = repo.clone();
12135 runner.state.config.coordination = crate::config::test_coordination();
12136 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12137 runner.state.config.graph.worktree_root = Some(root.join("wt"));
12138 runner.state.config.graph.advise = false;
12139 runner.roles.implementers = vec![spec("implement")];
12140 runner.roles.judges = vec![spec("judge")];
12141 runner.prep().await.unwrap();
12142 runner
12143 }
12144
12145 #[tokio::test]
12146 #[cfg(unix)]
12147 async fn parked_implementer_persists_completed_work() {
12148 for registered in [false, true] {
12149 let tmp = tempfile::tempdir().unwrap();
12150 let mut runner = registered_runner(tmp.path()).await;
12151 if !registered {
12152 std::fs::remove_file(runner.state.repo.join(".git/updater.contract")).unwrap();
12153 }
12154 let marker = tmp.path().join("started");
12155 let release = tmp.path().join("release");
12156 let mut implementer = spec("implement");
12157 implementer.command = vec![
12158 "sh".into(),
12159 "-c".into(),
12160 format!(
12161 "touch '{}'; while [ ! -f '{}' ]; do sleep 0.01; done; echo completed > completed; echo saved-result",
12162 marker.display(),
12163 release.display()
12164 ),
12165 ];
12166 runner.roles.implementers = vec![implementer.clone()];
12167 runner.state.config.agents = vec![implementer];
12168 let pause = runner.pause.clone();
12169 let (result, ()) = tokio::join!(runner.implement(), async {
12170 tokio::time::timeout(Duration::from_secs(10), async {
12171 while !marker.exists() {
12172 tokio::time::sleep(Duration::from_millis(10)).await;
12173 }
12174 })
12175 .await
12176 .unwrap();
12177 pause.park();
12178 std::fs::write(&release, "go").unwrap();
12179 });
12180 result.unwrap();
12181 let candidate = &runner.state.candidates[0];
12182 assert!(candidate.commits > 0, "{candidate:?}");
12183 assert!(candidate.summary.contains("saved-result"));
12184 assert_eq!(candidate.files, 1);
12185 let before = candidate.commits;
12186 runner.implement().await.unwrap();
12187 assert_eq!(runner.state.candidates[0].commits, before);
12188 }
12189 }
12190
12191 #[tokio::test]
12192 #[cfg(unix)]
12193 async fn resource_blocked_recovery_retries_the_unstarted_fixer() {
12194 let tmp = tempfile::tempdir().unwrap();
12195 let mut runner = registered_runner(tmp.path()).await;
12196 let winner = runner.state.candidates[0].clone();
12197 let cache = tmp.path().join("cache");
12198 runner.state.config.verify.e2e = vec![format!("CARGO_TARGET_DIR={} true", cache.display())];
12199 runner.state.config.graph.timeout_resource_wait = 0;
12200 let owner = crate::cache::Owner::here("other", "e2e", "e2e", &cache, "abc");
12201 let crate::cache::AcquireOutcome::Acquired(held) =
12202 crate::cache::try_acquire(&crate::run::home(), &cache, &owner).unwrap()
12203 else {
12204 panic!("cache must be free")
12205 };
12206 runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12207 from_run: "earlier".into(),
12208 generation: 1,
12209 mode: crate::recovery::Mode::VerifyRepair,
12210 reasons: Vec::new(),
12211 reviewed_head: None,
12212 rounds: Vec::new(),
12213 open_findings: Vec::new(),
12214 evidence: None,
12215 criteria_inherited: true,
12216 repair: crate::recovery::Repair::NotStarted,
12217 });
12218 runner.recovery_repair(&winner).await.unwrap();
12219 assert!(matches!(
12220 runner.state.recovery.as_ref().unwrap().repair,
12221 crate::recovery::Repair::WaitingForRestore { .. }
12222 ));
12223 drop(held);
12224 runner.state.resource_block = None;
12225 runner.state.config.graph.timeout_resource_wait = 1;
12226 runner.recovery_repair(&winner).await.unwrap();
12227 assert!(matches!(
12228 runner.state.recovery.as_ref().unwrap().repair,
12229 crate::recovery::Repair::Done { .. }
12230 ));
12231 assert!(runner.state.seats.values().any(|seat| seat.turns > 0));
12232 }
12233
12234 #[tokio::test]
12235 #[cfg(unix)]
12236 async fn repository_blocked_graft_and_recovery_retry_after_deferred_restore() {
12237 use std::os::fd::AsRawFd as _;
12238 for graft in [false, true] {
12239 let tmp = tempfile::tempdir().unwrap();
12240 let mut runner = registered_runner(tmp.path()).await;
12241 runner.state.config.graph.timeout_resource_wait = 1;
12242 let winner = runner.state.candidates[0].clone();
12243 let marker = tmp.path().join("fixer-started");
12244 let mut fixer = spec("fixer");
12245 fixer.command = vec![
12246 "sh".into(),
12247 "-c".into(),
12248 format!("echo started >> '{}'; echo done", marker.display()),
12249 ];
12250 runner.roles.fixer = Some(vec![fixer]);
12251 if graft {
12252 let mut loser = winner.clone();
12253 loser.label = 'B';
12254 loser.index = 1;
12255 runner.state.candidates.push(loser);
12256 runner.state.config.graph.graft_runner_up = true;
12257 runner.state.tally = Some(Tally {
12258 first_choice: BTreeMap::new(),
12259 borda: BTreeMap::new(),
12260 winner: winner.label,
12261 rankings: 1,
12262 unanimous_initial: true,
12263 deliberated: false,
12264 changed_votes: 0,
12265 unanimous_final: true,
12266 tie_break: None,
12267 judges: 1,
12268 present: 1,
12269 quorum: 1,
12270 met_quorum: true,
12271 uncontested: None,
12272 });
12273 runner.state.judgements.push(crate::run::Judgement {
12274 judge: 1,
12275 seat: "judge-1".into(),
12276 agent: "judge".into(),
12277 ranking: vec![winner.label, 'B'],
12278 reasons: BTreeMap::new(),
12279 confidence: None,
12280 strengths: BTreeMap::from([("B".into(), vec!["handles empty input".into()])]),
12281 order: vec![0, 1],
12282 failed: None,
12283 duration_ms: 0,
12284 });
12285 } else {
12286 runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12287 from_run: "earlier".into(),
12288 generation: 1,
12289 mode: crate::recovery::Mode::VerifyRepair,
12290 reasons: Vec::new(),
12291 reviewed_head: None,
12292 rounds: Vec::new(),
12293 open_findings: Vec::new(),
12294 evidence: None,
12295 criteria_inherited: true,
12296 repair: crate::recovery::Repair::NotStarted,
12297 });
12298 }
12299 let reader = crate::repo_lock::SeatLease::for_checkout(
12300 &winner.worktree,
12301 false,
12302 crate::config::test_coordination().as_ref(),
12303 )
12304 .await
12305 .unwrap();
12306 let pause = runner.pause.clone();
12307 let (result, ()) = tokio::join!(
12308 async {
12309 if graft {
12310 runner.graft().await
12311 } else {
12312 runner.recovery_repair(&winner).await
12313 }
12314 },
12315 async {
12316 tokio::time::sleep(Duration::from_millis(100)).await;
12317 pause.park();
12318 }
12319 );
12320 result.unwrap();
12321 assert!(!marker.exists(), "a blocked seat must not spawn");
12322 runner.state = RunState::load(&runner.state.id).unwrap();
12324 if graft {
12325 assert_eq!(
12326 runner.state.graft.as_ref().unwrap().outcome,
12327 GraftOutcome::WaitingForRestore
12328 );
12329 } else {
12330 assert!(matches!(
12331 runner.state.recovery.as_ref().unwrap().repair,
12332 crate::recovery::Repair::WaitingForRestore { .. }
12333 ));
12334 }
12335 drop(reader);
12336 runner.pause = Pause::new();
12337 runner.state.resource_block = None;
12338 let external =
12340 std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12341 assert_eq!(
12342 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12343 0
12344 );
12345 let leftover = winner.worktree.join("leftover");
12346 std::fs::write(&leftover, "earlier fallback edits").unwrap();
12347 let result = if graft {
12348 runner.graft().await
12349 } else {
12350 runner.recovery_repair(&winner).await
12351 };
12352 assert!(
12353 result
12354 .unwrap_err()
12355 .is::<crate::repo_lock::ResourceBlocked>()
12356 );
12357 assert!(leftover.exists());
12358 assert!(!marker.exists());
12359 drop(external);
12360 runner.state.resource_block = None;
12361 let result = if graft {
12362 runner.graft().await
12363 } else {
12364 runner.recovery_repair(&winner).await
12365 };
12366 result.unwrap();
12367 assert!(!leftover.exists(), "restore must precede retry");
12368 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "started\n");
12369 if graft {
12370 assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12371 } else {
12372 assert!(matches!(
12373 runner.state.recovery.as_ref().unwrap().repair,
12374 crate::recovery::Repair::Done { .. }
12375 ));
12376 }
12377 }
12378 }
12379
12380 #[tokio::test]
12381 #[cfg(unix)]
12382 async fn interrupted_graft_restore_waits_and_preserves_its_record() {
12383 use std::os::fd::AsRawFd as _;
12384 let tmp = tempfile::tempdir().unwrap();
12385 let mut runner = registered_runner(tmp.path()).await;
12386 runner.state.config.graph.timeout_resource_wait = 1;
12387 let winner = runner.state.candidates[0].clone();
12388 let pre = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12389 runner.state.tally = Some(Tally {
12390 first_choice: BTreeMap::new(),
12391 borda: BTreeMap::new(),
12392 winner: winner.label,
12393 rankings: 0,
12394 unanimous_initial: false,
12395 deliberated: false,
12396 changed_votes: 0,
12397 unanimous_final: false,
12398 tie_break: None,
12399 judges: 0,
12400 present: 0,
12401 quorum: 0,
12402 met_quorum: true,
12403 uncontested: Some("fixture".into()),
12404 });
12405 runner.state.graft = Some(GraftRecord {
12406 pre: pre.clone(),
12407 post: None,
12408 lines: None,
12409 limit: None,
12410 agent: None,
12411 outcome: GraftOutcome::Running,
12412 requested: Vec::new(),
12413 adoption: None,
12414 adoption_error: None,
12415 });
12416 std::fs::write(winner.worktree.join("README.md"), "unfinished graft\n").unwrap();
12417 git::commit_all(&winner.worktree, "unfinished graft")
12418 .await
12419 .unwrap();
12420 let unfinished = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12421 let leftover = winner.worktree.join("leftover");
12422 std::fs::write(&leftover, "untrusted edits").unwrap();
12423 let path = runner.state.repo.join(".git/updater.lock");
12424 let external = std::fs::File::create(&path).unwrap();
12425 assert_eq!(
12426 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12427 0
12428 );
12429 assert!(
12430 runner
12431 .graft()
12432 .await
12433 .unwrap_err()
12434 .is::<crate::repo_lock::ResourceBlocked>()
12435 );
12436 assert!(matches!(
12437 runner.state.graft.as_ref().unwrap().outcome,
12438 GraftOutcome::Running
12439 ));
12440 assert_eq!(
12441 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12442 unfinished
12443 );
12444 assert!(leftover.exists());
12445 drop(external);
12446 std::fs::remove_file(&path).unwrap();
12447 std::fs::create_dir(&path).unwrap();
12448 assert!(runner.graft().await.is_err());
12449 assert!(matches!(
12450 runner.state.graft.as_ref().unwrap().outcome,
12451 GraftOutcome::Running
12452 ));
12453 assert_eq!(
12454 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12455 unfinished
12456 );
12457 assert!(leftover.exists());
12458 std::fs::remove_dir(&path).unwrap();
12459 runner.graft().await.unwrap();
12460 assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12461 assert_eq!(git::rev_parse(&winner.worktree, "HEAD").await.unwrap(), pre);
12462 assert!(!leftover.exists());
12463 }
12464
12465 #[tokio::test]
12466 #[cfg(unix)]
12467 async fn repository_contention_blocks_and_parks_without_poisoning_preparation() {
12468 use std::os::fd::AsRawFd as _;
12469 let tmp = tempfile::tempdir().unwrap();
12470 let mut runner = registered_runner(tmp.path()).await;
12471 runner.state.prep_complete = Some(false);
12472 runner.state.config.graph.timeout_resource_wait = 1;
12473 runner.state.save().unwrap();
12474 let external = std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12475 assert_eq!(
12476 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12477 0
12478 );
12479 runner.execute().await.unwrap();
12480 assert_eq!(runner.state.status, RunStatus::Blocked);
12481 assert_eq!(
12482 runner.state.resource_block.as_ref().unwrap().kind,
12483 "repository coordination"
12484 );
12485 assert!(runner.state.prep_failure.is_none());
12486 assert!(runner.state.seats.is_empty());
12487 runner.pause.park();
12488 tokio::time::timeout(Duration::from_secs(1), runner.execute())
12489 .await
12490 .unwrap()
12491 .unwrap();
12492 assert!(runner.state.parked);
12493 assert!(runner.state.prep_failure.is_none());
12494 }
12495
12496 #[tokio::test]
12497 #[cfg(unix)]
12498 async fn gate_takes_cache_before_repository_so_an_editing_wave_can_finish() {
12499 let tmp = tempfile::tempdir().unwrap();
12500 let mut runner = registered_runner(tmp.path()).await;
12501 let winner = runner.state.candidates[0].clone();
12502 let cache = tmp.path().join("cache");
12503 let started = tmp.path().join("pre-gate-started");
12504 runner.state.config.verify.pre_gate = vec![format!("touch '{}'", started.display())];
12505 runner.state.config.verify.gate =
12506 vec![format!("CARGO_TARGET_DIR='{}' true", cache.display())];
12507 let home = crate::run::home();
12508 let owner =
12509 crate::cache::Owner::here("editing-wave", "implement", "*", &winner.worktree, "HEAD");
12510 let guard = match crate::cache::try_acquire(&home, &cache, &owner).unwrap() {
12511 crate::cache::AcquireOutcome::Acquired(g) => g,
12512 crate::cache::AcquireOutcome::Busy(busy) => panic!("cache must be free: {busy:?}"),
12513 };
12514 let mut gate = Box::pin(runner.run_gate(&winner));
12515 assert!(
12516 tokio::time::timeout(Duration::from_millis(150), &mut gate)
12517 .await
12518 .is_err()
12519 );
12520 assert!(!started.exists(), "pre-gate must wait for the cache too");
12521 let wait = crate::repo_lock::WaitPolicy::new(Duration::from_millis(200), || false);
12525 let editor = crate::repo_lock::SeatLease::checkout_with(
12526 &winner.worktree,
12527 true,
12528 crate::config::test_coordination().as_ref(),
12529 &wait,
12530 )
12531 .await
12532 .unwrap();
12533 drop(editor);
12534 guard.release();
12535 let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12536 .await
12537 .unwrap()
12538 .unwrap();
12539 drop(gate);
12540 assert!(outcomes.iter().all(CommandOutcome::ok));
12541 assert!(started.exists());
12542 }
12543
12544 #[tokio::test]
12545 #[cfg(unix)]
12546 async fn post_seat_rescue_and_gate_wait_for_shared_lock() {
12547 use std::os::fd::AsRawFd as _;
12548
12549 ask_test_home();
12550 let tmp = tempfile::tempdir().unwrap();
12551 let repo = tmp.path().join("repo");
12552 std::fs::create_dir_all(&repo).unwrap();
12553 init_repo(&repo);
12554 let common = repo.join(".git");
12555 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12556 let mut runner = runner_at(RunStatus::Prep);
12557 runner.state.repo = repo.clone();
12558 runner.state.config.coordination = crate::config::test_coordination();
12559 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12560 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12561 runner.state.config.graph.advise = false;
12562 runner.roles.implementers = vec![spec("implement")];
12563 runner.roles.judges = vec![spec("judge")];
12564 runner.prep().await.unwrap();
12565 let winner = runner.state.candidates[0].clone();
12566 std::fs::write(winner.worktree.join("leftover"), "seat work\n").unwrap();
12569 let completed = tmp.path().join("seat-completed");
12570 runner.roles.implementers[0].command = vec![
12571 "sh".into(),
12572 "-c".into(),
12573 format!("touch '{}'; echo completed", completed.display()),
12574 ];
12575 let outsider = std::fs::File::create(common.join("updater.lock")).unwrap();
12576 assert_eq!(
12577 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12578 0
12579 );
12580 let before = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12581 let mut implement = Box::pin(runner.implement());
12582 tokio::select! {
12583 result = &mut implement => panic!("rescue must wait: {result:?}"),
12584 result = tokio::time::timeout(Duration::from_secs(10), async {
12585 while !completed.exists() {
12586 tokio::time::sleep(Duration::from_millis(20)).await;
12587 }
12588 }) => result.unwrap(),
12589 }
12590 assert!(
12591 tokio::time::timeout(Duration::from_millis(200), &mut implement)
12592 .await
12593 .is_err()
12594 );
12595 assert_eq!(
12596 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12597 before
12598 );
12599 assert_eq!(
12600 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12601 0
12602 );
12603 tokio::time::timeout(Duration::from_secs(10), &mut implement)
12604 .await
12605 .unwrap()
12606 .unwrap();
12607 drop(implement);
12608 assert_ne!(
12609 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12610 before
12611 );
12612 assert!(git::is_clean(&winner.worktree).await.unwrap());
12613
12614 let started = tmp.path().join("pre-gate-started");
12615 let proceed = tmp.path().join("pre-gate-proceed");
12616 let checked = tmp.path().join("gate-ran");
12617 runner.state.config.verify.pre_gate = vec![format!(
12618 "echo mechanical >> README.md; touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12619 started.display(),
12620 proceed.display()
12621 )];
12622 runner.state.config.verify.gate = vec![format!("touch '{}'", checked.display())];
12623 assert_eq!(
12624 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12625 0
12626 );
12627 let mut gate = Box::pin(runner.run_gate(&winner));
12628 assert!(
12629 tokio::time::timeout(Duration::from_millis(200), &mut gate)
12630 .await
12631 .is_err()
12632 );
12633 assert!(!started.exists());
12634 assert!(!checked.exists());
12635 assert!(git::is_clean(&winner.worktree).await.unwrap());
12636 assert_eq!(
12637 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12638 0
12639 );
12640 tokio::select! {
12641 result = &mut gate => panic!("pre-gate must wait for signal: {result:?}"),
12642 result = tokio::time::timeout(Duration::from_secs(10), async {
12643 while !started.exists() {
12644 tokio::time::sleep(Duration::from_millis(20)).await;
12645 }
12646 }) => result.unwrap(),
12647 }
12648 assert_eq!(
12649 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12650 -1,
12651 "mechanical edits must hold the real lock"
12652 );
12653 std::fs::write(proceed, "ready").unwrap();
12654 let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12655 .await
12656 .unwrap()
12657 .unwrap();
12658 drop(gate);
12659 assert!(outcomes.iter().all(CommandOutcome::ok));
12660 assert!(checked.exists());
12661 assert!(runner.state.pre_gate_commit.is_some());
12662 assert!(git::is_clean(&winner.worktree).await.unwrap());
12663 assert_eq!(
12664 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12665 0,
12666 "gate completion must release coordination"
12667 );
12668 drop(outsider);
12669 std::fs::remove_file(common.join("updater.lock")).unwrap();
12670 std::fs::create_dir(common.join("updater.lock")).unwrap();
12671 std::fs::remove_file(&started).unwrap();
12672 std::fs::remove_file(&checked).unwrap();
12673 let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12674 let error = runner.run_gate(&winner).await.unwrap_err();
12675 assert!(
12676 error
12677 .to_string()
12678 .contains("acquire mandatory repository lock")
12679 );
12680 assert!(
12681 !started.exists(),
12682 "failed acquisition must prevent pre-gate"
12683 );
12684 assert!(!checked.exists(), "failed acquisition must prevent gate");
12685 assert!(git::is_clean(&winner.worktree).await.unwrap());
12686 assert_eq!(
12687 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12688 head
12689 );
12690 }
12691
12692 #[tokio::test]
12693 #[cfg(unix)]
12694 async fn preparation_waits_for_shared_lock_before_checkout_and_holds_it_through_setup() {
12695 use std::os::fd::AsRawFd as _;
12696 use std::time::Duration;
12697
12698 ask_test_home();
12699 let tmp = tempfile::tempdir().unwrap();
12700 let repo = tmp.path().join("repo");
12701 std::fs::create_dir_all(&repo).unwrap();
12702 init_repo(&repo);
12703 let common = repo.join(".git");
12704 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12705 let lock = std::fs::File::create(common.join("updater.lock")).unwrap();
12706 assert_eq!(
12707 unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12708 0
12709 );
12710 let mut runner = runner_at(RunStatus::Prep);
12711 runner.state.repo = repo.clone();
12712 runner.state.config.coordination = crate::config::test_coordination();
12713 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12714 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12715 runner.state.config.graph.advise = true;
12716 runner.state.config.graph.advisors = 1;
12717 runner.roles.implementers = vec![spec("implement")];
12718 runner.roles.judges = vec![spec("judge")];
12719 let root = runner.state.worktree_root();
12720 let started = tmp.path().join("setup-started");
12721 let proceed = tmp.path().join("setup-proceed");
12722 runner.state.config.worktree.setup = vec![crate::config::SetupStep {
12723 run: Some(format!(
12724 "touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12725 started.display(),
12726 proceed.display()
12727 )),
12728 ..Default::default()
12729 }];
12730 let branches = git::git(&repo, &["branch", "--list"]).await.unwrap();
12731 let config = git::git(&repo, &["config", "--local", "--list"])
12732 .await
12733 .unwrap();
12734 let mut prep = Box::pin(runner.prep());
12735 assert!(
12736 tokio::time::timeout(Duration::from_millis(200), &mut prep)
12737 .await
12738 .is_err()
12739 );
12740 assert!(
12741 !root.exists(),
12742 "contention must prevent even checkout creation"
12743 );
12744 assert!(!started.exists(), "contention must prevent setup");
12745 assert_eq!(
12746 git::git(&repo, &["branch", "--list"]).await.unwrap(),
12747 branches
12748 );
12749 assert_eq!(
12750 git::git(&repo, &["config", "--local", "--list"])
12751 .await
12752 .unwrap(),
12753 config
12754 );
12755 drop(lock);
12756 tokio::select! {
12757 result = &mut prep => panic!("setup must wait for its signal: {result:?}"),
12758 result = tokio::time::timeout(Duration::from_secs(10), async {
12759 while !started.exists() {
12760 tokio::time::sleep(Duration::from_millis(20)).await;
12761 }
12762 }) => result.unwrap(),
12763 }
12764 let outsider = std::fs::File::open(common.join("updater.lock")).unwrap();
12765 assert_eq!(
12766 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12767 -1
12768 );
12769 assert!(
12770 tokio::time::timeout(
12771 Duration::from_millis(100),
12772 crate::repo_lock::SeatLease::for_checkout(
12773 &repo,
12774 true,
12775 crate::config::test_coordination().as_ref()
12776 )
12777 )
12778 .await
12779 .is_err(),
12780 "preparation must also exclude local editors"
12781 );
12782 std::fs::write(proceed, "ready").unwrap();
12783 tokio::time::timeout(Duration::from_secs(10), &mut prep)
12784 .await
12785 .unwrap()
12786 .unwrap();
12787 drop(prep);
12788 assert_eq!(runner.state.prep_complete, Some(true));
12789 assert!(root.join("judge-1").exists());
12790 assert!(root.join("advisor-1").exists());
12791 assert_eq!(
12792 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12793 0
12794 );
12795 }
12796
12797 #[tokio::test]
12798 #[cfg(unix)]
12799 async fn preparation_lock_failure_prevents_repository_changes() {
12800 ask_test_home();
12801 let tmp = tempfile::tempdir().unwrap();
12802 let repo = tmp.path().join("repo");
12803 std::fs::create_dir_all(&repo).unwrap();
12804 init_repo(&repo);
12805 std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12806 std::fs::create_dir(repo.join(".git/updater.lock")).unwrap();
12809 let mut runner = runner_at(RunStatus::Prep);
12810 runner.state.repo = repo.clone();
12811 runner.state.config.coordination = crate::config::test_coordination();
12812 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12813 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12814 runner.roles.implementers = vec![spec("implement")];
12815 let error = runner.prep().await.unwrap_err();
12816 assert!(
12817 error
12818 .to_string()
12819 .contains("acquire mandatory repository lock")
12820 );
12821 assert!(!runner.state.worktree_root().exists());
12822 assert!(runner.state.candidates.is_empty());
12823 assert!(!repo.join(".git/config.worktree").exists());
12824 assert_eq!(
12825 git::git(&repo, &["branch", "--list"]).await.unwrap().trim(),
12826 "* main"
12827 );
12828 }
12829
12830 #[tokio::test]
12831 async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
12832 ask_test_home();
12833 let tmp = tempfile::tempdir().unwrap();
12834 let repo = tmp.path().join("repo");
12835 std::fs::create_dir_all(&repo).unwrap();
12836 init_repo(&repo);
12837 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
12838 git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
12839 std::fs::write(repo.join("referenced"), "required commit").unwrap();
12840 git::git(&repo, &["add", "."]).await.unwrap();
12841 git::git(&repo, &["commit", "-m", "required seed"])
12842 .await
12843 .unwrap();
12844 let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
12845 git::git(&repo, &["checkout", "main"]).await.unwrap();
12846 let mut runner = runner_at(RunStatus::Prep);
12847 runner.state.repo = repo.clone();
12848 runner.state.base_commit = base.clone();
12849 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12850 runner.state.config.graph.advise = false;
12851 runner.state.config.blind.commit_msg_hook = false;
12852 runner.state.seeds = vec![refs::Seed {
12853 token: seed.clone(),
12854 sha: seed.clone(),
12855 kind: refs::SeedKind::Unmerged,
12856 branch: false,
12857 detail: String::new(),
12858 }];
12859 runner.roles.implementers = vec![spec("one"), spec("two")];
12860 runner.roles.judges = vec![spec("judge")];
12861 let label = blind::assign_labels(2, runner.state.seed)[0];
12862 let branch = runner.state.branch_for(label);
12863 let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
12864 git::worktree_add_branch(&repo, &worktree, &branch, &base)
12865 .await
12866 .unwrap();
12867 runner.state.candidates.push(Candidate {
12868 index: 0,
12869 label,
12870 agent: "one".into(),
12871 branch,
12872 worktree,
12873 summary: String::new(),
12874 stat: String::new(),
12875 files: 0,
12876 commits: 0,
12877 empty: false,
12878 failed: None,
12879 verified_noop: None,
12880 duration_ms: 0,
12881 folded: false,
12882 });
12883 runner.state.save().unwrap();
12884 runner.state = RunState::load(&runner.state.id).unwrap();
12886 assert_eq!(runner.state.prep_complete, Some(false));
12887 runner.prep().await.unwrap();
12888 assert_eq!(runner.state.prep_complete, Some(true));
12889 assert_eq!(runner.state.candidates.len(), 2);
12890 for candidate in &runner.state.candidates {
12891 assert_eq!(
12892 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
12893 git::tree_of(&repo, &seed).await.unwrap()
12894 );
12895 assert_eq!(
12896 std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
12897 "required commit"
12898 );
12899 }
12900 assert!(runner.state.worktree_root().join("judge-1").exists());
12901 let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
12902 runner.prep().await.unwrap();
12903 assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
12904
12905 runner.state.prep_complete = Some(false);
12906 runner.state.config.worktree.setup = vec![crate::config::SetupStep {
12907 run: Some(format!(
12908 "test -f '{}'",
12909 tmp.path().join("dependency-ready").display()
12910 )),
12911 ..Default::default()
12912 }];
12913 assert!(runner.prep().await.is_err());
12914 assert!(runner.state.prep_failure.is_some());
12915 std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
12916 runner.state = RunState::load(&runner.state.id).unwrap();
12917 assert!(
12918 runner.prep().await.is_err(),
12919 "correcting the cause alone must not auto-retry"
12920 );
12921 let failed_at = runner.state.events.last().unwrap().at;
12922 assert!(
12923 runner.retry_preparation(failed_at).is_err(),
12924 "old approvals cannot unlock the hold"
12925 );
12926 let mut task = crate::queue::Task::new(
12927 "retry".into(),
12928 "retry setup".into(),
12929 repo.clone(),
12930 crate::queue::Source::Human,
12931 );
12932 task.start(runner.state.id.clone());
12933 task.hold_machine(Some("setup failed".into()));
12934 task.release_operator("cli", None);
12935 runner
12936 .retry_preparation(task.resumption.as_ref().unwrap().at)
12937 .unwrap();
12938 runner.prep().await.unwrap();
12939 assert!(runner.state.prep_failure.is_none());
12940 assert_eq!(runner.state.prep_complete, Some(true));
12941 assert_eq!(runner.state.candidates.len(), 2);
12942 assert!(runner.state.worktree_root().join("judge-1").exists());
12943 for candidate in &runner.state.candidates {
12944 assert_eq!(
12945 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
12946 git::tree_of(&repo, &seed).await.unwrap()
12947 );
12948 }
12949 }
12950
12951 async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
12952 let mut heads = Vec::new();
12953 for candidate in candidates {
12954 heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
12955 }
12956 heads
12957 }
12958
12959 fn init_repo(dir: &Path) {
12960 let run = |args: &[&str]| {
12961 let out = std::process::Command::new("git")
12962 .args(args)
12963 .current_dir(dir)
12964 .quiet()
12965 .output()
12966 .expect("spawn git");
12967 assert!(
12968 out.status.success(),
12969 "git {args:?} failed: {}",
12970 String::from_utf8_lossy(&out.stderr)
12971 );
12972 };
12973 run(&["init", "-b", "main"]);
12974 run(&["config", "user.name", "magi test"]);
12975 run(&["config", "user.email", "magi@example.com"]);
12976 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
12977 run(&["add", "-A"]);
12978 run(&["commit", "-m", "init"]);
12979 }
12980
12981 fn ask_test_home() {
12989 crate::run::pin_test_home();
12990 }
12991
12992 fn runner_at(status: RunStatus) -> Runner {
12995 let mut state = RunState::new(
12996 PathBuf::from("/nonexistent/repo"),
12997 "main".to_owned(),
12998 "deadbeef".to_owned(),
12999 "task".to_owned(),
13000 Config::default(),
13001 );
13002 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13005 state.id = format!(
13006 "20000101-000000-{:04x}",
13007 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
13008 );
13009 state.status = status;
13010 Runner {
13011 state,
13012 roles: ResolvedRoles {
13013 implementers: Vec::new(),
13014 judges: Vec::new(),
13015 reviewers: Vec::new(),
13016 fixer: None,
13017 conductor: conductor(),
13018 implementer_roster: Vec::new(),
13019 judge_roster: Vec::new(),
13020 reviewer_roster: Vec::new(),
13021 },
13022 sem: Arc::new(Semaphore::new(1)),
13023 pause: Pause::new(),
13024 interrupt: Pause::new(),
13025 }
13026 }
13027
13028 fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
13029 crate::run::MergeRepair {
13030 question: "repair-question".into(),
13031 head: "old-head".into(),
13032 pr: "https://github.com/o/r/pull/42".into(),
13033 reviews: vec![review_round(false, 1, 1, 1, false, true)],
13034 gate: vec![outcome(Some(0), false)],
13035 gate_head: Some("old-head".into()),
13036 gate_fixes: Vec::new(),
13037 pre_gate: Vec::new(),
13038 phase,
13039 failure: None,
13040 }
13041 }
13042
13043 #[tokio::test]
13044 async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
13045 crate::run::pin_test_home();
13046 let mut runner = runner_at(RunStatus::Landing);
13047 runner.state.merge_repairs.push(merge_repair_request(
13048 crate::run::MergeRepairPhase::Requested,
13049 ));
13050 runner.state.save().unwrap();
13051 let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
13052 assert!(
13053 runner
13054 .execute()
13055 .await
13056 .unwrap_err()
13057 .to_string()
13058 .contains("already running")
13059 );
13060 assert_eq!(
13061 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13062 crate::run::MergeRepairPhase::Requested
13063 );
13064 drop(claim);
13065 let mut disk = runner.state.clone();
13066 disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
13067 disk.save().unwrap();
13068 assert!(
13069 runner
13070 .execute()
13071 .await
13072 .unwrap_err()
13073 .to_string()
13074 .contains("decision changed")
13075 );
13076 assert_eq!(
13077 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13078 crate::run::MergeRepairPhase::Complete
13079 );
13080 }
13081
13082 #[test]
13083 fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
13084 crate::run::pin_test_home();
13085 let mut runner = runner_at(RunStatus::Landing);
13086 let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
13087 runner.state.reviews = request.reviews.clone();
13088 runner.state.gate = request.gate.clone();
13089 runner.state.gate_ran = true;
13090 runner.state.land_approval = Some(crate::run::LandApproval {
13091 question: "old".into(),
13092 head: "old-head".into(),
13093 });
13094 runner.state.merge_repairs.push(request);
13095 runner.begin_merge_repair_review(0).unwrap();
13096 assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
13097 assert!(!runner.state.gate_status().ok());
13098 assert!(runner.state.land_approval.is_none());
13099 let disk = RunState::load(&runner.state.id).unwrap();
13100 assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
13101 assert!(disk.merge_repairs[0].gate[0].ok());
13102 assert_eq!(
13103 disk.merge_repairs[0].phase,
13104 crate::run::MergeRepairPhase::Reviewing
13105 );
13106 assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
13107 assert!(disk.reviews.is_empty());
13108 }
13109
13110 #[tokio::test]
13111 async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
13112 crate::run::pin_test_home();
13113 for phase in [
13114 crate::run::MergeRepairPhase::Fixing,
13115 crate::run::MergeRepairPhase::Requested,
13116 ] {
13117 let mut runner = runner_at(RunStatus::Landing);
13118 runner.state.config.graph.land_rounds = 0;
13119 runner.state.merge_repairs.push(merge_repair_request(phase));
13120 runner.state.save().unwrap();
13121 assert!(!runner.repair_merge_review().await.unwrap());
13122 assert_eq!(runner.state.status, RunStatus::Blocked);
13123 assert_eq!(
13124 runner.state.merge_repairs[0].phase,
13125 crate::run::MergeRepairPhase::Failed
13126 );
13127 let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
13128 assert!(
13129 reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
13130 "interrupted"
13131 } else {
13132 "budget exhausted"
13133 })
13134 );
13135 assert!(runner.state.merge.is_none());
13136 }
13137 }
13138
13139 #[test]
13143 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
13144 crate::run::pin_test_home();
13145 let mut runner = runner_at(RunStatus::Implementing);
13146 let interrupt = Pause::new();
13147 runner.watch_interrupt(interrupt.clone());
13148
13149 interrupt.park_because("task a1b2 asked to run first");
13150
13151 assert!(runner.park_here().expect("park_here"));
13152 assert!(runner.state.parked);
13153 let last = runner.state.events.last().expect("a park event");
13154 assert_eq!(last.node, "park");
13155 assert!(
13156 last.message.contains("task a1b2 asked to run first"),
13157 "expected the interrupt reason in {:?}",
13158 last.message
13159 );
13160 }
13161
13162 #[test]
13165 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
13166 crate::run::pin_test_home();
13167 let mut runner = runner_at(RunStatus::Implementing);
13168 runner.state.begin_pass();
13169 let shutdown = Pause::new();
13170 runner.on_pause(shutdown.clone());
13171 let upgrade = ParkCause::Upgrade {
13172 from: "0.121.0".to_owned(),
13173 to: Some("0.121.1".to_owned()),
13174 };
13175 shutdown.park_for(upgrade.clone());
13176 shutdown.park_for(ParkCause::Operator);
13177
13178 assert!(runner.park_here().expect("park_here"));
13179 assert_eq!(
13180 runner.state.passes[0].end,
13181 Some(crate::run::PassEnd::Parked { cause: upgrade })
13182 );
13183 let last = runner.state.events.last().expect("a park event");
13184 assert!(last.message.contains("0.121.0"), "{}", last.message);
13185 }
13186
13187 #[test]
13195 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
13196 crate::run::pin_test_home();
13197 let mut runner = runner_at(RunStatus::Implementing);
13198 let shutdown = Pause::new();
13199 runner.on_pause(shutdown.clone());
13200 let interrupt = Pause::new();
13201 runner.watch_interrupt(interrupt.clone());
13202
13203 assert!(!runner.park_here().expect("park_here"));
13205 assert!(!runner.state.parked);
13206
13207 interrupt.park_because("test");
13209 assert!(!shutdown.parked());
13210 assert!(runner.park_here().expect("park_here"));
13211 }
13212
13213 #[tokio::test]
13227 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
13228 crate::run::pin_test_home();
13229 let mut runner = runner_at(RunStatus::Implementing);
13230 let interrupt = Pause::new();
13231 runner.watch_interrupt(interrupt.clone());
13232
13233 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
13234 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
13235
13236 let node = async move {
13240 started_tx.send(()).expect("send started");
13241 finish_rx.await.expect("recv finish");
13242 "node finished"
13243 };
13244
13245 let interrupter = async move {
13246 started_rx.await.expect("recv started");
13247 interrupt.park_because("higher-priority task waiting");
13249 tokio::task::yield_now().await;
13253 finish_tx.send(()).expect("send finish");
13254 };
13255
13256 let (node_result, ()) = tokio::join!(node, interrupter);
13257 assert_eq!(
13258 node_result, "node finished",
13259 "the in-flight call ran to completion"
13260 );
13261
13262 assert!(runner.park_here().expect("park_here"));
13265 assert!(runner.state.parked);
13266 }
13267
13268 #[test]
13274 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
13275 crate::run::pin_test_home();
13276 let mut runner = runner_at(RunStatus::Judging);
13277 runner.state.config.agents = vec![conductor()];
13281 runner.state.candidates = vec![Candidate {
13282 index: 0,
13283 label: 'A',
13284 agent: "alpha".to_owned(),
13285 branch: "magi/x/A".to_owned(),
13286 worktree: PathBuf::from("/nonexistent/worktree"),
13287 summary: "did the thing".to_owned(),
13288 stat: "1 file changed".to_owned(),
13289 files: 1,
13290 commits: 1,
13291 empty: false,
13292 failed: None,
13293 verified_noop: None,
13294 duration_ms: 1234,
13295 folded: false,
13296 }];
13297 let run_id = runner.state.id.clone();
13298
13299 let interrupt = Pause::new();
13300 runner.watch_interrupt(interrupt.clone());
13301 interrupt.park_because("task c3d4 asked to run first");
13302 assert!(runner.park_here().expect("park_here"));
13303
13304 let resumed = Runner::resume(&run_id).expect("resume");
13305 assert_eq!(resumed.state.candidates.len(), 1);
13306 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
13307 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
13308 assert_eq!(resumed.state.status, runner.state.status);
13309 assert!(
13310 resumed.state.parked,
13311 "still parked until `execute` actually walks the graph again"
13312 );
13313 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
13314 }
13315
13316 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
13318 let mut q = ask::Question::new(
13319 run.to_owned(),
13320 "implement".to_owned(),
13321 "impl-A".to_owned(),
13322 "Which storage backend should the cache use?".to_owned(),
13323 String::new(),
13324 vec!["SQLite".to_owned(), "Redis".to_owned()],
13325 );
13326 store.put(&mut q).unwrap();
13327 q
13328 }
13329
13330 #[test]
13331 fn a_failed_runs_open_question_is_abandoned() {
13332 ask_test_home();
13333 let store = ask::Questions::open();
13334 let mut runner = runner_at(RunStatus::Failed);
13335 let run = runner.state.id.clone();
13336 let q = ask_open_question(&store, &run);
13337
13338 runner.settle_questions();
13339
13340 let back = store.get(&q.id).unwrap();
13341 assert!(
13342 !back.status.open(),
13343 "the seat that asked died with the run; nobody is left to read an answer"
13344 );
13345 assert!(
13346 back.detail.contains(&run) && back.detail.contains("failed"),
13347 "the reason names what the run became, not just that it is gone: {}",
13348 back.detail
13349 );
13350 }
13351
13352 #[test]
13353 fn a_merged_runs_open_question_is_abandoned_too() {
13354 ask_test_home();
13355 let store = ask::Questions::open();
13356 for status in [RunStatus::Merged, RunStatus::Ready] {
13359 let mut runner = runner_at(status);
13360 let run = runner.state.id.clone();
13361 let q = ask_open_question(&store, &run);
13362
13363 runner.settle_questions();
13364
13365 let back = store.get(&q.id).unwrap();
13366 assert!(
13367 !back.status.open(),
13368 "{status:?} run's question must not outlive the run"
13369 );
13370 }
13371 }
13372
13373 #[test]
13374 fn a_still_resumable_runs_open_question_is_left_alone() {
13375 ask_test_home();
13376 let store = ask::Questions::open();
13377 for status in [RunStatus::Blocked, RunStatus::Stalled] {
13383 let mut runner = runner_at(status);
13384 let run = runner.state.id.clone();
13385 let q = ask_open_question(&store, &run);
13386
13387 runner.settle_questions();
13388
13389 let back = store.get(&q.id).unwrap();
13390 assert!(
13391 back.status.open(),
13392 "{status:?} is still alive; the question must still be waiting"
13393 );
13394 }
13395 }
13396
13397 #[test]
13398 fn settle_questions_never_touches_an_already_answered_question() {
13399 ask_test_home();
13400 let store = ask::Questions::open();
13401 let mut runner = runner_at(RunStatus::Failed);
13402 let run = runner.state.id.clone();
13403 let mut q = ask_open_question(&store, &run);
13404 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
13405 .unwrap();
13406 store.put(&mut q).unwrap();
13407
13408 runner.settle_questions();
13413 runner.settle_questions();
13414
13415 let back = store.get(&q.id).unwrap();
13416 assert_eq!(
13417 back.status,
13418 ask::QuestionStatus::Answered,
13419 "a real answer is a decision on record, never overwritten by a sweep"
13420 );
13421 }
13422
13423 #[tokio::test]
13434 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
13435 crate::run::pin_test_home();
13436 let tmp = tempfile::tempdir().expect("tempdir");
13437 let repo = tmp.path().join("repo");
13438 std::fs::create_dir_all(&repo).unwrap();
13439 init_repo(&repo);
13440
13441 let mut config = Config::default();
13442 config.graph.worktree_root = Some(tmp.path().join("wt"));
13443
13444 let mut state = RunState::new(
13445 repo.clone(),
13446 "main".to_owned(),
13447 "deadbeef".to_owned(),
13448 "task".to_owned(),
13449 config,
13450 );
13451 let root = state.worktree_root();
13452 let wt_a = root.join("cand-A");
13453 let wt_b = root.join("cand-B");
13454 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
13455 .await
13456 .expect("worktree A");
13457 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
13458 .await
13459 .expect("worktree B");
13460
13461 state.candidates = vec![
13462 Candidate {
13463 index: 0,
13464 label: 'A',
13465 agent: "alpha".to_owned(),
13466 branch: "magi/x/A".to_owned(),
13467 worktree: wt_a.clone(),
13468 summary: String::new(),
13469 stat: String::new(),
13470 files: 0,
13471 commits: 0,
13472 empty: false,
13473 failed: None,
13474 verified_noop: None,
13475 duration_ms: 0,
13476 folded: false,
13477 },
13478 Candidate {
13479 index: 1,
13480 label: 'B',
13481 agent: "beta".to_owned(),
13482 branch: "magi/x/B".to_owned(),
13483 worktree: wt_b.clone(),
13484 summary: String::new(),
13485 stat: String::new(),
13486 files: 0,
13487 commits: 0,
13488 empty: false,
13489 failed: None,
13490 verified_noop: None,
13491 duration_ms: 0,
13492 folded: false,
13493 },
13494 ];
13495 state.tally = Some(Tally {
13496 first_choice: BTreeMap::from([('A', 1)]),
13497 borda: BTreeMap::new(),
13498 winner: 'A',
13499 rankings: 1,
13500 unanimous_initial: true,
13501 deliberated: false,
13502 changed_votes: 0,
13503 unanimous_final: true,
13504 tie_break: None,
13505 judges: 1,
13506 present: 1,
13507 quorum: 1,
13508 met_quorum: true,
13509 uncontested: None,
13510 });
13511 state.status = RunStatus::Ready;
13512
13513 fold_run(&mut state, false, &crate::run::home())
13514 .await
13515 .expect("fold_run");
13516
13517 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
13518 assert!(
13519 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
13520 "the unmerged winner's branch survives"
13521 );
13522 assert!(
13523 !state.candidates[0].folded,
13524 "the winner is not marked folded"
13525 );
13526
13527 assert!(!wt_b.exists(), "the loser's worktree is removed");
13528 assert!(
13529 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
13530 "the loser's branch is removed"
13531 );
13532 assert!(state.candidates[1].folded, "the loser is marked folded");
13533 }
13534
13535 #[tokio::test]
13536 #[cfg(unix)]
13537 async fn fold_run_waits_for_shared_lock_before_removing_worktrees_branches_or_config() {
13538 use std::os::fd::AsRawFd as _;
13539 use std::time::Duration;
13540
13541 let tmp = tempfile::tempdir().unwrap();
13542 let repo = tmp.path().join("repo");
13543 std::fs::create_dir_all(&repo).unwrap();
13544 init_repo(&repo);
13545 let common = repo.join(".git");
13546 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
13547 let home = tmp.path().join("home");
13548 let mut config = Config::default();
13549 config.coordination = crate::config::test_coordination();
13550 config.graph.worktree_root = Some(tmp.path().join("wt"));
13551 config.graph.timeout_resource_wait = 1;
13552 let mut state = RunState::new(
13553 repo.clone(),
13554 "main".into(),
13555 "deadbeef".into(),
13556 "task".into(),
13557 config,
13558 );
13559 let root = state.worktree_root();
13560 let candidate = root.join("cand-A");
13561 let reviewer = root.join("review-1");
13562 let branch = state.branch_for('A');
13563 git::worktree_add_branch(&repo, &candidate, &branch, "main")
13564 .await
13565 .unwrap();
13566 git::worktree_add_detached(&repo, &reviewer, "main")
13567 .await
13568 .unwrap();
13569 git::acquire_worktree_config(&repo).await.unwrap();
13570 state.enabled_worktree_config = true;
13571 state.status = RunStatus::Ready;
13572 state.candidates.push(Candidate {
13573 index: 0,
13574 label: 'A',
13575 agent: "implement".into(),
13576 branch: branch.clone(),
13577 worktree: candidate.clone(),
13578 summary: String::new(),
13579 stat: String::new(),
13580 files: 0,
13581 commits: 0,
13582 empty: false,
13583 failed: None,
13584 verified_noop: None,
13585 duration_ms: 0,
13586 folded: false,
13587 });
13588 state.save_under(&home).unwrap();
13589 let config_before = std::fs::read(common.join("config")).unwrap();
13590 let worktrees_before = git::git(&repo, &["worktree", "list", "--porcelain"])
13591 .await
13592 .unwrap();
13593 let external = std::fs::File::create(common.join("updater.lock")).unwrap();
13596 assert_eq!(
13597 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
13598 0
13599 );
13600 let mut fold = Box::pin(fold_run(&mut state, true, &home));
13601 assert!(
13602 tokio::time::timeout(Duration::from_millis(200), &mut fold)
13603 .await
13604 .is_err()
13605 );
13606 assert!(candidate.exists());
13607 assert!(reviewer.exists());
13608 assert!(git::branch_exists(&repo, &branch).await.unwrap());
13609 assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
13610 assert_eq!(
13611 git::git(&repo, &["worktree", "list", "--porcelain"])
13612 .await
13613 .unwrap(),
13614 worktrees_before
13615 );
13616 let error = tokio::time::timeout(Duration::from_secs(3), &mut fold)
13617 .await
13618 .unwrap()
13619 .unwrap_err();
13620 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
13621 drop(fold);
13622 assert!(candidate.exists() && reviewer.exists());
13623 assert!(!state.candidates[0].folded && state.enabled_worktree_config);
13624 assert_eq!(
13625 state.resource_block.as_ref().unwrap().kind,
13626 "repository coordination"
13627 );
13628 assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
13629 drop(external);
13630 let mut fold = Box::pin(fold_run(&mut state, true, &home));
13631 let removed = tokio::time::timeout(Duration::from_secs(10), &mut fold)
13632 .await
13633 .unwrap()
13634 .unwrap();
13635 drop(fold);
13636 assert!(removed.contains(&branch));
13637 assert!(!root.exists());
13638 assert!(!git::branch_exists(&repo, &branch).await.unwrap());
13639 assert!(state.candidates[0].folded);
13640 assert!(!state.enabled_worktree_config);
13641 assert_ne!(std::fs::read(common.join("config")).unwrap(), config_before);
13642 let enabled = git::git_raw(
13643 &repo,
13644 &["config", "--local", "--get", "extensions.worktreeConfig"],
13645 )
13646 .await
13647 .unwrap();
13648 assert!(!enabled.ok(), "the last config reference was released");
13649 }
13650
13651 #[tokio::test]
13654 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
13655 let tmp = tempfile::tempdir().expect("tempdir");
13656 let repo = tmp.path().join("repo");
13657 std::fs::create_dir_all(&repo).unwrap();
13658 init_repo(&repo);
13659 let home = tmp.path().join("home");
13660
13661 let mut config = Config::default();
13662 config.graph.worktree_root = Some(tmp.path().join("wt"));
13663 let mut state = RunState::new(
13664 repo.clone(),
13665 "main".to_owned(),
13666 "deadbeef".to_owned(),
13667 "task".to_owned(),
13668 config,
13669 );
13670 git::git(&repo, &["branch", "magi/x/A", "main"])
13672 .await
13673 .expect("branch");
13674 state.candidates = vec![Candidate {
13675 index: 0,
13676 label: 'A',
13677 agent: "alpha".to_owned(),
13678 branch: "magi/x/A".to_owned(),
13679 worktree: state.worktree_root().join("cand-A"),
13680 summary: String::new(),
13681 stat: String::new(),
13682 files: 0,
13683 commits: 0,
13684 empty: false,
13685 failed: None,
13686 verified_noop: None,
13687 duration_ms: 0,
13688 folded: true,
13689 }];
13690 state.released_to = Some("20260901-000000-new1".to_owned());
13691 state.released_branches = vec!["magi/x/A".to_owned()];
13692
13693 fold_run(&mut state, true, &home).await.expect("fold_run");
13694
13695 assert!(
13696 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
13697 "the handed-over branch survives a fold"
13698 );
13699 }
13700
13701 #[tokio::test]
13705 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
13706 let tmp = tempfile::tempdir().expect("tempdir");
13707 let repo = tmp.path().join("repo");
13708 std::fs::create_dir_all(&repo).unwrap();
13709 init_repo(&repo);
13710 let run = |args: &[&str]| {
13711 let out = std::process::Command::new("git")
13712 .quiet()
13713 .args(args)
13714 .current_dir(&repo)
13715 .output()
13716 .expect("spawn git");
13717 assert!(out.status.success(), "git {args:?}");
13718 };
13719 run(&["branch", "magi/x/A"]);
13720 run(&["checkout", "-q", "-b", "magi/x/B"]);
13721 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
13722 run(&["add", "-A"]);
13723 run(&["commit", "-q", "-m", "work"]);
13724 run(&["checkout", "-q", "main"]);
13725
13726 let probe = RunState::new(
13730 repo.clone(),
13731 "main".to_owned(),
13732 "deadbeef".to_owned(),
13733 "task".to_owned(),
13734 Config::default(),
13735 );
13736 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
13737 let bare = tmp.path().join("origin.git");
13738 let out = std::process::Command::new("git")
13739 .quiet()
13740 .args(["init", "-q", "--bare"])
13741 .arg(&bare)
13742 .output()
13743 .expect("spawn git");
13744 assert!(out.status.success(), "git init --bare");
13745 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
13746 run(&["push", "-q", "origin", "main"]);
13747
13748 let mut state = RunState::new(
13749 repo.clone(),
13750 "main".to_owned(),
13751 "deadbeef".to_owned(),
13752 "task".to_owned(),
13753 Config::default(),
13754 );
13755 state.seeds = vec![refs::Seed {
13756 token: "magi/27b2/A".to_owned(),
13757 kind: refs::SeedKind::Unresolved,
13758 sha: String::new(),
13759 branch: true,
13760 detail: "no branch or commit named magi/27b2/A".to_owned(),
13761 }];
13762
13763 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
13764 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
13765 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
13766 let detail = empty_candidate_detail(&state, "main");
13767 assert!(detail.starts_with("empty candidate"), "{detail}");
13768 assert!(detail.contains("magi/27b2/A"), "{detail}");
13769 }
13770
13771 #[tokio::test]
13780 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
13781 let tmp = tempfile::tempdir().expect("tempdir");
13782 let repo = tmp.path().join("repo");
13783 std::fs::create_dir_all(&repo).unwrap();
13784 init_repo(&repo);
13785
13786 let mut config = Config::default();
13787 config.merge.mode = MergeMode::Local;
13788
13789 let mut state = RunState::new(
13790 repo.clone(),
13791 "main".to_owned(),
13792 "deadbeef".to_owned(),
13793 "task".to_owned(),
13794 config,
13795 );
13796 state.candidates = vec![Candidate {
13797 index: 0,
13798 label: 'A',
13799 agent: "alpha".to_owned(),
13800 branch: "does-not-exist".to_owned(),
13801 worktree: repo.clone(),
13802 summary: String::new(),
13803 stat: String::new(),
13804 files: 0,
13805 commits: 0,
13806 empty: false,
13807 failed: None,
13808 verified_noop: None,
13809 duration_ms: 0,
13810 folded: false,
13811 }];
13812 state.tally = Some(Tally {
13813 first_choice: BTreeMap::from([('A', 1)]),
13814 borda: BTreeMap::new(),
13815 winner: 'A',
13816 rankings: 1,
13817 unanimous_initial: true,
13818 deliberated: false,
13819 changed_votes: 0,
13820 unanimous_final: true,
13821 tie_break: None,
13822 judges: 0,
13823 present: 0,
13824 quorum: 0,
13825 met_quorum: true,
13826 uncontested: Some("only candidate A produced a change".to_owned()),
13827 });
13828 state.reviews = vec![ReviewRound {
13829 round: 1,
13830 head: "deadbeef".to_owned(),
13831 verified_head: None,
13832 verified_at: None,
13833 reviews: Vec::new(),
13834 e2e: Vec::new(),
13835 fix: None,
13836 blocking: 0,
13837 answered: 0,
13838 expected: 0,
13839 clean: true,
13840 verify_retried: false,
13841 e2e_deferred: false,
13842 e2e_defer_reason: None,
13843 progressed: false,
13844 vote_split: false,
13845 reconsideration: Vec::new(),
13846 verdict: None,
13847 }];
13848 state.gate = vec![CommandOutcome {
13849 command: "test".to_owned(),
13850 code: Some(0),
13851 output_tail: String::new(),
13852 duration_ms: 0,
13853 resource_blocked: false,
13854 }];
13855 state.gate_ran = true;
13856 state.status = RunStatus::Ready;
13861 state.merge = Some(MergeOutcome {
13862 mode: MergeMode::Local,
13863 ok: false,
13864 detail: "already concluded".to_owned(),
13865 empty: false,
13866 });
13867
13868 let mut runner = Runner {
13869 state,
13870 roles: ResolvedRoles {
13871 implementers: Vec::new(),
13872 judges: Vec::new(),
13873 reviewers: Vec::new(),
13874 fixer: None,
13875 conductor: conductor(),
13876 implementer_roster: Vec::new(),
13877 judge_roster: Vec::new(),
13878 reviewer_roster: Vec::new(),
13879 },
13880 sem: Arc::new(Semaphore::new(1)),
13881 pause: Pause::new(),
13882 interrupt: Pause::new(),
13883 };
13884
13885 runner.merge().await.expect("merge");
13886
13887 assert_eq!(
13888 runner.state.status,
13889 RunStatus::Ready,
13890 "a concluded run's status must not change on reentry"
13891 );
13892 assert_eq!(
13893 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13894 Some("already concluded"),
13895 "merge must not run again once the node already recorded an outcome"
13896 );
13897 }
13898
13899 #[tokio::test]
13908 async fn merge_refuses_a_gate_that_has_not_actually_run() {
13909 let tmp = tempfile::tempdir().expect("tempdir");
13910 let repo = tmp.path().join("repo");
13911 std::fs::create_dir_all(&repo).unwrap();
13912 init_repo(&repo);
13913
13914 let mut config = Config::default();
13915 config.merge.mode = MergeMode::Local;
13916
13917 let mut state = RunState::new(
13918 repo.clone(),
13919 "main".to_owned(),
13920 "deadbeef".to_owned(),
13921 "task".to_owned(),
13922 config,
13923 );
13924 state.candidates = vec![Candidate {
13925 index: 0,
13926 label: 'A',
13927 agent: "alpha".to_owned(),
13928 branch: "does-not-exist".to_owned(),
13929 worktree: repo.clone(),
13930 summary: String::new(),
13931 stat: String::new(),
13932 files: 0,
13933 commits: 0,
13934 empty: false,
13935 failed: None,
13936 verified_noop: None,
13937 duration_ms: 0,
13938 folded: false,
13939 }];
13940 state.tally = Some(Tally {
13941 first_choice: BTreeMap::from([('A', 1)]),
13942 borda: BTreeMap::new(),
13943 winner: 'A',
13944 rankings: 1,
13945 unanimous_initial: true,
13946 deliberated: false,
13947 changed_votes: 0,
13948 unanimous_final: true,
13949 tie_break: None,
13950 judges: 0,
13951 present: 0,
13952 quorum: 0,
13953 met_quorum: true,
13954 uncontested: Some("only candidate A produced a change".to_owned()),
13955 });
13956 state.reviews = vec![ReviewRound {
13957 round: 1,
13958 head: "deadbeef".to_owned(),
13959 verified_head: None,
13960 verified_at: None,
13961 reviews: Vec::new(),
13962 e2e: Vec::new(),
13963 fix: None,
13964 blocking: 0,
13965 answered: 0,
13966 expected: 0,
13967 clean: true,
13968 verify_retried: false,
13969 e2e_deferred: false,
13970 e2e_defer_reason: None,
13971 progressed: false,
13972 vote_split: false,
13973 reconsideration: Vec::new(),
13974 verdict: None,
13975 }];
13976 state.gate = Vec::new();
13978 state.gate_ran = false;
13979 state.status = RunStatus::Gating;
13980
13981 let mut runner = Runner {
13982 state,
13983 roles: ResolvedRoles {
13984 implementers: Vec::new(),
13985 judges: Vec::new(),
13986 reviewers: Vec::new(),
13987 fixer: None,
13988 conductor: conductor(),
13989 implementer_roster: Vec::new(),
13990 judge_roster: Vec::new(),
13991 reviewer_roster: Vec::new(),
13992 },
13993 sem: Arc::new(Semaphore::new(1)),
13994 pause: Pause::new(),
13995 interrupt: Pause::new(),
13996 };
13997
13998 runner.merge().await.expect("merge");
13999
14000 assert!(
14001 runner.state.merge.is_none(),
14002 "an empty gate must never be read as a passing one: {:?}",
14003 runner.state.merge
14004 );
14005 }
14006
14007 #[tokio::test]
14014 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
14015 let tmp = tempfile::tempdir().expect("tempdir");
14016 let repo = tmp.path().join("repo");
14017 std::fs::create_dir_all(&repo).unwrap();
14018 init_repo(&repo);
14019
14020 let config = Config::default();
14022
14023 let mut state = RunState::new(
14024 repo.clone(),
14025 "main".to_owned(),
14026 "deadbeef".to_owned(),
14027 "task".to_owned(),
14028 config,
14029 );
14030 state.candidates = vec![Candidate {
14031 index: 0,
14032 label: 'A',
14033 agent: "alpha".to_owned(),
14034 branch: "does-not-exist".to_owned(),
14035 worktree: repo.clone(),
14036 summary: String::new(),
14037 stat: String::new(),
14038 files: 0,
14039 commits: 0,
14040 empty: false,
14041 failed: None,
14042 verified_noop: None,
14043 duration_ms: 0,
14044 folded: false,
14045 }];
14046 state.tally = Some(Tally {
14047 first_choice: BTreeMap::from([('A', 1)]),
14048 borda: BTreeMap::new(),
14049 winner: 'A',
14050 rankings: 1,
14051 unanimous_initial: true,
14052 deliberated: false,
14053 changed_votes: 0,
14054 unanimous_final: true,
14055 tie_break: None,
14056 judges: 0,
14057 present: 0,
14058 quorum: 0,
14059 met_quorum: true,
14060 uncontested: Some("only candidate A produced a change".to_owned()),
14061 });
14062 state.reviews = vec![ReviewRound {
14063 round: 1,
14064 head: "deadbeef".to_owned(),
14065 verified_head: None,
14066 verified_at: None,
14067 reviews: Vec::new(),
14068 e2e: Vec::new(),
14069 fix: None,
14070 blocking: 0,
14071 answered: 0,
14072 expected: 0,
14073 clean: true,
14074 verify_retried: false,
14075 e2e_deferred: false,
14076 e2e_defer_reason: None,
14077 progressed: false,
14078 vote_split: false,
14079 reconsideration: Vec::new(),
14080 verdict: None,
14081 }];
14082
14083 let mut runner = Runner {
14084 state,
14085 roles: ResolvedRoles {
14086 implementers: Vec::new(),
14087 judges: Vec::new(),
14088 reviewers: Vec::new(),
14089 fixer: None,
14090 conductor: conductor(),
14091 implementer_roster: Vec::new(),
14092 judge_roster: Vec::new(),
14093 reviewer_roster: Vec::new(),
14094 },
14095 sem: Arc::new(Semaphore::new(1)),
14096 pause: Pause::new(),
14097 interrupt: Pause::new(),
14098 };
14099
14100 runner.gate().await.expect("gate");
14101 assert!(
14102 runner.state.gate_ran,
14103 "zero configured commands is still a real attempt, not an unrun gate"
14104 );
14105 assert!(runner.state.gate.is_empty());
14106 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
14107 assert_ne!(
14108 runner.state.status,
14109 RunStatus::Blocked,
14110 "a gate with nothing to check must not read as failed"
14111 );
14112
14113 runner.merge().await.expect("merge");
14114 assert_eq!(
14115 runner.state.status,
14116 RunStatus::Ready,
14117 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
14118 );
14119 }
14120
14121 #[tokio::test]
14132 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
14133 crate::run::pin_test_home();
14134 let home = crate::run::home();
14135
14136 let tmp = tempfile::tempdir().expect("tempdir");
14137 let repo = tmp.path().join("repo");
14138 std::fs::create_dir_all(&repo).unwrap();
14139 init_repo(&repo);
14140 let cache_dir = tmp.path().join("target");
14143
14144 let mut config = Config::default();
14145 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
14146 config.graph.timeout_verify = Some(2);
14149 config.graph.timeout_resource_wait = 2;
14150
14151 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14152 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14153 .expect("no io error acquiring directly")
14154 {
14155 crate::cache::AcquireOutcome::Acquired(g) => g,
14156 crate::cache::AcquireOutcome::Busy(b) => {
14157 panic!("expected the direct acquire to win the lease first: {b:?}")
14158 }
14159 };
14160
14161 let mut state = RunState::new(
14162 repo.clone(),
14163 "main".to_owned(),
14164 "deadbeef".to_owned(),
14165 "task".to_owned(),
14166 config,
14167 );
14168 state.candidates = vec![Candidate {
14169 index: 0,
14170 label: 'A',
14171 agent: "alpha".to_owned(),
14172 branch: "does-not-exist".to_owned(),
14173 worktree: repo.clone(),
14174 summary: String::new(),
14175 stat: String::new(),
14176 files: 0,
14177 commits: 0,
14178 empty: false,
14179 failed: None,
14180 verified_noop: None,
14181 duration_ms: 0,
14182 folded: false,
14183 }];
14184 state.tally = Some(Tally {
14185 first_choice: BTreeMap::from([('A', 1)]),
14186 borda: BTreeMap::new(),
14187 winner: 'A',
14188 rankings: 1,
14189 unanimous_initial: true,
14190 deliberated: false,
14191 changed_votes: 0,
14192 unanimous_final: true,
14193 tie_break: None,
14194 judges: 0,
14195 present: 0,
14196 quorum: 0,
14197 met_quorum: true,
14198 uncontested: Some("only candidate A produced a change".to_owned()),
14199 });
14200 state.reviews = vec![ReviewRound {
14201 round: 1,
14202 head: "deadbeef".to_owned(),
14203 verified_head: None,
14204 verified_at: None,
14205 reviews: Vec::new(),
14206 e2e: Vec::new(),
14207 fix: None,
14208 blocking: 0,
14209 answered: 0,
14210 expected: 0,
14211 clean: true,
14212 verify_retried: false,
14213 e2e_deferred: false,
14214 e2e_defer_reason: None,
14215 progressed: false,
14216 vote_split: false,
14217 reconsideration: Vec::new(),
14218 verdict: None,
14219 }];
14220
14221 let mut runner = Runner {
14222 state,
14223 roles: ResolvedRoles {
14224 implementers: Vec::new(),
14225 judges: Vec::new(),
14226 reviewers: Vec::new(),
14227 fixer: None,
14228 conductor: conductor(),
14229 implementer_roster: Vec::new(),
14230 judge_roster: Vec::new(),
14231 reviewer_roster: Vec::new(),
14232 },
14233 sem: Arc::new(Semaphore::new(1)),
14234 pause: Pause::new(),
14235 interrupt: Pause::new(),
14236 };
14237
14238 let started = std::time::Instant::now();
14239 runner.gate().await.expect("gate");
14240 assert!(
14241 started.elapsed() < Duration::from_secs(1),
14242 "a gate with nothing to run must never wait on a lease it never needed"
14243 );
14244 assert!(
14245 runner.state.gate_ran,
14246 "zero commands is still a real, immediate attempt"
14247 );
14248 assert!(runner.state.gate.is_empty());
14249 assert_ne!(
14250 runner.state.status,
14251 RunStatus::Blocked,
14252 "must not read as resource-blocked on a lease it never asked for"
14253 );
14254 }
14255
14256 #[tokio::test]
14266 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
14267 crate::run::pin_test_home();
14268
14269 let tmp = tempfile::tempdir().expect("tempdir");
14270 let repo = tmp.path().join("repo");
14271 std::fs::create_dir_all(&repo).unwrap();
14272 init_repo(&repo);
14273
14274 let mut config = Config::default();
14275 config.verify.gate = vec![
14276 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
14277 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
14278 .to_owned(),
14279 ];
14280
14281 let mut state = RunState::new(
14282 repo.clone(),
14283 "main".to_owned(),
14284 "deadbeef".to_owned(),
14285 "task".to_owned(),
14286 config,
14287 );
14288 let run_id = state.id.clone();
14289 state.candidates = vec![Candidate {
14290 index: 0,
14291 label: 'A',
14292 agent: "alpha".to_owned(),
14293 branch: "does-not-exist".to_owned(),
14294 worktree: repo.clone(),
14295 summary: String::new(),
14296 stat: String::new(),
14297 files: 0,
14298 commits: 0,
14299 empty: false,
14300 failed: None,
14301 verified_noop: None,
14302 duration_ms: 0,
14303 folded: false,
14304 }];
14305 state.tally = Some(Tally {
14306 first_choice: BTreeMap::from([('A', 1)]),
14307 borda: BTreeMap::new(),
14308 winner: 'A',
14309 rankings: 1,
14310 unanimous_initial: true,
14311 deliberated: false,
14312 changed_votes: 0,
14313 unanimous_final: true,
14314 tie_break: None,
14315 judges: 0,
14316 present: 0,
14317 quorum: 0,
14318 met_quorum: true,
14319 uncontested: Some("only candidate A produced a change".to_owned()),
14320 });
14321 state.reviews = vec![ReviewRound {
14322 round: 1,
14323 head: "deadbeef".to_owned(),
14324 verified_head: None,
14325 verified_at: None,
14326 reviews: Vec::new(),
14327 e2e: Vec::new(),
14328 fix: None,
14329 blocking: 0,
14330 answered: 0,
14331 expected: 0,
14332 clean: true,
14333 verify_retried: false,
14334 e2e_deferred: false,
14335 e2e_defer_reason: None,
14336 progressed: false,
14337 vote_split: false,
14338 reconsideration: Vec::new(),
14339 verdict: None,
14340 }];
14341
14342 let mut runner = Runner {
14343 state,
14344 roles: ResolvedRoles {
14345 implementers: Vec::new(),
14346 judges: Vec::new(),
14347 reviewers: Vec::new(),
14348 fixer: None,
14349 conductor: conductor(),
14350 implementer_roster: Vec::new(),
14351 judge_roster: Vec::new(),
14352 reviewer_roster: Vec::new(),
14353 },
14354 sem: Arc::new(Semaphore::new(1)),
14355 pause: Pause::new(),
14356 interrupt: Pause::new(),
14357 };
14358
14359 let started_marker = repo.join("started.marker");
14360 let release_marker = repo.join("release.marker");
14361 let poller = tokio::spawn(async move {
14362 for _ in 0..100 {
14367 if started_marker.exists()
14368 && let Ok(s) = crate::run::RunState::load(&run_id)
14369 && let Some(a) = s.active.get("gate")
14370 {
14371 std::fs::write(&release_marker, b"go").expect("release marker");
14372 return Some(a.clone());
14373 }
14374 tokio::time::sleep(Duration::from_millis(50)).await;
14375 }
14376 None
14377 });
14378
14379 runner.gate().await.expect("gate");
14380 let captured = poller.await.expect("poller task");
14381 let captured = captured.expect(
14382 "the poller never saw a `gate` task entry in run.json while the command was \
14383 still blocked on its own release marker",
14384 );
14385
14386 assert_eq!(captured.task.as_deref(), Some("gate"));
14387 assert_eq!(captured.node, "gate");
14388 assert_eq!(captured.index, Some(1));
14389 assert_eq!(captured.total, Some(1));
14390 assert!(
14391 captured
14392 .command
14393 .as_deref()
14394 .is_some_and(|c| c.contains("started.marker")),
14395 "{captured:?}"
14396 );
14397
14398 assert!(
14399 runner.state.active.is_empty(),
14400 "the entry must be cleared once the command actually finished: {:?}",
14401 runner.state.active
14402 );
14403 assert!(runner.state.gate_ran);
14404 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
14405 }
14406
14407 #[tokio::test]
14410 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
14411 use crate::verdict::{Finding, ReviewVote, Severity};
14412 crate::run::pin_test_home();
14413 let tmp = tempfile::tempdir().expect("tempdir");
14414 let repo = tmp.path().join("repo");
14415 std::fs::create_dir_all(&repo).unwrap();
14416 init_repo(&repo);
14417
14418 for (severity, vote, expect) in [
14419 (Severity::Major, ReviewVote::Reject, true),
14420 (Severity::Minor, ReviewVote::Reject, false),
14421 (Severity::Major, ReviewVote::Approve, false),
14422 ] {
14423 let mut round = review_round(false, 1, 1, 1, false, true);
14424 round.reviews = vec![ReviewRecord {
14425 reviewer: 1,
14426 agent: "alpha".to_owned(),
14427 summary: String::new(),
14428 findings: vec![Finding {
14429 id: "R1-1-1".to_owned(),
14430 severity,
14431 file: None,
14432 line: None,
14433 title: "t".to_owned(),
14434 detail: String::new(),
14435 }],
14436 vote: Some(vote),
14437 failed: None,
14438 duration_ms: 0,
14439 attempts: 0,
14440 }];
14441 let mut state = RunState::new(
14442 repo.clone(),
14443 "main".to_owned(),
14444 "deadbeef".to_owned(),
14445 "task".to_owned(),
14446 Config::default(),
14447 );
14448 state.reviews = vec![round];
14449 let mut runner = Runner {
14450 state,
14451 roles: ResolvedRoles {
14452 implementers: Vec::new(),
14453 judges: Vec::new(),
14454 reviewers: Vec::new(),
14455 fixer: None,
14456 conductor: conductor(),
14457 implementer_roster: Vec::new(),
14458 judge_roster: Vec::new(),
14459 reviewer_roster: Vec::new(),
14460 },
14461 sem: Arc::new(Semaphore::new(1)),
14462 pause: Pause::new(),
14463 interrupt: Pause::new(),
14464 };
14465 let shell = runner.state.config.shell();
14466 runner
14467 .stop_reviewing("round budget spent", &shell, &repo)
14468 .await
14469 .expect("stop_reviewing");
14470 assert_eq!(runner.state.status, RunStatus::Gating);
14471 assert_eq!(
14472 runner.state.contested_handoff.is_some(),
14473 expect,
14474 "{severity:?} + {vote:?}"
14475 );
14476 }
14477 }
14478
14479 #[tokio::test]
14492 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
14493 crate::run::pin_test_home();
14494 let home = crate::run::home();
14495
14496 let tmp = tempfile::tempdir().expect("tempdir");
14497 let repo = tmp.path().join("repo");
14498 std::fs::create_dir_all(&repo).unwrap();
14499 init_repo(&repo);
14500 let head = crate::git::rev_parse(&repo, "HEAD")
14501 .await
14502 .expect("rev-parse");
14503 let cache_dir = tmp.path().join("target");
14506
14507 let mut config = Config::default();
14508 config.verify.e2e = vec![format!(
14509 "CARGO_TARGET_DIR='{}' test -f README.md",
14510 cache_dir.display()
14511 )];
14512 config.graph.review_rounds = 1;
14513 config.graph.timeout_verify = Some(2);
14516 config.graph.timeout_resource_wait = 2;
14517
14518 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14519 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14520 .expect("no io error acquiring directly")
14521 {
14522 crate::cache::AcquireOutcome::Acquired(g) => g,
14523 crate::cache::AcquireOutcome::Busy(b) => {
14524 panic!("expected the direct acquire to win the lease first: {b:?}")
14525 }
14526 };
14527
14528 let mut state = RunState::new(
14529 repo.clone(),
14530 "main".to_owned(),
14531 head.clone(),
14532 "task".to_owned(),
14533 config,
14534 );
14535 state.candidates = vec![Candidate {
14536 index: 0,
14537 label: 'A',
14538 agent: "alpha".to_owned(),
14539 branch: "does-not-exist".to_owned(),
14540 worktree: repo.clone(),
14541 summary: String::new(),
14542 stat: String::new(),
14543 files: 0,
14544 commits: 0,
14545 empty: false,
14546 failed: None,
14547 verified_noop: None,
14548 duration_ms: 0,
14549 folded: false,
14550 }];
14551 state.tally = Some(Tally {
14552 first_choice: BTreeMap::from([('A', 1)]),
14553 borda: BTreeMap::new(),
14554 winner: 'A',
14555 rankings: 1,
14556 unanimous_initial: true,
14557 deliberated: false,
14558 changed_votes: 0,
14559 unanimous_final: true,
14560 tie_break: None,
14561 judges: 0,
14562 present: 0,
14563 quorum: 0,
14564 met_quorum: true,
14565 uncontested: Some("only candidate A produced a change".to_owned()),
14566 });
14567 state.reviews = vec![ReviewRound {
14571 round: 1,
14572 head: head.clone(),
14573 verified_head: None,
14574 verified_at: None,
14575 reviews: Vec::new(),
14576 e2e: Vec::new(),
14577 fix: None,
14578 blocking: 1,
14579 answered: 1,
14580 expected: 1,
14581 clean: false,
14582 verify_retried: false,
14583 e2e_deferred: true,
14584 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
14585 progressed: false,
14586 vote_split: false,
14587 reconsideration: Vec::new(),
14588 verdict: None,
14589 }];
14590
14591 let mut runner = Runner {
14592 state,
14593 roles: ResolvedRoles {
14594 implementers: Vec::new(),
14595 judges: Vec::new(),
14596 reviewers: Vec::new(),
14597 fixer: None,
14598 conductor: conductor(),
14599 implementer_roster: Vec::new(),
14600 judge_roster: Vec::new(),
14601 reviewer_roster: Vec::new(),
14602 },
14603 sem: Arc::new(Semaphore::new(1)),
14604 pause: Pause::new(),
14605 interrupt: Pause::new(),
14606 };
14607
14608 let shell = runner.state.config.shell();
14609 runner
14610 .stop_reviewing("round budget spent", &shell, &repo)
14611 .await
14612 .expect("stop_reviewing");
14613
14614 let last = runner.state.reviews.last().expect("round record");
14615 assert_eq!(
14616 last.e2e_status(),
14617 E2eStatus::ResourceBlocked,
14618 "the shared cache is still held; the attempt must read as blocked, not deferred or \
14619 failed: {last:?}"
14620 );
14621 assert_eq!(
14622 last.verified_head.as_deref(),
14623 Some(head.as_str()),
14624 "which commit this attempt targeted is known even though nothing finished checking \
14625 it"
14626 );
14627 let first_attempt_at = last
14628 .verified_at
14629 .expect("when this attempt ran is known too");
14630 assert_ne!(
14631 runner.state.status,
14632 RunStatus::Blocked,
14633 "contention is evidence about the machine, not the patch — it must not settle the \
14634 run as blocked: {:?}",
14635 runner.state.status
14636 );
14637 assert!(
14638 !runner
14639 .state
14640 .events
14641 .iter()
14642 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
14643 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
14644 runner.state.events
14645 );
14646
14647 runner
14652 .stop_reviewing("round budget spent", &shell, &repo)
14653 .await
14654 .expect("stop_reviewing retry");
14655 assert_eq!(
14656 runner.state.reviews.len(),
14657 1,
14658 "no new round was started: {:?}",
14659 runner.state.reviews
14660 );
14661 let last = runner.state.reviews.last().expect("round record");
14662 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
14663 assert!(
14664 last.verified_at.expect("still known") > first_attempt_at,
14665 "a second reentry must be a fresh attempt, not a stale copy of the first"
14666 );
14667 assert_ne!(runner.state.status, RunStatus::Blocked);
14668
14669 held.release();
14670 }
14671
14672 #[tokio::test]
14684 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
14685 crate::run::pin_test_home();
14686 let home = crate::run::home();
14687
14688 let tmp = tempfile::tempdir().expect("tempdir");
14689 let repo = tmp.path().join("repo");
14690 std::fs::create_dir_all(&repo).unwrap();
14691 init_repo(&repo);
14692 let head = crate::git::rev_parse(&repo, "HEAD")
14693 .await
14694 .expect("rev-parse");
14695 let cache_dir = tmp.path().join("target");
14696
14697 let mut config = Config::default();
14698 config.verify.e2e = vec![format!(
14699 "CARGO_TARGET_DIR='{}' test -f README.md",
14700 cache_dir.display()
14701 )];
14702 config.graph.review_rounds = 1;
14703 config.graph.timeout_verify = Some(2);
14704 config.graph.timeout_resource_wait = 2;
14705
14706 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14707 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14708 .expect("no io error acquiring directly")
14709 {
14710 crate::cache::AcquireOutcome::Acquired(g) => g,
14711 crate::cache::AcquireOutcome::Busy(b) => {
14712 panic!("expected the direct acquire to win the lease first: {b:?}")
14713 }
14714 };
14715
14716 let mut state = RunState::new(
14717 repo.clone(),
14718 "main".to_owned(),
14719 head.clone(),
14720 "task".to_owned(),
14721 config,
14722 );
14723 state.candidates = vec![Candidate {
14724 index: 0,
14725 label: 'A',
14726 agent: "alpha".to_owned(),
14727 branch: "does-not-exist".to_owned(),
14728 worktree: repo.clone(),
14729 summary: String::new(),
14730 stat: String::new(),
14731 files: 0,
14732 commits: 0,
14733 empty: false,
14734 failed: None,
14735 verified_noop: None,
14736 duration_ms: 0,
14737 folded: false,
14738 }];
14739 state.tally = Some(Tally {
14740 first_choice: BTreeMap::from([('A', 1)]),
14741 borda: BTreeMap::new(),
14742 winner: 'A',
14743 rankings: 1,
14744 unanimous_initial: true,
14745 deliberated: false,
14746 changed_votes: 0,
14747 unanimous_final: true,
14748 tie_break: None,
14749 judges: 0,
14750 present: 0,
14751 quorum: 0,
14752 met_quorum: true,
14753 uncontested: Some("only candidate A produced a change".to_owned()),
14754 });
14755 state.reviews = vec![ReviewRound {
14759 round: 1,
14760 head: head.clone(),
14761 verified_head: Some(head.clone()),
14762 verified_at: Some(jiff::Timestamp::now()),
14763 reviews: Vec::new(),
14764 e2e: vec![CommandOutcome {
14765 command: format!(
14766 "CARGO_TARGET_DIR='{}' test -f README.md",
14767 cache_dir.display()
14768 ),
14769 code: None,
14770 output_tail: "waiting for the shared build cache".to_owned(),
14771 duration_ms: 0,
14772 resource_blocked: true,
14773 }],
14774 fix: None,
14775 blocking: 1,
14776 answered: 1,
14777 expected: 1,
14778 clean: false,
14779 verify_retried: false,
14780 e2e_deferred: false,
14781 e2e_defer_reason: None,
14782 progressed: false,
14783 vote_split: false,
14784 reconsideration: Vec::new(),
14785 verdict: None,
14786 }];
14787
14788 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
14789 let mut runner = Runner {
14790 state,
14791 roles: ResolvedRoles {
14792 implementers: Vec::new(),
14793 judges: Vec::new(),
14794 reviewers: Vec::new(),
14795 fixer: None,
14796 conductor: conductor(),
14797 implementer_roster: Vec::new(),
14798 judge_roster: Vec::new(),
14799 reviewer_roster: Vec::new(),
14800 },
14801 sem: Arc::new(Semaphore::new(1)),
14802 pause: Pause::new(),
14803 interrupt: Pause::new(),
14804 };
14805
14806 runner.review_loop().await.expect("review_loop");
14811
14812 assert_eq!(
14813 runner.state.reviews.len(),
14814 1,
14815 "no new round was started on top of the unresolved one: {:?}",
14816 runner.state.reviews
14817 );
14818 let last = &runner.state.reviews[0];
14819 assert_eq!(
14820 last.e2e_status(),
14821 E2eStatus::ResourceBlocked,
14822 "still contended: {last:?}"
14823 );
14824 assert!(
14825 last.verified_at.expect("still known") > first_attempt_at,
14826 "review_loop must have actually retried the check, not left it exactly as found"
14827 );
14828 assert_ne!(
14829 runner.state.status,
14830 RunStatus::Blocked,
14831 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
14832 runner.state.status
14833 );
14834
14835 held.release();
14836 }
14837
14838 #[tokio::test]
14839 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
14840 crate::run::pin_test_home();
14841 let tmp = tempfile::tempdir().expect("tempdir");
14842 let repo = tmp.path().join("repo");
14843 std::fs::create_dir_all(&repo).unwrap();
14844 init_repo(&repo);
14845
14846 let mut config = Config::default();
14847 config.merge.mode = MergeMode::Pr;
14848 config.graph.land = true;
14849 config.graph.land_approval = false;
14850
14851 let mut state = RunState::new(
14852 repo.clone(),
14853 "main".to_owned(),
14854 "deadbeef".to_owned(),
14855 "task".to_owned(),
14856 config,
14857 );
14858 state.candidates = vec![Candidate {
14859 index: 0,
14860 label: 'A',
14861 agent: "alpha".to_owned(),
14862 branch: "does-not-exist".to_owned(),
14863 worktree: repo.clone(),
14864 summary: String::new(),
14865 stat: String::new(),
14866 files: 0,
14867 commits: 0,
14868 empty: false,
14869 failed: None,
14870 verified_noop: None,
14871 duration_ms: 0,
14872 folded: false,
14873 }];
14874 state.tally = Some(Tally {
14875 first_choice: BTreeMap::from([('A', 1)]),
14876 borda: BTreeMap::new(),
14877 winner: 'A',
14878 rankings: 1,
14879 unanimous_initial: true,
14880 deliberated: false,
14881 changed_votes: 0,
14882 unanimous_final: true,
14883 tie_break: None,
14884 judges: 0,
14885 present: 0,
14886 quorum: 0,
14887 met_quorum: true,
14888 uncontested: Some("only candidate A produced a change".to_owned()),
14889 });
14890 state.reviews = vec![ReviewRound {
14891 round: 1,
14892 head: "deadbeef".to_owned(),
14893 verified_head: None,
14894 verified_at: None,
14895 reviews: Vec::new(),
14896 e2e: Vec::new(),
14897 fix: None,
14898 blocking: 0,
14899 answered: 0,
14900 expected: 0,
14901 clean: true,
14902 verify_retried: false,
14903 e2e_deferred: false,
14904 e2e_defer_reason: None,
14905 progressed: false,
14906 vote_split: false,
14907 reconsideration: Vec::new(),
14908 verdict: None,
14909 }];
14910 state.gate = vec![CommandOutcome {
14911 command: "test".to_owned(),
14912 code: Some(0),
14913 output_tail: String::new(),
14914 duration_ms: 0,
14915 resource_blocked: false,
14916 }];
14917 state.gate_ran = true;
14918 state.status = RunStatus::Landing;
14922 state.merge = Some(MergeOutcome {
14923 mode: MergeMode::Pr,
14924 ok: true,
14925 detail: "https://example.invalid/x/y/pull/1".to_owned(),
14926 empty: false,
14927 });
14928
14929 ask_test_home();
14933 state.save().expect("save landing run");
14936 let store = ask::Questions::open();
14937 let q = ask_open_question(&store, &state.id);
14938
14939 let mut runner = Runner {
14940 state,
14941 roles: ResolvedRoles {
14942 implementers: Vec::new(),
14943 judges: Vec::new(),
14944 reviewers: Vec::new(),
14945 fixer: None,
14946 conductor: conductor(),
14947 implementer_roster: Vec::new(),
14948 judge_roster: Vec::new(),
14949 reviewer_roster: Vec::new(),
14950 },
14951 sem: Arc::new(Semaphore::new(1)),
14952 pause: Pause::new(),
14953 interrupt: Pause::new(),
14954 };
14955
14956 runner.execute().await.expect("execute");
14961
14962 assert_eq!(
14963 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
14964 Some("https://example.invalid/x/y/pull/1"),
14965 "reentry must not push again or open a second pull request over the \
14966 one `land` is already watching"
14967 );
14968 assert_ne!(
14969 runner.state.status,
14970 RunStatus::Landing,
14971 "land could not actually reach the fake pull request, so it must \
14972 have given up rather than left the run silently parked forever"
14973 );
14974 assert_eq!(runner.state.status, RunStatus::Blocked);
14978 assert!(
14979 store.get(&q.id).unwrap().status.open(),
14980 "Blocked is still alive; settle_questions must have been a no-op here"
14981 );
14982 }
14983
14984 fn state_with_round(round: ReviewRound) -> RunState {
14985 let mut s = RunState::new(
14986 PathBuf::from("/repo"),
14987 "main".to_owned(),
14988 "abc1234".to_owned(),
14989 "add retries".to_owned(),
14990 Config::default(),
14991 );
14992 s.reviews = vec![round];
14993 s
14994 }
14995
14996 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
14997 crate::verdict::Finding {
14998 id: id.to_owned(),
14999 severity,
15000 file: None,
15001 line: None,
15002 title: title.to_owned(),
15003 detail: String::new(),
15004 }
15005 }
15006
15007 #[test]
15008 fn pr_body_names_open_findings_and_declined_ones() {
15009 let round = ReviewRound {
15010 round: 2,
15011 head: "deadbee".to_owned(),
15012 verified_head: None,
15013 verified_at: None,
15014 reviews: vec![ReviewRecord {
15015 attempts: 0,
15016 reviewer: 1,
15017 agent: "alpha".to_owned(),
15018 summary: String::new(),
15019 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
15020 vote: None,
15021 failed: None,
15022 duration_ms: 0,
15023 }],
15024 e2e: vec![CommandOutcome {
15025 command: "cargo test".to_owned(),
15026 code: Some(0),
15027 output_tail: String::new(),
15028 duration_ms: 0,
15029 resource_blocked: false,
15030 }],
15031 verify_retried: false,
15032 e2e_deferred: false,
15033 e2e_defer_reason: None,
15034 fix: Some(FixRecord {
15035 agent: "alpha".to_owned(),
15036 addressed: Vec::new(),
15037 rejected: vec![crate::verdict::Rejection {
15038 id: "R1-1-1".to_owned(),
15039 why: "not reachable from any caller".to_owned(),
15040 }],
15041 notes: String::new(),
15042 committed: true,
15043 failed: None,
15044 duration_ms: 0,
15045 continuation: None,
15046 }),
15047 blocking: 0,
15048 answered: 1,
15049 expected: 1,
15050 clean: false,
15051 progressed: true,
15052 vote_split: false,
15053 reconsideration: Vec::new(),
15054 verdict: None,
15055 };
15056 let state = state_with_round(round);
15057 let body = pr_message(&state, 'A').body;
15058
15059 assert!(body.contains("add retries"), "the task must still be there");
15060 assert!(body.contains("R2-1-1"), "{body}");
15061 assert!(!body.contains("unused import"), "{body}");
15062 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
15063 assert!(
15064 !body.contains("not reachable from any caller"),
15065 "the reason it was declined: {body}"
15066 );
15067 }
15068
15069 #[test]
15070 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
15071 let round = ReviewRound {
15072 round: 1,
15073 head: "deadbee".to_owned(),
15074 verified_head: None,
15075 verified_at: None,
15076 reviews: vec![ReviewRecord {
15077 attempts: 0,
15078 reviewer: 1,
15079 agent: "alpha".to_owned(),
15080 summary: String::new(),
15081 findings: Vec::new(),
15082 vote: None,
15083 failed: None,
15084 duration_ms: 0,
15085 }],
15086 e2e: Vec::new(),
15087 verify_retried: false,
15088 e2e_deferred: false,
15089 e2e_defer_reason: None,
15090 fix: None,
15091 blocking: 0,
15092 answered: 1,
15093 expected: 1,
15094 clean: true,
15095 progressed: false,
15096 vote_split: false,
15097 reconsideration: Vec::new(),
15098 verdict: None,
15099 };
15100 let state = state_with_round(round);
15101 let body = pr_message(&state, 'A').body;
15102 assert!(!body.contains("Open review findings"), "{body}");
15103 assert!(!body.contains("Declined"), "{body}");
15104 }
15105
15106 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
15107 let mut state = RunState::new(
15108 PathBuf::from("/repo"),
15109 "main".to_owned(),
15110 "abc1234".to_owned(),
15111 instruction.to_owned(),
15112 Config::default(),
15113 );
15114 state.candidates.push(Candidate {
15115 index: 0,
15116 label: 'A',
15117 agent: "alpha".to_owned(),
15118 branch: "magi/x/A".to_owned(),
15119 worktree: PathBuf::from("/wt"),
15120 summary: summary.to_owned(),
15121 stat: String::new(),
15122 files: 1,
15123 commits: 1,
15124 empty: false,
15125 failed: None,
15126 verified_noop: None,
15127 folded: false,
15128 duration_ms: 0,
15129 });
15130 state
15131 }
15132
15133 fn review_state(subjects: &[&str]) -> RunState {
15134 let mut state = state_with_summary(
15135 "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",
15136 "",
15137 );
15138 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15139 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
15140 state
15141 }
15142
15143 #[tokio::test]
15146 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
15147 ask_test_home();
15148 let tmp = tempfile::tempdir().unwrap();
15149 let repo = tmp.path().join("repo");
15150 std::fs::create_dir_all(&repo).unwrap();
15151 init_repo(&repo);
15152 let origin = tmp.path().join("origin.git");
15153 let g = |dir: &Path, args: &[&str]| {
15154 let out = std::process::Command::new("git")
15155 .args(args)
15156 .current_dir(dir)
15157 .quiet()
15158 .output()
15159 .expect("spawn git");
15160 assert!(
15161 out.status.success(),
15162 "git {args:?}: {}",
15163 String::from_utf8_lossy(&out.stderr)
15164 );
15165 };
15166 g(
15167 tmp.path(),
15168 &[
15169 "clone",
15170 "--bare",
15171 "-q",
15172 repo.to_str().unwrap(),
15173 origin.to_str().unwrap(),
15174 ],
15175 );
15176 g(
15177 &repo,
15178 &["remote", "add", "origin", origin.to_str().unwrap()],
15179 );
15180 let c1 = git::rev_parse(&repo, "main").await.unwrap();
15181
15182 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
15184 g(&repo, &["add", "-A"]);
15185 g(
15186 &repo,
15187 &["commit", "-q", "-m", "chore(deps): update a crate"],
15188 );
15189 g(&repo, &["push", "-q", "origin", "main"]);
15190 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
15191 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
15192 g(&repo, &["add", "-A"]);
15193 g(
15194 &repo,
15195 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
15196 );
15197 g(&repo, &["checkout", "-q", "main"]);
15198
15199 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
15200 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
15201 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
15203 assert_eq!(fallback, c1);
15204
15205 let mut state = review_state(&[
15206 "chore(deps): update a crate",
15207 "fix(daemon): apply a chosen action",
15208 ]);
15209 state.repo = repo.clone();
15210 state.base_branch = "main".to_owned();
15211 state.base_commit = c1;
15212 refresh_reviewed_commits(&mut state, "feat/own").await;
15213 assert_eq!(
15214 state.reviewed_commits,
15215 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
15216 );
15217 assert_eq!(
15218 review_title(&state).as_deref(),
15219 Some("fix(daemon): apply a chosen action")
15220 );
15221 assert_eq!(
15222 leaked_subjects(&state, "feat/own").await,
15223 Some(vec!["chore(deps): update a crate".to_owned()])
15224 );
15225 state.config.merge.remote = "nowhere".to_owned();
15228 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
15229 }
15230
15231 struct SummaryFixture {
15235 _tmp: tempfile::TempDir,
15236 repo: PathBuf,
15237 reply: PathBuf,
15238 runner: Runner,
15239 }
15240
15241 fn summary_fixture() -> SummaryFixture {
15242 ask_test_home();
15243 let tmp = tempfile::tempdir().unwrap();
15244 let repo = tmp.path().join("repo");
15245 std::fs::create_dir_all(&repo).unwrap();
15246 init_repo(&repo);
15247 let origin = tmp.path().join("origin.git");
15248 let g = |args: &[&str]| {
15249 let out = std::process::Command::new("git")
15250 .args(args)
15251 .current_dir(&repo)
15252 .quiet()
15253 .output()
15254 .expect("spawn git");
15255 assert!(
15256 out.status.success(),
15257 "git {args:?}: {}",
15258 String::from_utf8_lossy(&out.stderr)
15259 );
15260 };
15261 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
15262 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
15263 g(&["push", "-q", "origin", "main"]);
15264 g(&["checkout", "-q", "-b", "feat/own"]);
15265 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
15266 g(&["add", "-A"]);
15267 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
15268 let reply = tmp.path().join("reply.txt");
15269 std::fs::write(
15270 &reply,
15271 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
15272 )
15273 .unwrap();
15274 let mut runner = runner_at(RunStatus::Gating);
15275 let mut state = review_state(&["feat(own): add the own file"]);
15276 state.repo = repo.clone();
15277 state.base_branch = "main".to_owned();
15278 state.candidates[0].branch = "feat/own".to_owned();
15279 state.candidates[0].worktree = repo.clone();
15280 let mut author = spec("alpha");
15281 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
15282 author
15283 .env
15284 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
15285 state.config.agents = vec![author];
15286 runner.state = state;
15287 SummaryFixture {
15288 _tmp: tmp,
15289 repo,
15290 reply,
15291 runner,
15292 }
15293 }
15294
15295 async fn posted(f: &mut SummaryFixture) -> PrMessage {
15296 let winner = f.runner.state.candidates[0].clone();
15297 f.runner
15298 .guarded_pr_message(&winner, None, true)
15299 .await
15300 .unwrap()
15301 .expect("an English description never parks")
15302 }
15303
15304 fn clean_round_with_minor_findings() -> ReviewRound {
15305 let mut round = passing_round(2, None);
15306 round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
15307 round.reviews = vec![ReviewRecord {
15308 attempts: 0,
15309 reviewer: 1,
15310 agent: "alpha".into(),
15311 summary: "内部レビュー".into(),
15312 findings: vec![
15313 finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
15314 finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
15315 ],
15316 vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
15317 failed: None,
15318 duration_ms: 0,
15319 }];
15320 round
15321 }
15322
15323 #[tokio::test]
15324 async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
15325 for review_only in [false, true] {
15326 let mut f = summary_fixture();
15327 if !review_only {
15328 f.runner.state.candidates[0].agent = "alpha".into();
15329 }
15330 f.runner.state.reviews = vec![clean_round_with_minor_findings()];
15331 assert!(f.runner.state.reviews[0].clean);
15332 assert!(f.runner.state.reviews[0].fix.is_none());
15333 assert!(f.runner.state.open_findings().is_empty());
15334 assert_eq!(
15335 crate::pr_outcome::required_finding_ids(&f.runner.state),
15336 ["R2-1-1", "R2-1-2"]
15337 );
15338 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15339 let evidence = crate::pr_outcome::summary_evidence(
15340 &f.runner.state,
15341 crate::pr_outcome::FinalHead::At(&head),
15342 );
15343 assert!(
15344 evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
15345 );
15346 let omitted = posted(&mut f).await;
15348 assert!(omitted.body.contains("No description"));
15349 assert!(omitted.body.contains("`R2-1-1` [Minor]"));
15350 assert!(omitted.body.contains("`R2-1-2` [Minor]"));
15351 assert!(f.runner.state.change_summary.is_none());
15352 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nAdds the own file. R2-1-1 [Minor]: improve documentation for callers. R2-1-2 [Minor]: consider the remaining edge case.").unwrap();
15353 let described = posted(&mut f).await;
15354 assert!(described.body.contains("improve documentation for callers"));
15355 assert!(described.body.contains("remaining edge case"));
15356 assert!(described.body.contains("## Open review findings"));
15357 assert!(!described.body.contains("未修正"));
15358 }
15359 }
15360
15361 async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
15362 let mut f = summary_fixture();
15363 if !review_only {
15364 f.runner.state.candidates[0].agent = "alpha".into();
15365 }
15366 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15367 let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
15368 fixed
15369 .fix
15370 .as_mut()
15371 .unwrap()
15372 .rejected
15373 .push(crate::verdict::Rejection {
15374 id: "R1-1-1".into(),
15375 why: "呼び出されないコードのため辞退".into(),
15376 });
15377 let mut clean = clean_round_with_minor_findings();
15378 clean.verified_head = Some(head.clone());
15379 f.runner.state.reviews = vec![fixed, clean];
15380 ok_gate(&mut f.runner.state, Some(&head));
15381 std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
15382 f.runner.state.config.agents.push(scripted("beta", reply));
15383 f.runner.state.config.roles.language_judge =
15384 Some(crate::config::AgentChoice::One("beta".into()));
15385 f
15386 }
15387
15388 #[tokio::test]
15389 async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
15390 for review_only in [false, true] {
15391 let mut f = rewrite_fixture_with_findings(review_only,
15392 "TITLE: fix: public description\nR1-1-1: declined because no caller reaches this code. R2-1-1 [Minor]: documentation remains incomplete. R2-1-2 [Minor]: an edge case needs follow-up.").await;
15393 let m = posted(&mut f).await;
15394 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15395 let expected =
15396 public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
15397 assert!(m.body.contains(&expected));
15398 assert!(m.body.contains("## Review rounds"));
15399 assert!(
15400 m.body
15401 .contains("Gate (`verify.gate`): passed on the final commit")
15402 );
15403 assert!(
15404 m.body
15405 .contains("Tests (`verify.e2e`): passed on the final commit")
15406 );
15407 let c = f.runner.state.change_summary.as_ref().unwrap();
15408 assert!(
15409 m.body
15410 .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
15411 );
15412 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
15413 assert!(crate::pr_outcome::covers_required_findings(
15414 &f.runner.state,
15415 &m.body
15416 ));
15417 assert!(m.body.contains("documentation remains incomplete"));
15418 assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
15419 assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
15420 assert!(f.runner.state.github_text.is_none());
15421 }
15422 }
15423
15424 #[tokio::test]
15425 async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
15426 for review_only in [false, true] {
15427 for reply in [
15428 "TITLE: fix: public description\nA short English description.",
15429 "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
15430 ] {
15431 let mut f = rewrite_fixture_with_findings(review_only, reply).await;
15432 let winner = f.runner.state.candidates[0].clone();
15433 let posted = f
15434 .runner
15435 .guarded_pr_message(&winner, None, true)
15436 .await
15437 .unwrap();
15438 assert!(posted.is_none());
15439 assert!(f.runner.state.parked);
15440 let withheld = f.runner.state.github_text.as_ref().unwrap();
15441 assert!(
15442 withheld.rewrite.is_none(),
15443 "an incomplete rewrite must not be selectable"
15444 );
15445 let q = ask::Questions::open()
15446 .get(withheld.question.as_ref().unwrap())
15447 .unwrap();
15448 assert!(
15449 !q.choices
15450 .contains(&crate::github_text::USE_REWRITE.to_owned())
15451 );
15452 assert!(
15453 q.detail.contains("R1-1-1")
15454 && q.detail.contains("R2-1-1")
15455 && q.detail.contains("R2-1-2")
15456 );
15457 }
15458 }
15459 }
15460
15461 #[tokio::test]
15462 async fn japanese_reports_stay_local_in_both_public_run_modes() {
15463 for review_only in [false, true] {
15464 let mut f = summary_fixture();
15465 if !review_only {
15466 f.runner.state.candidates[0].agent = "alpha".into();
15467 }
15468 f.runner.state.instruction = "内部タスクの説明".into();
15469 f.runner.state.candidates[0].summary = "内部の候補報告".into();
15470 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15471 f.runner.state.reviews = vec![
15472 round_with_notes(1, Some("コミットできません。テストは実行できません。")),
15473 round_with_notes(2, Some("二回目の内部修正報告です。")),
15474 passing_round(3, Some(&head)),
15475 ];
15476 f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
15477 ok_gate(&mut f.runner.state, Some(&head));
15478 let captured = f.repo.join("public-prompt.txt");
15479 let author = &mut f.runner.state.config.agents[0];
15480 author.command = vec![
15481 "sh".into(),
15482 "-c".into(),
15483 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
15484 ];
15485 author.env.insert(
15486 "PROMPT_FILE".into(),
15487 captured.to_string_lossy().into_owned(),
15488 );
15489 let m = posted(&mut f).await;
15490 assert!(m.body.contains("Adds `own.txt`"));
15491 assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
15492 assert!(m.body.contains("R1-1-1"));
15493 assert!(
15494 m.body
15495 .contains("Gate (`verify.gate`): passed on the final commit")
15496 );
15497 assert!(
15498 m.body
15499 .contains("Tests (`verify.e2e`): passed on the final commit")
15500 );
15501 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
15502 assert!(!m.body.contains("内部") && !m.body.contains("できません"));
15503 let prompt = std::fs::read_to_string(captured).unwrap();
15504 assert!(prompt.contains(&format!(
15505 "git diff {} {head}",
15506 f.runner.state.change_summary.as_ref().unwrap().base
15507 )));
15508 assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
15509 assert_eq!(
15510 f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
15511 "二回目の内部修正報告です。"
15512 );
15513 }
15514 }
15515
15516 #[tokio::test]
15517 async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
15518 for review_only in [false, true] {
15519 let mut f = summary_fixture();
15520 if !review_only {
15521 f.runner.state.candidates[0].agent = "alpha".into();
15522 }
15523 f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
15524 f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
15525 std::fs::write(&f.reply, "no usable description").unwrap();
15526 let failed = posted(&mut f).await;
15527 assert!(
15528 failed
15529 .body
15530 .contains("No description of what this change does could be generated")
15531 );
15532 assert!(failed.body.is_ascii());
15533 assert!(f.runner.state.github_text.is_none());
15534 assert!(f.runner.state.change_summary.is_none());
15535 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15536 let evidence = crate::pr_outcome::summary_evidence(
15537 &f.runner.state,
15538 crate::pr_outcome::FinalHead::At(&head),
15539 );
15540 let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
15541 assert!(
15542 crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
15543 .exists()
15544 );
15545 f.runner.state =
15546 serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
15547 std::fs::write(
15548 &f.reply,
15549 "TITLE: feat(own): add the own file\nAdds the committed own file.",
15550 )
15551 .unwrap();
15552 let retried = posted(&mut f).await;
15553 assert!(retried.body.contains("Adds the committed own file."));
15554 assert_eq!(
15555 f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
15556 "日本語の修正報告"
15557 );
15558 }
15559 }
15560
15561 #[tokio::test]
15562 async fn same_head_verification_and_findings_invalidate_public_prose() {
15563 let mut f = summary_fixture();
15564 posted(&mut f).await;
15565 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15566 let original = f
15567 .runner
15568 .state
15569 .change_summary
15570 .as_ref()
15571 .unwrap()
15572 .evidence
15573 .clone();
15574 ok_gate(&mut f.runner.state, Some(&head));
15575 std::fs::write(
15576 &f.reply,
15577 "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
15578 )
15579 .unwrap();
15580 let verified = posted(&mut f).await;
15581 assert!(verified.body.contains("now verified"));
15582 assert_ne!(
15583 f.runner.state.change_summary.as_ref().unwrap().evidence,
15584 original
15585 );
15586 let mut round = round_with_notes(1, Some("内部報告"));
15587 round.reviews.push(ReviewRecord {
15588 attempts: 0,
15589 reviewer: 1,
15590 agent: "alpha".into(),
15591 summary: "内部レビュー".into(),
15592 findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
15593 vote: None,
15594 failed: None,
15595 duration_ms: 0,
15596 });
15597 round
15598 .fix
15599 .as_mut()
15600 .unwrap()
15601 .rejected
15602 .push(crate::verdict::Rejection {
15603 id: "R1-1-1".into(),
15604 why: "呼び出し元から到達できない".into(),
15605 });
15606 f.runner.state.reviews = vec![round];
15607 let omitted = posted(&mut f).await;
15609 assert!(omitted.body.contains("No description"));
15610 assert!(omitted.body.contains("R1-1-1"));
15611 assert!(!omitted.body.contains("到達"));
15612 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: declined because the code is unreachable from callers; confirm that assumption when adding callers.").unwrap();
15613 let described = posted(&mut f).await;
15614 assert!(described.body.contains("unreachable from callers"));
15615 assert!(described.body.contains("R1-1-1"));
15616 assert!(!described.body.contains("到達"));
15617 let evidence = f
15618 .runner
15619 .state
15620 .change_summary
15621 .as_ref()
15622 .unwrap()
15623 .evidence
15624 .clone();
15625 f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
15626 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
15627 assert!(posted(&mut f).await.body.contains("rationale updated"));
15628 assert_ne!(
15629 f.runner.state.change_summary.as_ref().unwrap().evidence,
15630 evidence
15631 );
15632 f.runner
15634 .state
15635 .change_summary
15636 .as_mut()
15637 .unwrap()
15638 .evidence
15639 .clear();
15640 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
15641 assert!(posted(&mut f).await.body.contains("current specification"));
15642 }
15643
15644 #[tokio::test]
15645 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
15646 let mut f = summary_fixture();
15647 let m = posted(&mut f).await;
15648 assert_eq!(m.title, "feat(own): add the own file");
15649 assert!(
15650 m.body
15651 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
15652 "{}",
15653 m.body
15654 );
15655 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
15656 assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
15657 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
15658 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15659 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
15660 let kept = f.runner.state.change_summary.as_ref().unwrap();
15661 assert_eq!(
15662 (kept.head.as_str(), kept.base.as_str()),
15663 (head.as_str(), base.as_str())
15664 );
15665 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
15666 }
15667
15668 #[tokio::test]
15669 async fn review_only_summary_receives_the_configured_github_language() {
15670 let mut f = summary_fixture();
15671 f.runner.state.config.graph.github_language = "ja".into();
15672 let captured = f.repo.join("summary-prompt.txt");
15673 let author = &mut f.runner.state.config.agents[0];
15674 author.command = vec![
15675 "sh".into(),
15676 "-c".into(),
15677 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
15678 ];
15679 author.env.insert(
15680 "PROMPT_FILE".into(),
15681 captured.to_string_lossy().into_owned(),
15682 );
15683 std::fs::write(
15684 &f.reply,
15685 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
15686 )
15687 .unwrap();
15688 let m = posted(&mut f).await;
15689 let prompt = std::fs::read_to_string(captured).unwrap();
15690 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
15691 assert!(!prompt.contains("in English"));
15692 assert_eq!(m.title, "feat(own): ファイルを追加");
15693 assert!(m.body.contains("own.txt を追加します。"));
15694 }
15695
15696 #[tokio::test]
15697 async fn review_fixes_come_after_the_whole_change_summary() {
15698 let mut f = summary_fixture();
15699 f.runner.state.reviews = vec![
15700 round_with_notes(1, Some("handled the PATH case")),
15701 round_with_notes(2, None),
15702 ];
15703 let m = posted(&mut f).await;
15704 let summary = m.body.find("## Summary").expect("summary");
15705 let fixes = m.body.find("## Review rounds").expect("review rounds");
15706 assert!(summary < fixes, "{}", m.body);
15707 assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
15708 assert!(m.body.contains("## Final verification"), "{}", m.body);
15709 }
15710
15711 #[tokio::test]
15712 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
15713 let mut f = summary_fixture();
15714 let first = posted(&mut f).await;
15715 let first_head = f.runner.state.change_summary.clone().unwrap().head;
15716 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
15718 let again = posted(&mut f).await;
15719 assert_eq!((again.title, again.body), (first.title, first.body));
15720
15721 let g = |args: &[&str]| {
15722 let out = std::process::Command::new("git")
15723 .args(args)
15724 .current_dir(&f.repo)
15725 .quiet()
15726 .output()
15727 .expect("spawn git");
15728 assert!(out.status.success(), "git {args:?}");
15729 };
15730 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
15731 g(&["add", "-A"]);
15732 g(&["commit", "-q", "-m", "fix(own): handle more"]);
15733 let second = posted(&mut f).await;
15734 let kept = f.runner.state.change_summary.clone().unwrap();
15735 assert_ne!(kept.head, first_head);
15736 assert!(second.body.contains("Not read yet."), "{}", second.body);
15737 assert!(!second.body.contains("so the first summary is read"));
15738 assert_eq!(second.title, "feat(own): other");
15739 }
15740
15741 #[tokio::test]
15742 async fn an_unavailable_summary_is_stated_and_never_invented() {
15743 let mut f = summary_fixture();
15744 std::fs::write(&f.reply, "no title line here").unwrap();
15745 let m = posted(&mut f).await;
15746 assert!(
15747 m.body
15748 .contains("No description of what this change does could be generated"),
15749 "{}",
15750 m.body
15751 );
15752 assert!(m.body.contains("internal reports remain in local records"));
15753 assert!(f.runner.state.change_summary.is_none());
15754 assert!(!m.body.contains("## Commits under review"), "{}", m.body);
15755 let winner = f.runner.state.candidates[0].clone();
15757 let preview = f
15758 .runner
15759 .guarded_pr_message(&winner, None, false)
15760 .await
15761 .unwrap()
15762 .unwrap();
15763 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
15764 }
15765
15766 #[test]
15767 fn pr_message_review_single_commit_uses_its_subject() {
15768 let state = review_state(&["feat(nats): per-role user"]);
15769 let m = pr_message(&state, 'A');
15770 assert_eq!(m.title, "feat(nats): per-role user");
15771 assert!(!m.body.contains("Review the work already"), "{}", m.body);
15772 assert!(m.body.contains("## Commits under review"), "{}", m.body);
15773 }
15774
15775 #[test]
15776 fn pr_message_review_multi_commit_takes_the_oldest() {
15777 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
15778 let m = pr_message(&state, 'A');
15779 assert_eq!(m.title, "feat: the change");
15780 for s in ["feat: the change", "fix: typo", "fix: again"] {
15781 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
15782 }
15783 }
15784
15785 #[test]
15786 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
15787 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
15788 let mut state = review_state(&[first, "fix: later fixup"]);
15789 state.candidates[0].branch = "機能/ログイン".to_owned();
15790 let m = pr_message(&state, 'A');
15791 assert!(
15792 m.title.starts_with("chore: land candidate A of run"),
15793 "{}",
15794 m.title
15795 );
15796 }
15797 }
15798
15799 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
15800 BranchFacts {
15801 commits: commits
15802 .iter()
15803 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
15804 .collect(),
15805 stat: stat.to_owned(),
15806 }
15807 }
15808
15809 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
15810 let mut r = review_round(true, 0, 1, 1, true, true);
15811 r.round = round;
15812 r.fix = notes.map(|n| FixRecord {
15813 agent: "fixer".to_owned(),
15814 addressed: Vec::new(),
15815 rejected: Vec::new(),
15816 notes: n.to_owned(),
15817 committed: true,
15818 failed: None,
15819 duration_ms: 0,
15820 continuation: None,
15821 });
15822 r
15823 }
15824
15825 #[test]
15826 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
15827 let state = review_state(&["ignored"]);
15828 let f = facts(
15829 &[
15830 (
15831 "fix(login): resolve PATH on macOS",
15832 "Login shells skip rc files.",
15833 ),
15834 ("fix: address review", ""),
15835 ],
15836 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
15837 );
15838 let m = pr_message_with(&state, 'A', Some(&f));
15839 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
15840 assert!(
15841 m.body
15842 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
15843 "{}",
15844 m.body
15845 );
15846 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
15847 assert!(m.body.contains("## Diff stat"), "{}", m.body);
15848 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
15849 for banned in [
15850 "Review the work already",
15851 "no task statement",
15852 "Original task",
15853 ] {
15854 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
15855 }
15856 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
15857 }
15858
15859 #[test]
15860 fn pr_message_review_truncates_a_huge_first_commit_body() {
15861 let state = review_state(&["ignored"]);
15862 let f = facts(
15863 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
15864 "s",
15865 );
15866 let m = pr_message_with(&state, 'A', Some(&f));
15867 assert!(m.body.len() < 30_000, "{}", m.body.len());
15868 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
15869 assert!(
15870 m.body.contains("- ... 1 more commit(s)"),
15871 "{}",
15872 m.body.len()
15873 );
15874 assert!(m.body.ends_with("magi:candidate-a\n"));
15875 }
15876
15877 #[test]
15878 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
15879 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
15880 assert_eq!(m.title, "feat: x");
15881 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
15882 assert!(!m.body.contains("Diff stat"), "{}", m.body);
15883 assert!(!m.body.contains("no task statement"), "{}", m.body);
15884 }
15885
15886 #[test]
15887 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
15888 let mut state = review_state(&["日本語の件名"]);
15889 state.candidates[0].branch = "fix/macos-login-path".to_owned();
15890 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
15891 state.candidates[0].branch = "機能/ログイン".to_owned();
15892 assert!(
15893 pr_message(&state, 'A')
15894 .title
15895 .starts_with("chore: land candidate A")
15896 );
15897 }
15898
15899 #[test]
15900 fn pr_message_review_fixes_survive_a_clean_final_round() {
15901 let mut state = review_state(&["feat: x"]);
15902 state.reviews = vec![
15903 round_with_notes(1, Some("handled the PATH case")),
15904 round_with_notes(2, None),
15905 ];
15906 let body = pr_message(&state, 'A').body;
15907 assert!(
15908 body.contains("## Review rounds") && !body.contains("handled the PATH case"),
15909 "{body}"
15910 );
15911 assert!(body.contains("### Round 1"), "{body}");
15912
15913 state.reviews = vec![
15914 round_with_notes(1, Some("first fix")),
15915 round_with_notes(2, Some("")),
15916 round_with_notes(3, Some("second fix")),
15917 round_with_notes(4, None),
15918 ];
15919 let body = pr_message(&state, 'A').body;
15920 assert!(
15921 body.contains("### Round 1") && !body.contains("first fix"),
15922 "{body}"
15923 );
15924 assert!(
15925 body.contains("### Round 3") && !body.contains("second fix"),
15926 "{body}"
15927 );
15928 assert!(body.contains("### Round 2"), "{body}");
15929 }
15930
15931 const FINAL: &str = "abcdef1234567890";
15932
15933 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
15934 let mut r = round_with_notes(round, None);
15935 r.verified_head = verified.map(str::to_owned);
15936 r
15937 }
15938
15939 fn ok_gate(state: &mut RunState, head: Option<&str>) {
15940 state.gate = vec![CommandOutcome {
15941 command: "gate".to_owned(),
15942 code: Some(0),
15943 output_tail: String::new(),
15944 duration_ms: 0,
15945 resource_blocked: false,
15946 }];
15947 state.gate_ran = true;
15948 state.gate_head = head.map(str::to_owned);
15949 }
15950
15951 fn body_at(state: &RunState) -> String {
15952 pr_message_raw(
15953 state,
15954 'A',
15955 None,
15956 crate::pr_outcome::FinalHead::At(FINAL),
15957 ChangeNote::NotAsked,
15958 )
15959 .body
15960 }
15961
15962 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
15963
15964 #[test]
15965 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
15966 let mut state = review_state(&["feat: x"]);
15967 state.reviews = vec![
15968 round_with_notes(1, Some(SANDBOXED)),
15969 passing_round(2, Some(FINAL)),
15970 ];
15971 ok_gate(&mut state, Some(FINAL));
15972 let body = body_at(&state);
15973 assert!(!body.contains("Handled the PATH case."), "{body}");
15974 assert!(!body.contains("could not commit"), "{body}");
15975 assert!(!body.contains("could not run"), "{body}");
15976 assert!(
15977 body.contains("The fix is committed on the branch"),
15978 "{body}"
15979 );
15980 assert!(!body.contains("sandbox"), "{body}");
15981 assert!(
15982 body.contains("Tests (`verify.e2e`): passed on the final commit"),
15983 "{body}"
15984 );
15985 assert!(
15986 body.contains("Gate (`verify.gate`): passed on the final commit"),
15987 "{body}"
15988 );
15989 assert!(
15991 state.reviews[0]
15992 .fix
15993 .as_ref()
15994 .unwrap()
15995 .notes
15996 .contains("could not commit")
15997 );
15998 }
15999
16000 #[test]
16001 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
16002 let mut state = review_state(&["feat: x"]);
16003 let mut r1 = round_with_notes(1, Some(SANDBOXED));
16004 r1.fix.as_mut().unwrap().committed = false;
16005 let mut r2 = passing_round(2, Some(FINAL));
16006 r2.e2e[0].code = Some(1);
16007 state.reviews = vec![r1, r2];
16008 let body = body_at(&state);
16009 assert!(!body.contains("could not commit"), "{body}");
16010 assert!(!body.contains("could not run"), "{body}");
16011 assert!(body.contains("This round produced no commit"), "{body}");
16012 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
16013 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
16014 assert!(!body.contains("passed"), "{body}");
16015 }
16016
16017 #[test]
16018 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
16019 let mut state = review_state(&["feat: x"]);
16020 state.reviews = vec![
16021 round_with_notes(1, Some(SANDBOXED)),
16022 passing_round(2, Some("0123456789abcdef")),
16023 ];
16024 ok_gate(&mut state, Some("0123456789abcdef"));
16025 let body = body_at(&state);
16026 assert!(!body.contains("HTTP tests could not run"), "{body}");
16027 assert!(!body.contains("could not commit"), "{body}");
16029 assert!(
16030 body.contains("passed on an earlier commit (`0123456`)"),
16031 "{body}"
16032 );
16033 assert!(!body.contains("passed on the final commit"), "{body}");
16034
16035 ok_gate(&mut state, None);
16037 let body = body_at(&state);
16038 assert!(
16039 body.contains("passed on a commit that was not recorded"),
16040 "{body}"
16041 );
16042 }
16043
16044 #[test]
16045 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
16046 let state = state_with_summary(
16047 "add retries to the client",
16048 "TITLE: feat: retries\n\nDid it.",
16049 );
16050 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
16051 assert_eq!(m.title, "feat: retries");
16052 assert!(
16053 m.body.contains("<summary>Original task</summary>"),
16054 "{}",
16055 m.body
16056 );
16057 assert!(!m.body.contains("Commits under review"), "{}", m.body);
16058 assert!(
16059 m.body
16060 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
16061 "{}",
16062 m.body
16063 );
16064 }
16065
16066 #[test]
16067 fn pr_message_review_bounds_a_long_english_subject() {
16068 let long = format!("feat: {}", "word ".repeat(100));
16069 let m = pr_message(&review_state(&[&long]), 'A');
16070 assert!(m.title.starts_with("feat: word"), "{}", m.title);
16071 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16072 }
16073
16074 #[test]
16075 fn pr_message_implementation_run_is_unchanged_by_review_support() {
16076 let state = state_with_summary("add retries\n\ndetails", "- did some things");
16077 let m = pr_message(&state, 'A');
16078 assert_eq!(m.title, "add retries");
16079 assert!(m.body.contains("<summary>Original task</summary>"));
16080 assert!(!m.body.contains("Commits under review"));
16081 assert_eq!(landing_subject_source(&state), state.instruction);
16082 }
16083
16084 #[test]
16085 fn review_run_squash_subject_is_the_change_not_the_prompt() {
16086 let state = review_state(&["feat: the change", "fix: typo"]);
16087 let source = landing_subject_source(&state);
16088 assert_eq!(land::merge_subject("", &source), "feat: the change");
16089 assert_eq!(
16090 land::merge_subject("magi: candidate A (uncommitted work)", &source),
16091 "feat: the change"
16092 );
16093 assert_eq!(
16095 land::merge_subject("feat: renamed by hand", &source),
16096 "feat: renamed by hand"
16097 );
16098 let blank = review_state(&["日本語"]);
16099 assert!(
16100 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
16101 );
16102 }
16103
16104 #[test]
16105 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
16106 let state = review_state(&["feat: the change"]);
16107 let source = landing_subject_source(&state);
16108 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
16109 assert_eq!(
16110 land::merge_subject(landing_title(&state, old), &source),
16111 "feat: the change"
16112 );
16113 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
16114 let task = state_with_summary("add retries", "");
16115 assert_eq!(landing_title(&task, old), old);
16116 }
16117
16118 #[test]
16119 fn pr_message_describes_the_change_not_the_task() {
16120 let state = state_with_summary(
16121 "今回やってほしいこと: results projector を直す",
16122 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
16123 );
16124 let m = pr_message(&state, 'A');
16125 assert_eq!(m.title, "fix(web): batch the runs list reads");
16126 assert!(
16127 m.body.starts_with("## Summary\n\n- reads run.json once"),
16128 "{}",
16129 m.body
16130 );
16131 assert!(!m.body.contains("TITLE:"), "{}", m.body);
16132 let task_at = m.body.find("今回やってほしいこと").unwrap();
16133 let details_at = m.body.find("<details>").unwrap();
16134 assert!(
16135 details_at < task_at,
16136 "the task lives inside <details>: {}",
16137 m.body
16138 );
16139 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
16140 assert!(m.body.contains("magi:candidate-a"));
16141 }
16142
16143 #[test]
16144 fn pr_message_falls_back_to_the_task_without_a_title_line() {
16145 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
16146 let m = pr_message(&state, 'A');
16147 assert_eq!(m.title, "add retries");
16148 assert!(
16149 m.body.contains("## Summary\n\n- did some things"),
16150 "{}",
16151 m.body
16152 );
16153
16154 let none = RunState::new(
16155 PathBuf::from("/repo"),
16156 "main".to_owned(),
16157 "abc1234".to_owned(),
16158 "add retries".to_owned(),
16159 Config::default(),
16160 );
16161 let m = pr_message(&none, 'A');
16162 assert_eq!(m.title, "add retries");
16163 assert!(!m.body.contains("## Summary"), "{}", m.body);
16164 }
16165
16166 #[test]
16167 fn pr_message_refuses_the_candidate_commit_subject() {
16168 for bad in [
16169 "TITLE: magi: candidate A (uncommitted work)",
16170 "TITLE: chore: stuff (uncommitted work)",
16171 "TITLE: ",
16172 ] {
16173 let state = state_with_summary("add retries", bad);
16174 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
16175 }
16176 }
16177
16178 #[test]
16179 fn pr_message_bounds_a_very_long_task_and_title() {
16180 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
16181 let state = state_with_summary(&long, "- nothing");
16182 let m = pr_message(&state, 'A');
16183 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16184 assert!(!m.title.contains('\n'));
16185
16186 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
16187 let m = pr_message(&state, 'A');
16188 assert!(m.title.starts_with("feat: "));
16189 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16190 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
16191 }
16192
16193 fn long_title_of(instruction: &str) -> String {
16194 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
16195 }
16196
16197 #[test]
16198 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
16199 let first = "Make the landing path keep a readable title for long tasks";
16200 let line = format!(
16201 "{first}. {}",
16202 "And then keep going with more words ".repeat(20)
16203 );
16204 let t = long_title_of(&line);
16205 assert_eq!(t, first);
16206 assert!(!t.starts_with("chore: land"));
16207 }
16208
16209 #[test]
16210 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
16211 let line = "word ".repeat(200);
16212 let t = long_title_of(&line);
16213 assert!(t.ends_with("word..."), "{t}");
16214 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
16215 }
16216
16217 #[test]
16218 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
16219 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
16220 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
16221 }
16222 }
16223
16224 #[test]
16225 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
16226 let mut state = state_with_summary("再試行を追加する", "- nothing");
16227 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
16228 state.config.graph.github_language = "ja".into();
16229 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
16230 }
16231
16232 #[test]
16233 fn pr_message_title_limit_is_exact() {
16234 let at = "a".repeat(PR_TITLE_MAX);
16235 assert_eq!(long_title_of(&at), at);
16236 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
16237 assert!(over.ends_with("..."), "{over}");
16238 assert_eq!(over.chars().count(), PR_TITLE_MAX);
16239 }
16240
16241 #[test]
16242 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
16243 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
16244 let t = long_title_of(&line);
16245 assert!(t.ends_with("..."), "{t}");
16246 assert!(t.is_ascii(), "{t}");
16247 }
16248
16249 #[test]
16250 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
16251 let line = format!(
16252 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
16253 "plus more filler words ".repeat(20)
16254 );
16255 let t = long_title_of(&line);
16256 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
16257 }
16258
16259 #[test]
16260 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
16261 let line = format!("\n\n# {}", "title words ".repeat(40));
16262 let state = state_with_summary(&line, "- nothing");
16263 let m = pr_message(&state, 'A');
16264 assert!(m.title.starts_with("title words"), "{}", m.title);
16265 assert_eq!(
16266 land::merge_subject(&m.title, &landing_subject_source(&state)),
16267 m.title
16268 );
16269 assert_eq!(
16271 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
16272 "feat: renamed by hand"
16273 );
16274 }
16275
16276 #[tokio::test]
16277 async fn github_text_rewrite_is_bounded_and_falls_back() {
16278 crate::run::pin_test_home();
16279 for (reply, accepted) in [
16280 (
16281 "TITLE: fix: retries\nAdd retries for failed requests.",
16282 true,
16283 ),
16284 (
16285 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
16286 false,
16287 ),
16288 (
16289 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
16290 true,
16291 ),
16292 (
16293 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
16294 true,
16295 ),
16296 (
16297 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
16298 true,
16299 ),
16300 (
16301 "TITLE: fix: retries\n日本語の説明です。 token=secret",
16302 false,
16303 ),
16304 ("TITLE: fix: retries\n", false),
16305 ] {
16306 let dir = tempfile::tempdir().unwrap();
16307 let mut runner = runner_at(RunStatus::Gating);
16308 runner.state = state_with_summary(
16309 "add retries",
16310 "TITLE: fix: retries\n日本語の説明を書きます。",
16311 );
16312 runner.state.repo = dir.path().to_owned();
16313 runner.state.candidates[0].worktree = dir.path().to_owned();
16314 let mut author = spec("alpha");
16315 author.command = vec![
16316 "sh".into(),
16317 "-c".into(),
16318 "printf '%s' \"$REWRITE_REPLY\"".into(),
16319 ];
16320 author.env.insert("REWRITE_REPLY".into(), reply.into());
16321 runner.state.config.agents = vec![author];
16322 seed_public_summary(&mut runner);
16323 let winner = runner.state.candidates[0].clone();
16324 let first = runner
16325 .guarded_pr_message(&winner, None, true)
16326 .await
16327 .unwrap();
16328 let message = if accepted {
16329 first.expect("a passing rewrite never asks")
16330 } else {
16331 assert!(first.is_none());
16333 assert!(runner.state.parked);
16334 let store = ask::Questions::open();
16335 let asked: Vec<_> = store
16336 .list()
16337 .into_iter()
16338 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
16339 .collect();
16340 assert_eq!(asked.len(), 1);
16341 assert!(!asked[0].detail.contains("secret"));
16342 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
16343 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
16344 assert_eq!(
16345 asked[0]
16346 .choices
16347 .contains(&crate::github_text::USE_REWRITE.to_owned()),
16348 rewrite.is_some()
16349 );
16350 store
16353 .update(&asked[0].id, |q| {
16354 q.abandon("test");
16355 Ok(())
16356 })
16357 .unwrap();
16358 let again = runner
16359 .guarded_pr_message(&winner, None, true)
16360 .await
16361 .unwrap()
16362 .expect("an abandoned question falls back");
16363 let n = store
16364 .list()
16365 .into_iter()
16366 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
16367 .count();
16368 assert_eq!(n, 1, "asked once per rejected text");
16369 again
16370 };
16371 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
16372 assert!(!message.body.contains("secret"));
16373 assert!(!message.body.contains("prefix"));
16374 assert!(!message.title.contains("secret"));
16375 assert_eq!(
16376 message.body.contains("Add retries for failed requests."),
16377 accepted
16378 );
16379 assert_eq!(
16380 runner.state.seats["github-text-rewrite"].turns, 1,
16381 "only one rewrite invocation"
16382 );
16383 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
16384 if accepted {
16385 assert!(runner.state.github_text.is_none());
16386 assert!(!runner.state.parked);
16387 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
16388 assert!(message.body.contains("## Final verification"));
16389 assert!(message.body.contains("Evidence: final diff"));
16390 assert!(
16391 message
16392 .body
16393 .contains(&format!("magi:run/{}", runner.state.id))
16394 );
16395 }
16396 if !accepted {
16397 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
16398 assert!(
16399 message
16400 .body
16401 .contains(&format!("magi:run/{}", runner.state.id))
16402 );
16403 }
16404 }
16405 }
16406
16407 fn scripted(id: &str, reply: &str) -> AgentSpec {
16408 let mut a = spec(id);
16409 a.command = vec![
16410 "sh".into(),
16411 "-c".into(),
16412 "printf '%s' \"$REWRITE_REPLY\"".into(),
16413 ];
16414 a.env.insert("REWRITE_REPLY".into(), reply.into());
16415 a
16416 }
16417
16418 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
16419 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
16420
16421 fn seed_public_summary(runner: &mut Runner) {
16423 let repo = &runner.state.repo;
16424 let g = |args: &[&str]| {
16425 let out = std::process::Command::new("git")
16426 .args(args)
16427 .current_dir(repo)
16428 .quiet()
16429 .output()
16430 .unwrap();
16431 assert!(out.status.success(), "git {args:?}");
16432 String::from_utf8_lossy(&out.stdout).trim().to_owned()
16433 };
16434 g(&["init", "-q", "-b", "main"]);
16435 g(&[
16436 "-c",
16437 "user.name=Test",
16438 "-c",
16439 "user.email=test@example.invalid",
16440 "commit",
16441 "-q",
16442 "--allow-empty",
16443 "-m",
16444 "feat: fixture",
16445 ]);
16446 let head = g(&["rev-parse", "HEAD"]);
16447 g(&["update-ref", "refs/remotes/origin/main", &head]);
16448 let evidence = crate::pr_outcome::summary_evidence(
16449 &runner.state,
16450 crate::pr_outcome::FinalHead::At(&head),
16451 );
16452 let generated = &runner.state.candidates[0].summary;
16453 runner.state.change_summary = Some(crate::run::ChangeSummary {
16454 base: head.clone(),
16455 head,
16456 evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
16457 title: summary_title(generated).unwrap(),
16458 body: summary_without_title(generated),
16459 });
16460 }
16461
16462 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
16463 let mut runner = runner_at(RunStatus::Gating);
16464 runner.state = state_with_summary("add retries", JA_SUMMARY);
16465 if review_only {
16466 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
16467 }
16468 runner.state.repo = dir.to_owned();
16469 runner.state.candidates[0].worktree = dir.to_owned();
16470 runner.state.config.agents = agents;
16471 seed_public_summary(&mut runner);
16472 runner
16473 }
16474
16475 fn gate_questions(runner: &Runner) -> usize {
16476 ask::Questions::open()
16477 .list()
16478 .into_iter()
16479 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
16480 .count()
16481 }
16482
16483 #[tokio::test]
16484 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
16485 crate::run::pin_test_home();
16486 let dir = tempfile::tempdir().unwrap();
16487 let mut runner = rewrite_runner(
16488 dir.path(),
16489 true,
16490 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
16491 );
16492 runner.state.config.roles.language_judge =
16493 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
16494 let winner = runner.state.candidates[0].clone();
16495 let msg = runner
16496 .guarded_pr_message(&winner, None, true)
16497 .await
16498 .unwrap()
16499 .expect("an English rewrite posts without asking");
16500 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
16501 assert!(msg.body.contains("<details>kept</details>"));
16502 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
16503 assert_eq!(gate_questions(&runner), 0);
16504 assert!(runner.state.github_text.is_none());
16505 assert!(!runner.state.seats.contains_key("impl-A"));
16506 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
16507 }
16508
16509 #[tokio::test]
16510 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
16511 crate::run::pin_test_home();
16512 let dir = tempfile::tempdir().unwrap();
16513 let mut runner = rewrite_runner(
16514 dir.path(),
16515 false,
16516 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
16517 );
16518 runner.state.config.roles.language_judge =
16519 Some(crate::config::AgentChoice::One("beta".into()));
16520 runner.state.seats.insert(
16521 "impl-A".into(),
16522 SeatState::new("impl-A", "alpha", runner.state.seed),
16523 );
16524 let winner = runner.state.candidates[0].clone();
16525 let msg = runner
16526 .guarded_pr_message(&winner, None, true)
16527 .await
16528 .unwrap()
16529 .expect("rewritten");
16530 assert!(msg.body.contains("<details>kept</details>"));
16531 assert_eq!(runner.state.seats["impl-A"].turns, 0);
16532 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
16533 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
16534 }
16535
16536 #[tokio::test]
16537 async fn rewrite_reuses_a_resumable_implementer_session() {
16538 crate::run::pin_test_home();
16539 let dir = tempfile::tempdir().unwrap();
16540 let mut runner = rewrite_runner(
16541 dir.path(),
16542 false,
16543 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
16544 );
16545 runner.state.config.roles.language_judge =
16546 Some(crate::config::AgentChoice::One("beta".into()));
16547 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
16548 seat.turns = 1;
16549 runner.state.seats.insert("impl-A".into(), seat);
16550 let winner = runner.state.candidates[0].clone();
16551 runner
16552 .guarded_pr_message(&winner, None, true)
16553 .await
16554 .unwrap()
16555 .expect("rewritten");
16556 assert_eq!(runner.state.seats["impl-A"].turns, 2);
16557 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
16558 }
16559
16560 #[tokio::test]
16561 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
16562 crate::run::pin_test_home();
16563 let dir = tempfile::tempdir().unwrap();
16564 let mut broken = spec("bad");
16565 broken.command = vec!["false".into()];
16566 let mut runner = rewrite_runner(
16567 dir.path(),
16568 true,
16569 vec![
16570 broken,
16571 scripted("noformat", "Add retries."),
16572 scripted("good", EN_REWRITE),
16573 ],
16574 );
16575 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
16576 "bad".into(),
16577 "noformat".into(),
16578 "good".into(),
16579 ]));
16580 let winner = runner.state.candidates[0].clone();
16581 let msg = runner
16582 .guarded_pr_message(&winner, None, true)
16583 .await
16584 .unwrap()
16585 .expect("the third agent rewrites");
16586 assert!(msg.body.contains("`src/net.rs`"));
16587 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
16588 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
16589 }
16590
16591 #[tokio::test]
16592 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
16593 crate::run::pin_test_home();
16594 for (agents, role, saved) in [
16596 (
16597 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
16598 Some("beta"),
16599 true,
16600 ),
16601 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
16602 (Vec::new(), None, false),
16603 ] {
16604 let dir = tempfile::tempdir().unwrap();
16605 let mut runner = rewrite_runner(dir.path(), true, agents);
16606 runner.state.config.roles.language_judge =
16607 role.map(|r| crate::config::AgentChoice::One(r.into()));
16608 let winner = runner.state.candidates[0].clone();
16609 let first = runner
16610 .guarded_pr_message(&winner, None, true)
16611 .await
16612 .unwrap();
16613 assert!(first.is_none(), "withheld text parks the run");
16614 assert!(runner.state.parked);
16615 assert_eq!(gate_questions(&runner), 1);
16616 let g = runner.state.github_text.as_ref().unwrap();
16617 assert_eq!(g.rewrite.is_some(), saved);
16618 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
16619 }
16620 }
16621
16622 #[tokio::test]
16623 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
16624 crate::run::pin_test_home();
16625 let dir = tempfile::tempdir().unwrap();
16626 let mut runner = runner_at(RunStatus::Gating);
16627 runner.state =
16628 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
16629 runner.state.repo = dir.path().to_owned();
16630 runner.state.candidates[0].worktree = dir.path().to_owned();
16631 let mut author = spec("alpha");
16632 author.command = vec![
16633 "sh".into(),
16634 "-c".into(),
16635 "printf '%s' \"$REWRITE_REPLY\"".into(),
16636 ];
16637 author.env.insert(
16638 "REWRITE_REPLY".into(),
16639 format!(
16640 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
16641 "English sentence. ".repeat(600)
16642 ),
16643 );
16644 runner.state.config.agents = vec![author];
16645 seed_public_summary(&mut runner);
16646 let winner = runner.state.candidates[0].clone();
16647 assert!(
16648 runner
16649 .guarded_pr_message(&winner, None, true)
16650 .await
16651 .unwrap()
16652 .is_none()
16653 );
16654 let record = runner.state.github_text.clone().unwrap();
16655 let rewrite = record.rewrite.clone().unwrap();
16656 assert_eq!(rewrite.failed, ["body-language"]);
16657 assert!(
16658 rewrite
16659 .body
16660 .ends_with(&format!("magi:run/{}", runner.state.id))
16661 );
16662 let store = ask::Questions::open();
16663 let q = store.get(record.question.as_ref().unwrap()).unwrap();
16664 assert!(
16665 q.choices
16666 .contains(&crate::github_text::USE_REWRITE.to_owned())
16667 );
16668 for label in [
16669 "ORIGINAL",
16670 "FALLBACK",
16671 "REWRITE",
16672 "Rules still failed: body-language",
16673 "truncated",
16674 ] {
16675 assert!(q.detail.contains(label), "{label}");
16676 }
16677 let artifact = std::fs::read_to_string(crate::run::artifact_path(
16678 &runner.state,
16679 &crate::github_text::artifact_name(&record.fingerprint),
16680 ))
16681 .unwrap();
16682 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
16683 runner.state =
16685 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
16686 store
16687 .update(&q.id, |q| {
16688 q.say("まだ日本語")?;
16689 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
16690 })
16691 .unwrap();
16692 assert!(
16693 runner
16694 .guarded_pr_message(&winner, None, true)
16695 .await
16696 .unwrap()
16697 .is_none()
16698 );
16699 let retry = runner.state.github_text.as_ref().unwrap();
16700 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
16701 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
16702 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
16703 assert!(
16704 q.posting_comparison
16705 .as_ref()
16706 .unwrap()
16707 .matches_detail(&q.detail)
16708 );
16709 store
16710 .update(&q.id, |q| {
16711 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
16712 })
16713 .unwrap();
16714 let message = runner
16715 .guarded_pr_message(&winner, None, true)
16716 .await
16717 .unwrap()
16718 .unwrap();
16719 assert_eq!(message.title, rewrite.title);
16720 assert_eq!(message.body, rewrite.body);
16721 assert_eq!(
16722 runner.state.github_text.as_ref().unwrap().choice,
16723 Some(GithubTextChoice::Rewrite)
16724 );
16725 let again = runner
16726 .guarded_pr_message(&winner, None, true)
16727 .await
16728 .unwrap()
16729 .unwrap();
16730 assert_eq!(again.title, message.title);
16731 assert_eq!(again.body, message.body);
16732 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
16733 assert!(
16734 !runner
16735 .state
16736 .events
16737 .iter()
16738 .any(|e| e.message.contains(&rewrite.title)
16739 || e.message.contains("English sentence."))
16740 );
16741 }
16742
16743 #[tokio::test]
16744 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
16745 crate::run::pin_test_home();
16746 let dir = tempfile::tempdir().unwrap();
16747 let mut runner = runner_at(RunStatus::Gating);
16748 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
16749 runner.state.repo = dir.path().to_owned();
16750 runner.state.candidates[0].worktree = dir.path().to_owned();
16751 seed_public_summary(&mut runner);
16752 let winner = runner.state.candidates[0].clone();
16753 assert!(
16754 runner
16755 .guarded_pr_message(&winner, None, true)
16756 .await
16757 .unwrap()
16758 .is_none()
16759 );
16760 let record = runner.state.github_text.as_ref().unwrap();
16761 assert!(record.rewrite.is_none());
16762 let mut old = serde_json::to_value(record).unwrap();
16764 old.as_object_mut().unwrap().remove("rewrite");
16765 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
16766 assert!(old.rewrite.is_none());
16767 let id = record.question.clone().unwrap();
16768 ask::Questions::open()
16769 .update(&id, |q| {
16770 q.choices.push(crate::github_text::USE_REWRITE.into());
16771 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
16772 })
16773 .unwrap();
16774 let message = runner
16775 .guarded_pr_message(&winner, None, true)
16776 .await
16777 .unwrap()
16778 .unwrap();
16779 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
16780 assert_eq!(
16781 runner.state.github_text.as_ref().unwrap().choice,
16782 Some(GithubTextChoice::Fallback)
16783 );
16784 }
16785
16786 #[tokio::test]
16787 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
16788 crate::run::pin_test_home();
16789 let dir = tempfile::tempdir().unwrap();
16790 let mut runner = runner_at(RunStatus::Gating);
16791 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
16792 runner.state.repo = dir.path().to_owned();
16793 runner.state.candidates[0].worktree = dir.path().to_owned();
16794 seed_public_summary(&mut runner);
16795 let winner = runner.state.candidates[0].clone();
16796 assert!(
16797 runner
16798 .guarded_pr_message(&winner, None, true)
16799 .await
16800 .unwrap()
16801 .is_none()
16802 );
16803 let store = ask::Questions::open();
16804 let find = |run: &str| {
16805 store
16806 .list()
16807 .into_iter()
16808 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
16809 .unwrap()
16810 };
16811 let run = runner.state.id.clone();
16812 let q = find(&run);
16813 let fp = runner
16814 .state
16815 .github_text
16816 .as_ref()
16817 .unwrap()
16818 .fingerprint
16819 .clone();
16820 let name = crate::github_text::artifact_name(&fp);
16821 let path = crate::run::artifact_path(&runner.state, &name);
16822 assert!(q.detail.contains(&path.display().to_string()));
16823 assert!(q.detail.contains("title-language: title"));
16824 let saved = std::fs::read_to_string(&path).expect("artifact written");
16825 assert!(saved.contains("日本語のタイトル"));
16826 assert!(q.cwd.is_none());
16827 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
16828 let say = |id: &str, text: &str, choice: &str| {
16830 store
16831 .update(id, |q| {
16832 q.thread.push(ask::Turn {
16833 who: ask::Who::Operator,
16834 body: text.to_owned(),
16835 at: jiff::Timestamp::now(),
16836 note: None,
16837 });
16838 q.answer(ask::Answer::Choice(choice.to_owned()))
16839 })
16840 .unwrap();
16841 };
16842 say(&q.id, "まだ日本語", "use my text");
16843 assert!(
16844 runner
16845 .guarded_pr_message(&winner, None, true)
16846 .await
16847 .unwrap()
16848 .is_none()
16849 );
16850 let second = find(&run);
16851 assert_ne!(second.id, q.id);
16852 assert!(second.detail.contains("did not pass"));
16853 assert!(second.detail.contains("日本語のタイトル"));
16856 assert!(!second.detail.contains("まだ日本語"));
16857 assert!(second.detail.contains(&path.display().to_string()));
16858 say(&second.id, "fix: retry failed requests", "use my text");
16859 let message = runner
16860 .guarded_pr_message(&winner, None, true)
16861 .await
16862 .unwrap()
16863 .expect("a vetted replacement is posted");
16864 assert_eq!(message.title, "fix: retry failed requests");
16865 assert_eq!(
16866 runner
16867 .state
16868 .github_text
16869 .as_ref()
16870 .unwrap()
16871 .chosen_title
16872 .as_deref(),
16873 Some("fix: retry failed requests")
16874 );
16875 assert!(runner.state.github_text.as_ref().unwrap().resolved);
16876 let again = runner
16878 .guarded_pr_message(&winner, None, true)
16879 .await
16880 .unwrap()
16881 .unwrap();
16882 assert_eq!(again.title, message.title);
16883 assert_eq!(
16884 store
16885 .list()
16886 .into_iter()
16887 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
16888 .count(),
16889 2
16890 );
16891 }
16892
16893 #[test]
16894 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
16895 let mut state = state_with_summary(
16899 "add retries",
16900 "TITLE: fix(web): batch reads\n- reads run.json once",
16901 );
16902 state.config.graph.language = "ja".to_owned();
16903 let m = pr_message(&state, 'A');
16904 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
16905 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
16906
16907 let task = "今回やってほしいこと: results projector を直す";
16910 let mut state = state_with_summary(task, "- no title line");
16911 state.config.graph.language = "ja".to_owned();
16912 let m = pr_message(&state, 'A');
16913 assert_eq!(
16914 m.title,
16915 format!("chore: land candidate A of run {}", state.id)
16916 );
16917 assert!(
16918 m.body.contains(&format!(
16919 "<summary>Original task</summary>\n\n{task}\n\n</details>"
16920 )),
16921 "{}",
16922 m.body
16923 );
16924 }
16925
16926 #[test]
16927 fn pr_message_scrubs_home_paths_and_addresses() {
16928 let state = state_with_summary(
16929 "fix it in /Users/someone/src/x",
16930 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
16931 );
16932 let m = pr_message(&state, 'A');
16933 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
16934 assert!(!m.body.contains(leak), "{}", m.body);
16935 }
16936 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
16937 }
16938
16939 #[test]
16940 fn pr_message_survives_a_task_that_closes_details() {
16941 let state = state_with_summary("a </details> b", "TITLE: fix: x");
16942 let m = pr_message(&state, 'A');
16943 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
16944 }
16945
16946 #[test]
16947 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
16948 let cmd = manual_merge_command(
16949 MergeStyle::Squash,
16950 Path::new("/repo"),
16951 "b",
16952 "fix: \"quoted\" $(x) `y`\n\nbody",
16953 );
16954 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
16955 }
16956
16957 #[test]
16958 fn manual_merge_command_matches_the_configured_style() {
16959 let repo = Path::new("/repo");
16960 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
16961
16962 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
16963 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
16964
16965 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
16966 assert_eq!(
16967 squash,
16968 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
16969 \"Merge magi run 0832 (candidate A)\""
16970 );
16971
16972 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
16973 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
16974 }
16975
16976 #[test]
16977 fn a_nudge_gets_a_quarter_of_the_budget() {
16978 assert_eq!(retry_budget(secs(1200), true), secs(300));
16980 assert_eq!(retry_budget(secs(3600), true), secs(900));
16981 }
16982
16983 #[test]
16984 fn a_resent_prompt_keeps_the_whole_budget() {
16985 assert_eq!(retry_budget(secs(1200), false), secs(1200));
16988 assert_eq!(retry_budget(secs(60), false), secs(60));
16989 }
16990
16991 #[test]
16992 fn the_floor_never_exceeds_the_original_budget() {
16993 assert_eq!(retry_budget(secs(60), true), secs(60));
16997 assert_eq!(retry_budget(secs(480), true), secs(120));
16998 assert_eq!(retry_budget(secs(0), true), secs(0));
16999 }
17000
17001 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
17002 agent::CommandEvidence {
17003 id: "item1".to_owned(),
17004 description: "cargo test".to_owned(),
17005 exit_code,
17006 result_summary: String::new(),
17007 source: "codex".to_owned(),
17008 }
17009 }
17010
17011 #[test]
17012 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
17013 assert!(!has_unconfirmed_command(&[]));
17017 }
17018
17019 #[test]
17020 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
17021 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
17025 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
17026 assert!(!has_unconfirmed_command(&[
17027 evidence(Some(0)),
17028 evidence(Some(101))
17029 ]));
17030 }
17031
17032 #[test]
17033 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
17034 assert!(has_unconfirmed_command(&[
17035 evidence(Some(0)),
17036 evidence(None)
17037 ]));
17038 }
17039
17040 #[test]
17041 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
17042 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17043 assert_eq!(
17044 verified_noop_claim(true, &[], text).as_deref(),
17045 Some("already fixed by b32cfc4, on main.")
17046 );
17047 }
17048
17049 #[test]
17050 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
17051 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17054 assert!(verified_noop_claim(false, &[], text).is_none());
17055 }
17056
17057 #[test]
17058 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
17059 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17060 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
17061 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
17063 }
17064
17065 #[test]
17066 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
17067 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
17068 }
17069
17070 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
17073 runner.state.candidates = shape
17074 .iter()
17075 .enumerate()
17076 .map(|(i, &(empty, verified))| Candidate {
17077 index: i,
17078 label: (b'A' + i as u8) as char,
17079 agent: "sonnet".to_owned(),
17080 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
17081 worktree: PathBuf::from(format!("/wt/{i}")),
17082 summary: String::new(),
17083 stat: String::new(),
17084 files: 0,
17085 commits: 0,
17086 empty,
17087 failed: None,
17088 verified_noop: verified.map(str::to_owned),
17089 duration_ms: 0,
17090 folded: false,
17091 })
17092 .collect();
17093 }
17094
17095 #[test]
17096 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
17097 ask_test_home();
17098 let mut runner = runner_at(RunStatus::Implementing);
17099 set_candidates(
17100 &mut runner,
17101 &[
17102 (true, Some("already on main at b32cfc4")),
17103 (true, Some("same fix, see the existing test")),
17104 ],
17105 );
17106
17107 runner
17108 .after_implement()
17109 .expect("a verified no-op is not an error");
17110
17111 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
17112 }
17113
17114 #[test]
17115 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
17116 ask_test_home();
17117 let mut runner = runner_at(RunStatus::Implementing);
17118 set_candidates(
17122 &mut runner,
17123 &[(true, Some("already on main at b32cfc4")), (true, None)],
17124 );
17125
17126 let err = runner
17127 .after_implement()
17128 .expect_err("an unverified empty candidate must still fail the run");
17129
17130 assert!(
17131 err.to_string().contains("no candidate produced a change"),
17132 "{err}"
17133 );
17134 assert_eq!(runner.state.status, RunStatus::Failed);
17135 }
17136
17137 #[test]
17138 fn after_implement_still_fails_an_ordinary_all_empty_run() {
17139 ask_test_home();
17140 let mut runner = runner_at(RunStatus::Implementing);
17141 set_candidates(&mut runner, &[(true, None), (true, None)]);
17142
17143 let err = runner
17144 .after_implement()
17145 .expect_err("no candidate declared anything; this is an ordinary failure");
17146
17147 assert!(
17148 err.to_string().contains("no candidate produced a change"),
17149 "{err}"
17150 );
17151 assert_eq!(runner.state.status, RunStatus::Failed);
17152 }
17153}
17154
17155#[derive(Debug)]
17158struct GraftRestoreFailed(String);
17159
17160impl std::fmt::Display for GraftRestoreFailed {
17161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17162 write!(
17163 f,
17164 "could not restore the winner to its pre-graft commit: {}",
17165 self.0
17166 )
17167 }
17168}
17169
17170impl std::error::Error for GraftRestoreFailed {}
17171
17172#[cfg(test)]
17176mod resource_wait_tests {
17177 use super::*;
17178 use std::collections::BTreeMap;
17179
17180 fn sh(id: &str, script: &str) -> AgentSpec {
17181 AgentSpec {
17182 id: id.to_owned(),
17183 kind: crate::config::AgentKind::Command,
17184 model: None,
17185 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
17186 extra_args: Vec::new(),
17187 env: BTreeMap::new(),
17188 prompt_delivery: None,
17189 }
17190 }
17191
17192 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
17193 SeatJob {
17194 seat: SeatState::new("impl-A", &spec.id, 1),
17195 spec,
17196 cwd: dir.to_path_buf(),
17197 prompt: "do it".to_owned(),
17198 timeout,
17199 allow_write: true,
17200 sessions: false,
17201 artifacts: dir.join("art"),
17202 stem: "impl-A".to_owned(),
17203 handover: None,
17204 }
17205 }
17206
17207 fn state(resource_wait_secs: u64) -> RunState {
17208 let mut config = Config::default();
17209 config.graph.timeout_resource_wait = resource_wait_secs;
17210 RunState::new(
17211 PathBuf::from("/repo"),
17212 "main".to_owned(),
17213 "abc1234".to_owned(),
17214 "task".to_owned(),
17215 config,
17216 )
17217 }
17218
17219 fn hold(cache: &Path) -> crate::cache::Guard {
17220 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
17221 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
17222 crate::cache::AcquireOutcome::Acquired(g) => g,
17223 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
17224 }
17225 }
17226
17227 async fn run(
17228 jobs: Vec<SeatJob>,
17229 sem: Arc<Semaphore>,
17230 cache: Option<&Path>,
17231 st: &mut RunState,
17232 ) -> Vec<(usize, SeatState, AgentOutcome)> {
17233 let prompts = Prompts::default();
17234 let ctx = WaveCtx {
17235 run: "r",
17236 node: "implement",
17237 prompts: &prompts,
17238 cache,
17239 round: None,
17240 carry_seats: false,
17241 pauses: None,
17242 };
17243 wave(jobs, sem, &ctx, st, 0).await
17244 }
17245
17246 #[tokio::test]
17247 async fn verification_cache_wait_preserves_the_command_budget() {
17248 crate::run::pin_test_home();
17249 let tmp = tempfile::tempdir().unwrap();
17250 let cache = tmp.path().join("target");
17251 let guard = hold(&cache);
17252 let release = tokio::spawn(async move {
17253 tokio::time::sleep(Duration::from_millis(100)).await;
17254 guard.release();
17255 });
17256 let mut st = state(10);
17257 let budget = Duration::from_millis(50);
17258 let wait = repository_wait(&st, None);
17259 let (outcomes, _) = with_cache_lease(
17260 &mut st,
17261 Some(&cache),
17262 "gate",
17263 "gate",
17264 tmp.path(),
17265 "abc1234",
17266 budget,
17267 "test gate",
17268 &wait,
17269 |_, available| async move {
17270 assert_eq!(
17271 available, budget,
17272 "contention must not shorten verification"
17273 );
17274 (Vec::new(), false, Vec::new())
17275 },
17276 )
17277 .await
17278 .unwrap();
17279 release.await.unwrap();
17280 assert!(outcomes.is_empty(), "{outcomes:?}");
17281 assert!(
17282 st.events
17283 .iter()
17284 .any(|event| event.message.contains("waiting for"))
17285 );
17286 assert!(st.resource_wait.is_none());
17287 hold(&cache).release();
17289 }
17290
17291 #[tokio::test]
17292 async fn verification_cache_wait_expires_without_starting_commands() {
17293 crate::run::pin_test_home();
17294 let tmp = tempfile::tempdir().unwrap();
17295 let cache = tmp.path().join("target");
17296 let _held = hold(&cache);
17297 let mut st = state(0);
17298 let wait = repository_wait(&st, None);
17299 let (outcomes, _) = with_cache_lease(
17300 &mut st,
17301 Some(&cache),
17302 "gate",
17303 "gate",
17304 tmp.path(),
17305 "abc1234",
17306 Duration::from_secs(60),
17307 "test gate",
17308 &wait,
17309 |_, _| async { panic!("commands must not start without the cache lease") },
17310 )
17311 .await
17312 .unwrap();
17313 assert_eq!(outcomes.len(), 1);
17314 assert!(outcomes[0].resource_blocked);
17315 assert!(st.resource_wait.is_none());
17316 }
17317
17318 #[tokio::test]
17319 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
17320 crate::run::pin_test_home();
17321 let tmp = tempfile::tempdir().unwrap();
17322 let cache = tmp.path().join("target-a");
17323 let guard = hold(&cache);
17324 tokio::spawn(async move {
17326 tokio::time::sleep(Duration::from_millis(1500)).await;
17327 guard.release();
17328 });
17329 let mut st = state(60);
17330 let results = run(
17331 vec![job(
17332 sh("alpha", "echo ok"),
17333 tmp.path(),
17334 Duration::from_secs(1),
17335 )],
17336 Arc::new(Semaphore::new(1)),
17337 Some(&cache),
17338 &mut st,
17339 )
17340 .await;
17341 assert!(
17342 matches!(results[0].2, AgentOutcome::Ok(_)),
17343 "the wait must not be charged to the agent"
17344 );
17345 assert!(st.resource_block.is_none());
17346 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
17347 assert!(st.resource_wait.is_none() && st.active.is_empty());
17348 }
17349
17350 #[tokio::test]
17351 #[cfg(unix)]
17352 async fn repository_wait_in_a_wave_is_a_bounded_cancellable_resource_block() {
17353 use std::os::fd::AsRawFd as _;
17354 crate::run::pin_test_home();
17355 let tmp = tempfile::tempdir().unwrap();
17356 crate::git::git(tmp.path(), &["init"]).await.unwrap();
17357 let common = tmp.path().join(".git");
17358 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
17359 let external = std::fs::File::create(common.join("updater.lock")).unwrap();
17360 assert_eq!(
17361 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
17362 0
17363 );
17364 let mut st = state(1);
17365 st.config.coordination = crate::config::test_coordination();
17366 let mut reader = job(
17367 sh("reader", "echo must-not-run"),
17368 tmp.path(),
17369 Duration::from_secs(30),
17370 );
17371 reader.allow_write = false;
17372 let results = run(
17373 vec![reader.clone()],
17374 Arc::new(Semaphore::new(1)),
17375 None,
17376 &mut st,
17377 )
17378 .await;
17379 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
17380 assert_eq!(results[0].1.turns, 0);
17381 assert!(FailClass::of(&results[0].2).is_none());
17382 assert_eq!(
17383 st.resource_block.as_ref().unwrap().kind,
17384 "repository coordination"
17385 );
17386 assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
17387 assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
17388 drop(external);
17389 let editor = crate::repo_lock::SeatLease::for_checkout(
17390 tmp.path(),
17391 true,
17392 crate::config::test_coordination().as_ref(),
17393 )
17394 .await
17395 .unwrap();
17396 let pause = Pause::new();
17397 let interrupt = Pause::new();
17398 let prompts = Prompts::default();
17399 let ctx = WaveCtx {
17400 run: "r",
17401 node: "review",
17402 prompts: &prompts,
17403 cache: None,
17404 round: None,
17405 carry_seats: false,
17406 pauses: Some((&pause, &interrupt)),
17407 };
17408 let (results, ()) = tokio::join!(
17409 wave(vec![reader], Arc::new(Semaphore::new(1)), &ctx, &mut st, 0),
17410 async {
17411 tokio::time::sleep(Duration::from_millis(50)).await;
17412 pause.park();
17413 }
17414 );
17415 assert!(
17416 matches!(&results[0].2, AgentOutcome::ResourceBlocked(why) if why.contains("parking"))
17417 );
17418 assert_eq!(results[0].1.turns, 0);
17419 assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
17420 assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
17421 drop(editor);
17422 }
17423
17424 #[tokio::test]
17425 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
17426 crate::run::pin_test_home();
17427 let tmp = tempfile::tempdir().unwrap();
17428 let cache = tmp.path().join("target-b");
17429 let _held = hold(&cache);
17430 let mut st = state(1);
17431 let results = run(
17432 vec![job(
17433 sh("alpha", "echo ok"),
17434 tmp.path(),
17435 Duration::from_secs(1),
17436 )],
17437 Arc::new(Semaphore::new(1)),
17438 Some(&cache),
17439 &mut st,
17440 )
17441 .await;
17442 let (_, seat, out) = &results[0];
17443 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
17444 assert!(wave_blocked(&results));
17445 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
17446 assert_eq!(seat.turns, 0, "nothing was asked");
17447 assert!(
17448 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
17449 "no agent was spawned"
17450 );
17451 assert!(FailClass::of(out).is_none(), "not a failure class");
17452 let block = st.resource_block.as_ref().expect("recorded for the resume");
17453 assert_eq!(block.kind, "shared build cache");
17454 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
17455 assert!(st.active.is_empty());
17456 }
17457
17458 #[tokio::test]
17459 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
17460 crate::run::pin_test_home();
17461 let tmp = tempfile::tempdir().unwrap();
17462 let mut st = state(1);
17463 let results = run(
17464 vec![job(
17465 sh("alpha", "echo ok"),
17466 tmp.path(),
17467 Duration::from_secs(1),
17468 )],
17469 Arc::new(Semaphore::new(0)),
17470 None,
17471 &mut st,
17472 )
17473 .await;
17474 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
17475 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
17476 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
17477 }
17478
17479 #[tokio::test]
17480 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
17481 crate::run::pin_test_home();
17482 let tmp = tempfile::tempdir().unwrap();
17483 let mut st = state(60);
17484 let results = run(
17485 vec![job(
17486 sh("alpha", "sleep 30"),
17487 tmp.path(),
17488 Duration::from_secs(1),
17489 )],
17490 Arc::new(Semaphore::new(1)),
17491 None,
17492 &mut st,
17493 )
17494 .await;
17495 let out = &results[0].2;
17496 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
17497 let class = FailClass::of(out).expect("a failure");
17498 assert_eq!(class, FailClass::Timeout);
17499 assert!(should_hand_over(None, &class), "existing handover policy");
17500 assert!(st.resource_block.is_none());
17501 }
17502}