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
581struct DriverExecution<'a> {
584 runner: &'a mut Runner,
585 driver: crate::driver::DriverGuard,
586}
587
588impl Drop for DriverExecution<'_> {
589 fn drop(&mut self) {
590 self.driver.release_local(&mut self.runner.state);
591 }
592}
593
594impl Runner {
595 pub async fn start(
597 repo: &Path,
598 instruction: String,
599 config: Config,
600 origin: Origin,
601 ) -> Result<Self> {
602 Self::start_naming(repo, instruction, "", config, origin).await
603 }
604
605 pub async fn start_naming(
609 repo: &Path,
610 instruction: String,
611 also_scan: &str,
612 config: Config,
613 origin: Origin,
614 ) -> Result<Self> {
615 let repo = git::toplevel(repo).await?;
616 let missing = agent::missing_programs(&config.agents);
617 if !missing.is_empty() {
618 bail!(
619 "these agent programs are not on PATH: {}. Fix the roster in \
620 magi.toml or install them.",
621 missing.join(", ")
622 );
623 }
624 let base_branch =
625 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
626 .await?;
627 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
628 let roles = config.resolve_roles()?;
629 let max_parallel = config.graph.max_parallel.max(1);
630 let seeds = refs::resolve(
633 &repo,
634 &base_commit,
635 &config.merge.remote,
636 &format!("{also_scan}\n{instruction}"),
637 )
638 .await;
639 refs::plan(&repo, &seeds).await?;
640 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
641 state.origin = Some(origin);
645 for seed in &seeds {
646 state.event(
647 "seed",
648 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
649 );
650 }
651 state.seeds = seeds;
652 state.event("start", format!("run {} created", state.id));
653 state.save()?;
654 Ok(Self {
655 state,
656 roles,
657 sem: Arc::new(Semaphore::new(max_parallel)),
658 pause: Pause::new(),
659 interrupt: Pause::new(),
660 })
661 }
662
663 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
677 Self::review_taking_over(repo, branch, config, None, origin).await
678 }
679
680 pub async fn review_taking_over(
687 repo: &Path,
688 branch: &str,
689 config: Config,
690 takeover: Option<crate::handover::Takeover>,
691 origin: Origin,
692 ) -> Result<Self> {
693 let repo = git::toplevel(repo).await?;
694 let missing = agent::missing_programs(&config.agents);
695 if !missing.is_empty() {
696 bail!(
697 "these agent programs are not on PATH: {}. Fix the roster in \
698 magi.toml or install them.",
699 missing.join(", ")
700 );
701 }
702 let base_branch =
703 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
704 .await?;
705 if base_branch == branch {
706 bail!("`{branch}` is the base branch; there is nothing to review against");
707 }
708 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
709
710 let roles = config.resolve_roles()?;
711 let max_parallel = config.graph.max_parallel.max(1);
712 let mut state = RunState::new(
713 repo.clone(),
714 base_branch,
715 base_commit.clone(),
716 String::new(),
717 config,
718 );
719 state.origin = Some(origin);
720
721 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
728 earlier: Vec::new(),
729 home: crate::run::home(),
730 choice: None,
731 task_instruction: None,
732 });
733 let wait = repository_wait(&state, None);
734 let coordination_config = state.config.coordination.clone();
735 let coordination =
736 crate::repo_lock::SeatLease::operation_with(&repo, coordination_config.as_ref(), &wait)
737 .await?;
738 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
739 if let Some(released) = &released {
740 state.event(
741 "release",
742 format!(
743 "took `{branch}` over from run {}: its worktree was released: {}",
744 crate::run::short_of(&released.old_id),
745 released.audit
746 ),
747 );
748 }
749 if let Some(choice) = takeover.choice.as_ref()
753 && let Err(e) =
754 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
755 .await
756 {
757 if let Some(released) = &released {
758 released.restore(&repo, branch).await;
759 }
760 return Err(e.context("applying the owner's answer about the diverged branch"));
761 }
762 for id in takeover.earlier.iter().rev() {
768 if let Ok(old) = RunState::load_under(id, &takeover.home)
769 && let Some(head) = old.pr_head.clone()
770 && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
771 {
772 state.pr_head = Some(head);
773 break;
774 }
775 }
776 drop(coordination);
777 let opened = Self::open_review(
778 &repo,
779 branch,
780 state,
781 roles,
782 max_parallel,
783 base_commit,
784 &takeover,
785 )
786 .await;
787 if opened.is_err()
788 && let Some(released) = &released
789 {
790 let _coordination = crate::repo_lock::SeatLease::operation_with(
791 &repo,
792 coordination_config.as_ref(),
793 &wait,
794 )
795 .await?;
796 released.restore(&repo, branch).await;
797 }
798 opened
799 }
800
801 pub async fn recover_pr(
803 repo: &Path,
804 task: &str,
805 selection: crate::pr_recovery::Selection,
806 config: Config,
807 ) -> Result<Self> {
808 selection.check_task(task, &selection.instruction)?;
809 let base_branch =
810 git::merge_base_branch(repo, &config.merge.remote, config.merge.base.as_deref())
811 .await?;
812 let base_commit = resolve_base(repo, &base_branch, &config.merge.remote).await?;
813 selection
814 .verify(repo, &config.merge.remote, &base_branch, &selection.head)
815 .await?;
816 let roles = config.resolve_roles()?;
817 let max_parallel = config.graph.max_parallel.max(1);
818 let mut state = RunState::new(
819 repo.to_path_buf(),
820 base_branch,
821 base_commit.clone(),
822 selection.instruction.clone(),
823 config,
824 );
825 state.origin = Some(crate::run::Origin::queue(task));
826 state.pr_head = Some(selection.branch.clone());
827 state.pr_recovery = Some(selection.clone());
828 crate::pr_head::check_predecessors(&state, &selection.branch).await?;
829 Self::open_selected(repo, state, roles, max_parallel, base_commit, selection).await
830 }
831
832 async fn open_selected(
833 repo: &Path,
834 state: RunState,
835 roles: ResolvedRoles,
836 max_parallel: usize,
837 base_commit: String,
838 selection: crate::pr_recovery::Selection,
839 ) -> Result<Self> {
840 let mut state = state;
841 let branch = state.branch_for('A');
842 let wait = repository_wait(&state, None);
843 let coordination = coordinate_operation(&mut state, repo, &wait).await?;
844 git::git(repo, &["branch", &branch, &selection.head]).await?;
845 drop(coordination);
846 state.event(
847 "recovery",
848 format!(
849 "selected public implementation {} from {}",
850 selection.head, selection.url
851 ),
852 );
853 let takeover = crate::handover::Takeover {
854 earlier: Vec::new(),
855 home: crate::run::home(),
856 choice: None,
857 task_instruction: Some(selection.instruction.clone()),
858 };
859 state.save()?;
860 let id = state.id.clone();
861 let opened = Self::open_review(
862 repo,
863 &branch,
864 state,
865 roles,
866 max_parallel,
867 base_commit,
868 &takeover,
869 )
870 .await;
871 match opened {
872 Ok(runner) => Ok(runner),
873 Err(e) => {
874 if let Ok(mut partial) = RunState::load(&id) {
875 partial.status = RunStatus::Blocked;
876 partial.driver_exited = true;
877 partial.event(
878 "recovery",
879 format!("startup failed; retained source and candidate {branch}: {e:#}"),
880 );
881 partial.save()?;
882 }
883 Err(crate::pr_recovery::StartupFailed {
884 run: id,
885 cause: format!("{e:#}"),
886 }
887 .into())
888 }
889 }
890 }
891
892 async fn inherit(
897 repo: &Path,
898 branch: &str,
899 base_commit: &str,
900 takeover: &crate::handover::Takeover,
901 ) -> Option<crate::recovery::RecoveryRecord> {
902 use crate::recovery::{Relation, assess};
903 let newest = takeover.earlier.last()?;
904 let mut chosen = None;
905 for id in takeover.earlier.iter().rev() {
906 if let Ok(old) = RunState::load_under(id, &takeover.home)
907 && !old.reviews.is_empty()
908 && (old.winner().is_some_and(|w| w.branch == branch)
909 || old.pr_head.as_deref() == Some(branch))
910 {
911 chosen = Some(old);
912 break;
913 }
914 }
915 let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
916 .await
917 .unwrap_or_default();
918 let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
919 Some(last) if last.head == tip => Relation::Same,
920 Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
921 _ => Relation::Diverged,
922 };
923 let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
924 Some(assess(from, chosen.as_ref(), base_commit, relation))
925 }
926
927 async fn open_review(
930 repo: &Path,
931 branch: &str,
932 mut state: RunState,
933 roles: ResolvedRoles,
934 max_parallel: usize,
935 base_commit: String,
936 takeover: &crate::handover::Takeover,
937 ) -> Result<Self> {
938 let wait = repository_wait(&state, None);
939 let _coordination = coordinate_operation(&mut state, repo, &wait).await?;
940 if state.pr_recovery.is_none() {
941 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
942 }
943 let start = review_base(
946 repo,
947 &state.config.merge.remote,
948 &state.base_branch,
949 &base_commit,
950 branch,
951 )
952 .await;
953 let log = git::log_oneline(repo, &start, branch)
954 .await
955 .unwrap_or_default();
956 let log_text = if log.trim().is_empty() {
957 "(no commit messages)"
958 } else {
959 log.trim()
960 };
961 let mut instruction = format!(
962 "Review the work already on branch `{branch}`. There is no task \
963 statement: what the change claims to do is whatever its commits \
964 say.\n\n{log_text}"
965 );
966 if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
970 if let Some(task) = takeover
971 .task_instruction
972 .as_deref()
973 .filter(|t| !t.trim().is_empty())
974 && rec.reviewed_head.is_some()
975 {
976 instruction = format!(
977 "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
978 task.trim()
979 );
980 rec.criteria_inherited = true;
981 }
982 state.event("recovery", rec.summary());
983 state.recovery = Some(rec);
984 }
985 if let Some(selected) = &state.pr_recovery {
986 instruction = selected.instruction.clone();
987 }
988 state.task_instruction = takeover.task_instruction.clone();
989 state.instruction = instruction;
990 state.reviewed_commits = Some(
991 git::subjects(repo, &start, branch)
992 .await
993 .unwrap_or_default(),
994 );
995
996 let worktree = state.worktree_root().join("under-review");
999 if let Some(parent) = worktree.parent() {
1000 tokio::fs::create_dir_all(parent).await.ok();
1001 }
1002 let path = worktree.to_string_lossy().to_string();
1003 git::git(repo, &["worktree", "add", &path, branch])
1004 .await
1005 .with_context(|| {
1006 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
1007 })?;
1008
1009 let setup = match ensure_setup_config(&mut state, repo).await {
1010 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
1011 Err(e) => Err(e),
1012 };
1013 if let Err(e) = setup {
1014 git::worktree_remove_clean(repo, &worktree).await.ok();
1015 return Err(e);
1016 }
1017 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
1018 .await
1019 .unwrap_or(0);
1020 if commits == 0 {
1021 git::worktree_remove_clean(repo, &worktree).await.ok();
1022 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
1023 }
1024 let files = git::changed_files(&worktree, &base_commit, "HEAD")
1025 .await
1026 .map(|f| f.len())
1027 .unwrap_or(0);
1028 if files == 0
1029 && let (Ok(head_tree), Ok(base_tree)) = (
1030 git::tree_of(&worktree, "HEAD").await,
1031 git::tree_of(&worktree, &base_commit).await,
1032 )
1033 && head_tree == base_tree
1034 {
1035 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
1036 git::worktree_remove_clean(repo, &worktree).await.ok();
1037 bail!(
1038 "`{branch}` at {} has a tree identical to base {}; this usually means \
1039 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
1040 against `{}/{branch}`) rather than an empty change",
1041 short(&head),
1042 short(&base_commit),
1043 state.config.merge.remote
1044 );
1045 }
1046 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
1047 .await
1048 .unwrap_or_default();
1049
1050 state.candidates.push(Candidate {
1051 index: 0,
1052 label: 'A',
1053 agent: EXISTING_BRANCH.to_owned(),
1056 branch: branch.to_owned(),
1057 worktree,
1058 summary: String::new(),
1059 stat,
1060 files,
1061 commits,
1062 empty: false,
1063 failed: None,
1064 verified_noop: None,
1065 duration_ms: 0,
1066 folded: false,
1067 });
1068 state.tally = Some(Tally {
1069 first_choice: BTreeMap::from([('A', 0)]),
1070 borda: BTreeMap::new(),
1071 winner: 'A',
1072 rankings: 0,
1073 unanimous_initial: false,
1074 deliberated: false,
1075 changed_votes: 0,
1076 unanimous_final: false,
1077 tie_break: None,
1078 judges: 0,
1082 present: 0,
1083 quorum: 0,
1084 met_quorum: true,
1085 uncontested: Some("review-only run: nothing competed".to_owned()),
1086 });
1087 state.status = RunStatus::Reviewing;
1088 state.prep_complete = Some(true);
1089 state.event(
1090 "start",
1091 format!(
1092 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
1093 state.id
1094 ),
1095 );
1096 state.save()?;
1097 Ok(Self {
1098 state,
1099 roles,
1100 sem: Arc::new(Semaphore::new(max_parallel)),
1101 pause: Pause::new(),
1102 interrupt: Pause::new(),
1103 })
1104 }
1105
1106 pub fn resume(id: &str) -> Result<Self> {
1108 let state = RunState::load(id)?;
1109 crate::pr_head::ensure_owner(&state)?;
1110 if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1111 bail!(
1112 "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1113 );
1114 }
1115 if let Some(to) = &state.released_to {
1116 bail!(
1117 "run {} cannot be resumed: its worktree was released to run {}",
1118 state.short(),
1119 crate::run::short_of(to)
1120 );
1121 }
1122 let roles = state.config.resolve_roles()?;
1123 if let Some(id) = &state.config.graph.selected_implementer
1124 && state.candidates.iter().any(|c| c.agent != *id)
1125 {
1126 bail!(
1127 "replacement implementer pin does not match the saved candidates; task must stay held"
1128 );
1129 }
1130 let max_parallel = state.config.graph.max_parallel.max(1);
1131 Ok(Self {
1132 state,
1133 roles,
1134 sem: Arc::new(Semaphore::new(max_parallel)),
1135 pause: Pause::new(),
1136 interrupt: Pause::new(),
1137 })
1138 }
1139
1140 pub async fn execute(&mut self) -> Result<()> {
1147 let _repair_claim = if self.state.config.graph.selected_implementer.is_some()
1150 || self.state.land_approval.is_some()
1151 || self.state.merge_decision.is_some()
1152 || !self.state.merge_repairs.is_empty()
1153 || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1154 {
1155 let claim = FixClaim::acquire(&self.state.dir())?;
1156 let disk = RunState::load(&self.state.id)?;
1157 let checkpoint = |s: &RunState| {
1158 (
1159 s.merge_repairs.len(),
1160 s.merge_repairs
1161 .last()
1162 .map(|r| (r.question.clone(), r.phase)),
1163 s.land_approval
1164 .as_ref()
1165 .map(|a| (a.question.clone(), a.head.clone())),
1166 )
1167 };
1168 if checkpoint(&disk) != checkpoint(&self.state) {
1169 bail!(
1170 "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1171 );
1172 }
1173 Some(claim)
1174 } else {
1175 None
1176 };
1177 let mut execution = self.claim_driver()?;
1178 let runner = &mut *execution.runner;
1179 let wait = runner.repository_wait();
1180 let result = wait.scope(Box::pin(runner.execute_graph())).await;
1183 let result = match result {
1184 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
1185 record_repository_block(&mut runner.state, &e);
1186 runner.park_here()?;
1187 Ok(())
1188 }
1189 other => other,
1190 };
1191 execution.driver.finish(&mut runner.state)?;
1192 let ended = if result.is_err() {
1193 Some(crate::notices::run_stopped(&runner.state.id, &runner.state))
1194 } else {
1195 crate::notices::run_ended(&runner.state)
1196 };
1197 if let Some(notice) = ended {
1198 crate::notices::raise(notice);
1199 }
1200 result
1201 }
1202
1203 fn repository_wait(&self) -> crate::repo_lock::WaitPolicy {
1204 repository_wait(&self.state, Some((&self.pause, &self.interrupt)))
1205 }
1206
1207 async fn coordinate(&mut self, cwd: &Path) -> Result<Option<crate::repo_lock::SeatLease>> {
1208 let wait = self.repository_wait();
1209 coordinate_operation(&mut self.state, cwd, &wait).await
1210 }
1211
1212 fn claim_driver(&mut self) -> Result<DriverExecution<'_>> {
1214 let owner = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1215 let disk = RunState::load(&self.state.id)?;
1216 crate::pr_head::ensure_owner(&disk)?;
1217 if disk.liveness(false) == crate::run::Liveness::Live {
1218 bail!("run already has a live driver; wait for its execution to end");
1219 }
1220 if let Some(to) = &disk.released_to {
1221 bail!(
1222 "run {} cannot continue: its worktree was released to run {}",
1223 self.state.short(),
1224 crate::run::short_of(to)
1225 );
1226 }
1227 let driver = crate::driver::DriverGuard::acquire(&crate::run::home(), &disk)?;
1228 let mut execution = DriverExecution {
1229 runner: self,
1230 driver,
1231 };
1232 let _owner = owner;
1235 let state = &mut execution.runner.state;
1236 state.parked = false;
1241 state.resource_wait = None;
1242 state.resource_block = None;
1243 state.clear_active();
1250 let pid = std::process::id();
1265 state.driver_pid = Some(pid);
1266 state.driver_started_at = crate::proc::process_started_at(pid);
1267 state.driver_exited = false;
1268 state.begin_pass();
1269 execution.driver.bind(state);
1270 state.save()?;
1271 execution.driver.record(state)?;
1272 Ok(execution)
1273 }
1274
1275 async fn execute_graph(&mut self) -> Result<()> {
1276 self.persist_candidates().await?;
1279 if self
1280 .state
1281 .merge_decision
1282 .as_ref()
1283 .is_some_and(|decision| !decision.closed)
1284 && self.state.status != RunStatus::Landing
1285 {
1286 if crate::merge_decision::resumable_hold(&self.state) {
1287 self.state.merge = Some(MergeOutcome {
1288 mode: MergeMode::Pr,
1289 ok: false,
1290 detail: self
1291 .state
1292 .merge_decision
1293 .as_ref()
1294 .unwrap()
1295 .target
1296 .pr
1297 .clone(),
1298 empty: false,
1299 });
1300 self.run_land().await?;
1301 self.settle_questions();
1302 return Ok(());
1303 }
1304 bail!(
1305 "owner stop decision is held; inspect the existing PR and request an explicit fresh review/decision before further work"
1306 );
1307 }
1308 if self.state.config.graph.selected_implementer.is_some()
1309 && self.state.status == RunStatus::Blocked
1310 && self.state.tally.is_none()
1311 && self.state.candidate_preservation.is_empty()
1312 {
1313 bail!(
1314 "selected implementation is held; explicitly release the owning task to retry it"
1315 );
1316 }
1317
1318 if self.state.merge_repairs.last().is_some_and(|r| {
1319 matches!(
1320 r.phase,
1321 crate::run::MergeRepairPhase::Requested
1322 | crate::run::MergeRepairPhase::Fixing
1323 | crate::run::MergeRepairPhase::Reviewing
1324 )
1325 }) {
1326 if self.repair_merge_review().await? {
1327 self.run_land().await?;
1328 }
1329 self.settle_questions();
1330 return Ok(());
1331 }
1332 if self.state.pr_publication_pending {
1335 self.merge().await?;
1336 if self.resource_halt()? {
1337 return Ok(());
1338 }
1339 self.state.save()?;
1340 self.settle_questions();
1341 return Ok(());
1342 }
1343 if self.state.status == RunStatus::Stalled {
1356 if self.recover_stall().await? {
1357 self.finish_after_tally().await?;
1358 } else {
1359 self.state.save()?;
1361 }
1362 return Ok(());
1363 }
1364 if self.state.status == RunStatus::Landing {
1374 self.run_land().await?;
1375 self.settle_questions();
1380 return Ok(());
1381 }
1382 if self.state.github_text.is_some()
1387 && self.state.merge.is_none()
1388 && self.state.status == RunStatus::Gating
1389 {
1390 self.merge().await?;
1391 if self.resource_halt()? {
1392 return Ok(());
1393 }
1394 self.state.save()?;
1395 return Ok(());
1396 }
1397 if let Err(e) = self.prep().await {
1398 if e.is::<crate::repo_lock::ResourceBlocked>() {
1399 return Err(e);
1400 }
1401 self.state.status = RunStatus::Blocked;
1402 self.state
1403 .prep_failure
1404 .get_or_insert_with(|| format!("{e:#}"));
1405 self.state
1406 .event("prep", format!("preparation stopped: {e:#}"));
1407 self.state.save()?;
1408 return Err(e);
1409 }
1410 if self.park_here()? {
1411 return Ok(());
1412 }
1413 self.advise().await?;
1414 if self.park_here()? {
1415 return Ok(());
1416 }
1417 self.implement().await?;
1418 if self.park_here()? {
1419 return Ok(());
1420 }
1421 if self.state.status == RunStatus::VerifiedNoop {
1425 return Ok(());
1426 }
1427 self.judge().await?;
1428 if self.park_here()? {
1429 return Ok(());
1430 }
1431 self.deliberate().await?;
1432 if self.park_here()? {
1433 return Ok(());
1434 }
1435 self.vote().await?;
1436 if self.park_here()? {
1437 return Ok(());
1438 }
1439 self.tally()?;
1440 if self.state.status == RunStatus::Stalled {
1445 self.state.save()?;
1449 return Ok(());
1450 }
1451 self.finish_after_tally().await?;
1452 Ok(())
1453 }
1454
1455 fn stop_for_resource(&mut self) -> Result<()> {
1460 if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1462 {
1463 self.state.status = RunStatus::Blocked;
1464 }
1465 self.state.save()
1466 }
1467
1468 fn resource_halt(&mut self) -> Result<bool> {
1470 if self.state.resource_block.is_none() {
1471 return Ok(false);
1472 }
1473 self.stop_for_resource()?;
1474 Ok(true)
1475 }
1476
1477 fn park_here(&mut self) -> Result<bool> {
1484 let parking = self.pause.parked() || self.interrupt.parked();
1485 if !parking && self.state.resource_block.is_some() {
1486 self.stop_for_resource()?;
1487 return Ok(true);
1488 }
1489 if !self.pause.parked() && !self.interrupt.parked() {
1494 return Ok(false);
1495 }
1496 let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1497 let why = match cause.as_ref().map(ParkCause::label) {
1498 Some(reason) => format!(
1499 "parked after `{}` ({reason}) — resume to carry on from here",
1500 self.state.status.as_str()
1501 ),
1502 None => format!(
1503 "parked after `{}` — resume to carry on from here",
1504 self.state.status.as_str()
1505 ),
1506 };
1507 self.state.event("park", why);
1508 self.state
1509 .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1510 self.state.parked = true;
1511 self.state.save()?;
1512 Ok(true)
1513 }
1514
1515 pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1517 if self.state.prep_failure.is_none() {
1518 return Ok(());
1519 }
1520 let failed_at = self
1521 .state
1522 .events
1523 .iter()
1524 .rev()
1525 .find(|event| event.node == "prep")
1526 .map_or(self.state.updated_at, |event| event.at);
1527 anyhow::ensure!(
1528 approval_at > failed_at,
1529 "preparation retry requires a new explicit release after the failure"
1530 );
1531 self.state.prep_failure = None;
1532 self.state.prep_complete = Some(false);
1533 self.state
1534 .event("prep-retry", "operator approved retry of preparation");
1535 Ok(())
1536 }
1537
1538 pub fn on_pause(&mut self, pause: Pause) {
1540 self.pause = pause;
1541 }
1542
1543 pub fn watch_interrupt(&mut self, pause: Pause) {
1549 self.interrupt = pause;
1550 }
1551
1552 fn settle_questions(&mut self) {
1572 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1573 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1574 }
1575 }
1576
1577 async fn finish_after_tally(&mut self) -> Result<()> {
1580 self.graft().await?;
1583 if self.resource_halt()? {
1584 return Ok(());
1585 }
1586 self.fold_losers().await?;
1587 self.sync_to_base().await?;
1592 if self.state.status == RunStatus::AlreadyInBase {
1593 return Ok(());
1594 }
1595 self.review_loop().await?;
1596 if self.resource_halt()? {
1597 return Ok(());
1598 }
1599 self.sync_to_base().await?;
1600 if self.state.status == RunStatus::AlreadyInBase {
1601 return Ok(());
1602 }
1603 self.gate().await?;
1604 self.merge().await?;
1605 if self.resource_halt()? {
1606 return Ok(());
1607 }
1608 self.state.save()?;
1609 Ok(())
1610 }
1611
1612 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1619 let result = match ensure_setup_config(&mut self.state, repo).await {
1620 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1621 Err(e) => Err(e),
1622 };
1623 if let Err(e) = result {
1624 git::worktree_remove_clean(repo, wt).await.ok();
1625 if in_prep {
1626 self.rollback_prep(repo).await;
1627 }
1628 self.state.status = RunStatus::Blocked;
1629 self.state.event(
1630 "setup",
1631 format!("worktree setup failed in {}: {e:#}", wt.display()),
1632 );
1633 self.state.save()?;
1634 return Err(e);
1635 }
1636 Ok(())
1637 }
1638
1639 async fn rollback_prep(&mut self, repo: &Path) {
1641 let root = self.state.worktree_root();
1642 for c in std::mem::take(&mut self.state.candidates) {
1643 if matches!(
1644 git::worktree_remove_clean(repo, &c.worktree).await,
1645 Ok(true)
1646 ) {
1647 git::branch_delete(repo, &c.branch).await.ok();
1648 } else {
1649 self.state.candidates.push(c);
1650 }
1651 }
1652 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1653 for j in 1..=seats {
1654 git::worktree_remove_clean(repo, &root.join(format!("judge-{j}")))
1655 .await
1656 .ok();
1657 }
1658 for k in 1..=self.state.config.graph.advisors {
1659 git::worktree_remove_clean(repo, &root.join(format!("advisor-{k}")))
1660 .await
1661 .ok();
1662 }
1663 }
1664
1665 async fn prep(&mut self) -> Result<()> {
1666 if let Some(reason) = &self.state.prep_failure {
1667 bail!(
1668 "candidate preparation previously failed: {reason}; correct the source before retrying"
1669 );
1670 }
1671 if self
1672 .state
1673 .prep_complete
1674 .unwrap_or(!self.state.candidates.is_empty())
1675 {
1676 return Ok(());
1677 }
1678 let repo = self.state.repo.clone();
1679 let _coordination = self.coordinate(&repo).await?;
1682 self.rollback_prep(&repo).await;
1683 if !self.state.candidates.is_empty() {
1684 bail!(
1685 "preparation rollback retained worktrees; recover their uncommitted work before retrying"
1686 );
1687 }
1688 self.state.prep_complete = Some(false);
1689 self.state.status = RunStatus::Prep;
1690 self.state.save()?;
1691 let repo = self.state.repo.clone();
1692 let base = self.state.base_commit.clone();
1693 let plan = refs::plan(&repo, &self.state.seeds).await?;
1694 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1695 let root = self.state.worktree_root();
1696 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1697
1698 let hooks_dir = self.state.dir().join("hooks");
1701 if self.state.config.blind.commit_msg_hook {
1702 std::fs::create_dir_all(&hooks_dir)
1703 .with_context(|| format!("create {}", hooks_dir.display()))?;
1704 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1705 let path = hooks_dir.join("commit-msg");
1706 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1707 make_executable(&path)?;
1708 git::acquire_worktree_config(&repo).await?;
1716 self.state.enabled_worktree_config = true;
1717 }
1718
1719 ensure_setup_config(&mut self.state, &repo).await?;
1722
1723 for (index, (spec, label)) in self
1724 .roles
1725 .implementers
1726 .clone()
1727 .into_iter()
1728 .zip(labels)
1729 .enumerate()
1730 {
1731 let branch = self.state.branch_for(label);
1732 let worktree = root.join(format!("cand-{label}"));
1733 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1734 if self.state.config.blind.commit_msg_hook {
1735 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1736 }
1737 git::local_exclude(&worktree, "/.magi/").await?;
1738 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1739 git::worktree_remove_clean(&repo, &worktree).await.ok();
1742 git::branch_delete(&repo, &branch).await.ok();
1743 self.rollback_prep(&repo).await;
1744 self.state.status = RunStatus::Blocked;
1745 self.state
1746 .event("prep", format!("worktree setup failed: {e:#}"));
1747 self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1748 self.state.save()?;
1749 return Err(e);
1750 }
1751 self.state.candidates.push(Candidate {
1752 index,
1753 label,
1754 agent: spec.id.clone(),
1755 branch,
1756 worktree,
1757 summary: String::new(),
1758 stat: String::new(),
1759 files: 0,
1760 commits: 0,
1761 empty: false,
1762 failed: None,
1763 verified_noop: None,
1764 duration_ms: 0,
1765 folded: false,
1766 });
1767 self.state.save()?;
1768 for pick in &plan.picks {
1769 if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1770 {
1771 self.state.status = RunStatus::Blocked;
1772 self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1773 self.state
1774 .event("prep", format!("cannot apply referenced commit: {e}"));
1775 self.state.save()?;
1776 return Err(e);
1777 }
1778 }
1779 }
1780
1781 for j in 1..=self.roles.judges.len() {
1782 let wt = root.join(format!("judge-{j}"));
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 if self.state.config.graph.advise {
1797 for k in 1..=self.state.config.graph.advisors {
1798 let wt = root.join(format!("advisor-{k}"));
1799 if !wt.exists() {
1800 git::worktree_add_detached(&repo, &wt, &base).await?;
1801 self.setup_seat_worktree(&repo, &wt, true).await?;
1802 }
1803 }
1804 }
1805
1806 let authors: Vec<&str> = self
1811 .roles
1812 .implementers
1813 .iter()
1814 .map(|a| a.id.as_str())
1815 .collect();
1816 let overlap: Vec<String> = self
1817 .roles
1818 .judges
1819 .iter()
1820 .enumerate()
1821 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1822 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1823 .collect();
1824 if !overlap.is_empty() {
1825 let note = format!(
1826 "{} also authored a candidate; blind, but the panel is less \
1827 independent than {} distinct agents would be",
1828 overlap.join(", "),
1829 self.roles.judges.len()
1830 );
1831 self.state.event("prep", note);
1832 }
1833
1834 self.state.event(
1835 "prep",
1836 format!(
1837 "{} candidates, {} judges, base {} ({})",
1838 self.state.candidates.len(),
1839 self.roles.judges.len(),
1840 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1841 self.state.base_branch
1842 ),
1843 );
1844 self.state.prep_complete = Some(true);
1845 self.state.status = RunStatus::Implementing;
1846 self.state.save()?;
1847 Ok(())
1848 }
1849
1850 async fn advise(&mut self) -> Result<()> {
1883 let implement_untouched = self
1884 .state
1885 .candidates
1886 .iter()
1887 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1888 if !self.state.config.graph.advise || self.state.advise_attempted {
1889 return Ok(());
1890 }
1891 if !implement_untouched {
1892 self.state.event(
1893 "advise",
1894 "skipping the design-deliberation stage: at least one \
1895 candidate already shows implementation progress, so this \
1896 run is past the point the stage exists to run before"
1897 .to_owned(),
1898 );
1899 self.state.advise_attempted = true;
1900 self.state.save()?;
1901 return Ok(());
1902 }
1903 let run_id = self.state.id.clone();
1904 let prompts = self.state.config.prompts.clone();
1905 let instruction = self.state.instruction.clone();
1906 let language = self.state.config.graph.language.clone();
1907 let root = self.state.worktree_root();
1908 let n = self.state.config.graph.advisors;
1909 let where_recorded = self.state.dir().join("run.json");
1910
1911 let seats = match self.state.config.advisors() {
1912 Ok(seats) if !seats.is_empty() => seats,
1913 Ok(_) => {
1914 self.state.event(
1915 "advise",
1916 format!(
1917 "[graph] advisors is 0; skipping the design-deliberation \
1918 stage and continuing without a synthesis brief (see {})",
1919 where_recorded.display()
1920 ),
1921 );
1922 self.state.advise_attempted = true;
1923 self.state.save()?;
1924 return Ok(());
1925 }
1926 Err(e) => {
1927 self.state.event(
1928 "advise",
1929 format!(
1930 "could not resolve advisor seats ({e:#}); continuing \
1931 without a design-deliberation brief (see {})",
1932 where_recorded.display()
1933 ),
1934 );
1935 self.state.advise_attempted = true;
1936 self.state.save()?;
1937 return Ok(());
1938 }
1939 };
1940
1941 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1942 let artifacts = agent::artifacts_dir(&self.state.dir());
1943 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1944
1945 let mut jobs = Vec::new();
1946 for (i, spec) in seats.iter().cloned().enumerate() {
1947 let seat_key = format!("advisor-{}", i + 1);
1948 let seat = self.seat(&seat_key, &spec.id);
1949 jobs.push(SeatJob {
1950 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1951 spec,
1952 seat,
1953 cwd: worktrees[i % worktrees.len()].clone(),
1954 timeout,
1955 allow_write: false,
1956 sessions: false,
1957 artifacts: artifacts.clone(),
1958 stem: seat_key,
1959 handover: None,
1960 });
1961 }
1962
1963 self.state.event(
1964 "advise",
1965 format!(
1966 "{} advisor seat(s) sketching a design in parallel",
1967 jobs.len()
1968 ),
1969 );
1970 let mut quota_losses = Vec::new();
1971 let cache = self.state.config.cache_dir();
1972 let ctx = WaveCtx {
1973 carry_seats: false,
1974 pauses: Some((&self.pause, &self.interrupt)),
1975 run: &run_id,
1976 node: "advise",
1977 prompts: &prompts,
1978 cache: cache.as_deref(),
1979 round: None,
1980 };
1981 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1982 let results = ask_json_wave::<Proposal>(
1983 jobs,
1984 Arc::clone(&self.sem),
1985 self.state.config.graph.retries,
1986 &advisor_roster,
1987 &ctx,
1988 &mut quota_losses,
1989 &mut self.state,
1990 &|p: &Proposal| p.validate(),
1991 )
1992 .await;
1993 if self.state.resource_block.is_some() {
1994 return self.stop_for_resource();
1995 }
1996 self.state.quota.extend(quota_losses);
1997
1998 let mut records = Vec::with_capacity(results.len());
1999 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
2000 let agent_id = seat.agent.clone();
2001 self.state.seats.insert(seat.key.clone(), seat);
2002 match res {
2003 Ok((proposal, out)) => {
2004 self.state
2005 .event("advise", format!("advisor-{} proposed a design", i + 1));
2006 records.push(advise::AdvisorRecord::proposed(
2007 i + 1,
2008 agent_id,
2009 proposal,
2010 out.duration_ms,
2011 ));
2012 }
2013 Err(e) => {
2014 self.state.event(
2015 "advise",
2016 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
2017 );
2018 records.push(advise::AdvisorRecord::failed(
2019 i + 1,
2020 agent_id,
2021 e.to_string(),
2022 ));
2023 }
2024 }
2025 }
2026
2027 let mut advice = advise::Advice {
2028 records,
2029 synthesis: None,
2030 };
2031 if advice.proposals().is_empty() {
2032 self.state.event(
2033 "advise",
2034 "no advisor produced a usable proposal; continuing without a \
2035 synthesis brief"
2036 .to_owned(),
2037 );
2038 } else {
2039 match self
2040 .synthesize_brief(
2041 &advice,
2042 &instruction,
2043 &language,
2044 &worktrees[0],
2045 &artifacts,
2046 &run_id,
2047 &prompts,
2048 cache.as_deref(),
2049 )
2050 .await
2051 {
2052 Ok(Some(text)) => {
2053 self.state.event(
2054 "advise",
2055 "synthesized a design brief for the implementer".to_owned(),
2056 );
2057 advice.synthesis = Some(text);
2058 }
2059 Ok(None) => {
2060 self.state.event(
2061 "advise",
2062 "the synthesis seat produced nothing usable; continuing \
2063 without a design brief"
2064 .to_owned(),
2065 );
2066 }
2067 Err(e) => {
2068 if e.is::<crate::repo_lock::ResourceBlocked>() {
2069 return Err(e);
2070 }
2071 self.state.event(
2072 "advise",
2073 format!("could not synthesize a design brief: {e:#}"),
2074 );
2075 }
2076 }
2077 }
2078 advise::apply_reflection(&mut advice);
2079
2080 self.state.advice = Some(advice);
2081 self.state.advise_attempted = true;
2082 self.state.save()?;
2083 Ok(())
2084 }
2085
2086 #[allow(clippy::too_many_arguments)]
2098 async fn synthesize_brief(
2099 &mut self,
2100 advice: &advise::Advice,
2101 instruction: &str,
2102 language: &str,
2103 cwd: &Path,
2104 artifacts: &Path,
2105 run_id: &str,
2106 prompts: &Prompts,
2107 cache: Option<&Path>,
2108 ) -> Result<Option<String>> {
2109 let chain = agent::pick_chain(
2110 &self.state.config.agents,
2111 self.state.config.roles.synthesizer.as_ref(),
2112 &agent::installed,
2113 "synthesizer",
2114 )?;
2115 let proposals = advice.proposals();
2116 let mut prompt = prompt::with_overlay(
2117 prompt::synthesize_brief(instruction, &proposals, language),
2118 prompts.overlay("advise"),
2119 );
2120 if cache.is_some() {
2121 prompt.push('\n');
2126 prompt.push_str(&prompt::build_cache_note("advise", false));
2127 }
2128 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2129 let mut last = None;
2134 for (n, spec) in chain.iter().enumerate() {
2135 if n > 0 {
2136 self.state
2137 .event("advise", format!("synthesis falling back to {}", spec.id));
2138 }
2139 let mut seat = self.seat("advise-synthesis", &spec.id);
2140 let wait = self.repository_wait();
2141 let outcome = wait
2142 .scope(agent::invoke(
2143 spec,
2144 &mut seat,
2145 &Invocation {
2146 coordination: self.state.config.coordination.as_ref(),
2147 cwd,
2148 prompt: &prompt,
2149 timeout,
2150 allow_write: false,
2151 unsandboxed: false,
2152 sessions: false,
2153 artifacts,
2154 stem: &if n == 0 {
2155 "advise-synthesis".to_owned()
2156 } else {
2157 format!("advise-synthesis-{}", spec.id)
2158 },
2159 run: run_id,
2160 node: "advise",
2161 cache_dir: None,
2162 attachments: &[],
2163 writable: &[],
2164 },
2165 ))
2166 .await;
2167 if outcome.is_ok() {
2168 self.state.seats.insert(seat.key.clone(), seat);
2169 }
2170 let advance = agent::chain_advances(&outcome);
2171 last = Some(outcome);
2172 if !advance {
2173 break;
2174 }
2175 }
2176 let out = last.expect("a chain holds at least one agent")?;
2179 if !out.usable() {
2180 return Ok(None);
2181 }
2182 let text =
2183 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2184 Ok((!text.trim().is_empty()).then_some(text))
2185 }
2186
2187 async fn implement(&mut self) -> Result<()> {
2190 self.persist_candidates().await?;
2191 let run_id = self.state.id.clone();
2196 let prompts = self.state.config.prompts.clone();
2197 let todo: Vec<usize> = self
2198 .state
2199 .candidates
2200 .iter()
2201 .enumerate()
2202 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2203 .map(|(i, _)| i)
2204 .collect();
2205 if todo.is_empty() {
2206 return self.after_implement();
2207 }
2208 self.state.status = RunStatus::Implementing;
2209
2210 let language = self.state.config.graph.language.clone();
2211 let github_language = self.state.config.graph.github_language.clone();
2212 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2213 let sessions = self.state.config.graph.sessions;
2214 let artifacts = agent::artifacts_dir(&self.state.dir());
2215 let brief = self
2219 .state
2220 .advice
2221 .as_ref()
2222 .and_then(|a| a.synthesis.as_deref())
2223 .map(str::to_owned);
2224 let attachments = self.state.attachments.clone();
2225
2226 let mut jobs = Vec::new();
2227 for &i in &todo {
2228 let (index, label, worktree) = {
2229 let c = &self.state.candidates[i];
2230 (c.index, c.label, c.worktree.clone())
2231 };
2232 let spec = self.roles.implementers[index].clone();
2233 let seat_key = format!("impl-{label}");
2234 let seat = self.seat(&seat_key, &spec.id);
2235 let instruction = seeded_instruction(&self.state);
2236 jobs.push(SeatJob {
2237 spec,
2238 seat,
2239 prompt: prompt::implement_in(
2240 &instruction,
2241 &worktree.to_string_lossy(),
2242 &language,
2243 &github_language,
2244 brief.as_deref(),
2245 &attachments,
2246 ),
2247 cwd: worktree,
2248 timeout,
2249 allow_write: true,
2250 sessions,
2251 artifacts: artifacts.clone(),
2252 stem: format!("impl-{label}"),
2253 handover: None,
2254 });
2255 }
2256
2257 self.state.event(
2258 "implement",
2259 format!("{} candidates in parallel", jobs.len()),
2260 );
2261 let mut sent = jobs.clone();
2267 let cache = self.state.config.cache_dir();
2268 let ctx = WaveCtx {
2269 carry_seats: false,
2270 pauses: Some((&self.pause, &self.interrupt)),
2271 run: &run_id,
2272 node: "implement",
2273 prompts: &prompts,
2274 cache: cache.as_deref(),
2275 round: None,
2276 };
2277 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2278 if !wave_blocked(&results) {
2279 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2280 .await;
2281 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2282 .await?;
2283 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2284 .await;
2285 }
2286 let resource_blocked = self.state.resource_block.is_some() || wave_blocked(&results);
2287
2288 let selected_failed = self.state.config.graph.selected_implementer.is_some()
2289 && results
2290 .iter()
2291 .any(|(_, _, out)| FailClass::of(out).is_some());
2292 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2293 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
2294 continue;
2295 }
2296 let seat_key = seat.key.clone();
2297 let agent = seat.agent.clone();
2306 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2307 self.state.seats.insert(seat.key.clone(), seat);
2308 let label = self.state.candidates[i].label;
2309
2310 let (summary, duration, failed, verified_claim) = match out {
2311 AgentOutcome::Ok(o) => {
2312 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2313 let failed = (!o.usable()).then(|| {
2314 if o.timed_out {
2315 "agent timed out".to_owned()
2316 } else {
2317 format!(
2318 "agent exited with {}",
2319 crate::notices::exit_status(o.exit_code)
2320 )
2321 }
2322 });
2323 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2324 (text, o.duration_ms, failed, verified_claim)
2325 }
2326 AgentOutcome::Dropped(o) => {
2332 let why = o
2333 .dropped
2334 .as_ref()
2335 .map(|d| d.why.as_str())
2336 .unwrap_or("the CLI ended the stream without delivering its answer");
2337 (
2338 String::new(),
2339 o.duration_ms,
2340 Some(format!("the CLI dropped the stream ({why})")),
2341 None,
2342 )
2343 }
2344 AgentOutcome::Quota(o) => {
2345 self.state.quota.push(QuotaLoss {
2346 seat: seat_key,
2347 node: "implement".to_owned(),
2348 at: Timestamp::now(),
2349 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2350 });
2351 (
2352 String::new(),
2353 o.duration_ms,
2354 Some("rate limited (quota); produced no change".to_owned()),
2355 None,
2356 )
2357 }
2358 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2359 (String::new(), 0, Some(e), None)
2360 }
2361 };
2362
2363 let c = &mut self.state.candidates[i];
2366 if !exhausted_the_fallback_chain {
2367 c.agent = agent;
2368 }
2369 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2370 c.duration_ms = duration;
2371 c.failed = failed;
2372 c.verified_noop = verified_claim;
2373 self.state
2374 .candidate_preservation
2375 .insert(label, "awaiting persistence".to_owned());
2376 }
2377 self.state.save()?;
2378 self.persist_candidates().await?;
2379
2380 if resource_blocked {
2381 return self.stop_for_resource();
2382 }
2383 if selected_failed {
2384 self.state.status = RunStatus::Blocked;
2385 self.state.event("implement", "selected implementer failed or hit quota; holding without fallback, review or publication");
2386 self.state.save()?;
2387 bail!(
2388 "selected implementer unavailable or quota-limited; restore availability and explicitly release the task"
2389 );
2390 }
2391 self.after_implement()
2392 }
2393
2394 async fn persist_candidates(&mut self) -> Result<()> {
2396 let labels: Vec<_> = self.state.candidate_preservation.keys().copied().collect();
2397 for label in labels {
2398 let i = self
2399 .state
2400 .candidates
2401 .iter()
2402 .position(|c| c.label == label)
2403 .context("pending candidate is missing")?;
2404 let c = self.state.candidates[i].clone();
2405 let result = async {
2406 let wait = crate::repo_lock::WaitPolicy::new(
2407 Duration::from_secs(self.state.config.graph.timeout_resource_wait),
2408 || false,
2409 );
2410 let _coordination =
2411 coordinate_operation(&mut self.state, &c.worktree, &wait).await?;
2412 if let Err(e) = git::snapshot_uncommitted(
2415 &c.worktree,
2416 &self
2417 .state
2418 .dir()
2419 .join("artifacts")
2420 .join(format!("candidate-{label}-recovery")),
2421 )
2422 .await
2423 {
2424 self.state.event(
2425 "preserve",
2426 format!("candidate {label} snapshot incomplete: {e:#}"),
2427 );
2428 }
2429 let rescued = git::rescue_commit(
2430 &c.worktree,
2431 &format!("magi: candidate {label} (uncommitted work)"),
2432 )
2433 .await?;
2434 self.state.note_withheld("implement", &rescued.withheld);
2435 let base = &self.state.base_commit;
2436 let commits = git::commits_ahead(&c.worktree, base, "HEAD").await?;
2437 let patch = git::diff(&c.worktree, base, "HEAD").await?;
2438 let stat = git::diff_stat(&c.worktree, base, "HEAD").await?;
2439 let files = git::changed_files(&c.worktree, base, "HEAD").await?.len();
2440 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2441 if c.empty && commits > 0 && !patch.trim().is_empty()
2442 && (self.state.judge_skipped || !self.state.judgements.is_empty()
2443 || !self.state.deliberation.is_empty() || !self.state.votes.is_empty()
2444 || self.state.tally.is_some())
2445 {
2446 bail!("candidate {label} changes the judged candidate set; supported recovery and fresh judging are required before continuing");
2450 }
2451 let c = &mut self.state.candidates[i];
2452 c.commits = commits;
2453 c.stat = stat;
2454 c.files = files;
2455 c.empty = commits == 0 || patch.trim().is_empty();
2456 if !c.empty {
2457 c.failed = None;
2458 c.verified_noop = None;
2459 }
2460 self.state.event(
2461 "preserve",
2462 format!("candidate {label}: {files} files, {commits} commits"),
2463 );
2464 Ok::<_, anyhow::Error>(())
2465 }
2466 .await;
2467 match result {
2468 Ok(()) => {
2469 self.state.candidate_preservation.remove(&label);
2470 }
2471 Err(e) => {
2472 let reason = format!("candidate {label} preservation failed: {e:#}");
2473 self.state.event("preserve", &reason);
2474 self.state.candidate_preservation.insert(label, reason);
2475 }
2476 }
2477 if self.state.candidate_preservation.is_empty()
2478 && self.state.status == RunStatus::Blocked
2479 && self.state.candidates.iter().all(|c| c.failed.is_none())
2480 {
2481 self.state.status = RunStatus::Implementing;
2485 self.state.event(
2486 "preserve",
2487 "candidate persistence recovered; implementation can continue",
2488 );
2489 }
2490 self.state.save()?;
2491 }
2492 if !self.state.candidate_preservation.is_empty() {
2493 self.state.status = RunStatus::Blocked;
2494 self.state.save()?;
2495 bail!(
2496 "candidate preservation incomplete; retain worktrees and artifacts, repair persistence and resume this run"
2497 );
2498 }
2499 Ok(())
2500 }
2501
2502 async fn resume_undelivered(
2530 &mut self,
2531 results: &mut [(usize, SeatState, AgentOutcome)],
2532 sent: &[SeatJob],
2533 prompts: &Prompts,
2534 run_id: &str,
2535 ) {
2536 for (wi, seat, out) in results.iter_mut() {
2537 let Some(dropped) = (match &*out {
2538 AgentOutcome::Dropped(o) => o.dropped.clone(),
2539 _ => None,
2540 }) else {
2541 continue;
2542 };
2543 let Some(job) = sent.get(*wi) else { continue };
2544 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2546 self.state.event(
2547 "implement",
2548 format!(
2549 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2550 work is in the tree",
2551 seat.key, dropped.output_tokens, dropped.why
2552 ),
2553 );
2554 continue;
2555 }
2556 if !has_context(&job.spec, seat, job.sessions) {
2564 self.state.event(
2565 "implement",
2566 format!(
2567 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2568 is no session left to resume",
2569 seat.key, dropped.output_tokens, dropped.why
2570 ),
2571 );
2572 continue;
2573 }
2574 self.state.event(
2575 "implement",
2576 format!(
2577 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2578 conversation",
2579 seat.key, dropped.output_tokens, dropped.why
2580 ),
2581 );
2582 let mut retry = job.clone();
2583 retry.seat = seat.clone();
2584 retry.prompt = prompt::resume_after_drop(&dropped.why);
2585 retry.timeout = retry_budget(job.timeout, true);
2586 retry.stem = format!("{}-resume", job.stem);
2587 let cache = self.state.config.cache_dir();
2588 let ctx = WaveCtx {
2589 carry_seats: false,
2590 pauses: Some((&self.pause, &self.interrupt)),
2591 run: run_id,
2592 node: "implement",
2593 prompts,
2594 cache: cache.as_deref(),
2595 round: None,
2596 };
2597 let (resumed_seat, resumed) =
2598 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2599 *seat = resumed_seat;
2600 *out = resumed;
2601 }
2602 }
2603
2604 async fn resume_seat_handovers(
2669 &mut self,
2670 results: &mut [(usize, SeatState, AgentOutcome)],
2671 sent: &mut [SeatJob],
2672 prompts: &Prompts,
2673 run_id: &str,
2674 ) -> Result<()> {
2675 let instruction = seeded_instruction(&self.state);
2676 let language = self.state.config.graph.language.clone();
2677 let github_language = self.state.config.graph.github_language.clone();
2678 let brief = self
2679 .state
2680 .advice
2681 .as_ref()
2682 .and_then(|a| a.synthesis.as_deref())
2683 .map(str::to_owned);
2684 let attachments = self.state.attachments.clone();
2685 for (wi, seat, out) in results.iter_mut() {
2686 let others: BTreeSet<String> = sent
2689 .iter()
2690 .enumerate()
2691 .filter(|(j, _)| j != wi)
2692 .map(|(_, j)| j.spec.id.clone())
2693 .collect();
2694 let Some(job) = sent.get_mut(*wi) else {
2695 continue;
2696 };
2697 let start = self
2702 .roles
2703 .implementer_roster
2704 .iter()
2705 .position(|s| s.id == job.spec.id)
2706 .unwrap_or(0);
2707 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2708 let mut fallback_attempt = 0usize;
2709 let mut prev: Option<FailClass> = None;
2710 while let Some(cur) = FailClass::of(&*out) {
2711 if !should_hand_over(prev.as_ref(), &cur) {
2712 break;
2713 }
2714 let Some(next) =
2715 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2716 .cloned()
2717 else {
2718 break;
2719 };
2720 tried.insert(next.id.clone());
2721 fallback_attempt += 1;
2722
2723 let coordination = self.coordinate(&job.cwd).await?;
2724 if let Ok(r) = git::rescue_commit(
2725 &job.cwd,
2726 &format!(
2727 "magi: candidate {} (uncommitted work before {} fallback)",
2728 seat.key,
2729 if cur == FailClass::Quota {
2730 "quota"
2731 } else {
2732 "handover"
2733 }
2734 ),
2735 )
2736 .await
2737 {
2738 self.state.note_withheld("implement", &r.withheld);
2739 }
2740 drop(coordination);
2741
2742 record_handover(
2743 &mut self.state,
2744 "implement",
2745 &seat.key,
2746 &seat.agent,
2747 &next.id,
2748 &cur,
2749 &fail_reason(&*out),
2750 );
2751 prev = Some(cur.clone());
2752
2753 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2754 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2755 job.spec = next.clone();
2763 let mut retry = job.clone();
2764 retry.seat = new_seat;
2765 retry.prompt = prompt::implement_in(
2766 &instruction,
2767 &job.cwd.to_string_lossy(),
2768 &language,
2769 &github_language,
2770 brief.as_deref(),
2771 &attachments,
2772 );
2773 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2774 let cache = self.state.config.cache_dir();
2775 let ctx = WaveCtx {
2776 carry_seats: false,
2777 pauses: Some((&self.pause, &self.interrupt)),
2778 run: run_id,
2779 node: "implement",
2780 prompts,
2781 cache: cache.as_deref(),
2782 round: None,
2783 };
2784 let (fallback_seat, fallback_out) = run_one(
2785 retry,
2786 Arc::clone(&self.sem),
2787 &ctx,
2788 &mut self.state,
2789 fallback_attempt,
2790 )
2791 .await;
2792 *seat = fallback_seat;
2793 *out = fallback_out;
2794 }
2795 }
2796 Ok(())
2797 }
2798
2799 async fn resume_unconfirmed_commands(
2823 &mut self,
2824 results: &mut [(usize, SeatState, AgentOutcome)],
2825 sent: &[SeatJob],
2826 prompts: &Prompts,
2827 run_id: &str,
2828 ) {
2829 for (wi, seat, out) in results.iter_mut() {
2830 let AgentOutcome::Ok(o) = &*out else {
2831 continue;
2832 };
2833 if !has_unconfirmed_command(&o.commands) {
2834 continue;
2835 }
2836 let Some(job) = sent.get(*wi) else { continue };
2837 if !has_context(&job.spec, seat, job.sessions) {
2838 self.state.event(
2839 "implement",
2840 format!(
2841 "{}: the reply named a command whose own CLI never confirmed the exit \
2842 status of, but there is no session left to resume",
2843 seat.key
2844 ),
2845 );
2846 continue;
2847 }
2848 self.state.event(
2849 "implement",
2850 format!(
2851 "{}: the reply named a command whose own CLI never confirmed the exit \
2852 status of; resuming the conversation",
2853 seat.key
2854 ),
2855 );
2856 let mut retry = job.clone();
2857 retry.seat = seat.clone();
2858 retry.prompt = prompt::resume_incomplete(
2859 "a command in your last reply had no confirmed exit status",
2860 );
2861 retry.timeout = retry_budget(job.timeout, true);
2862 retry.stem = format!("{}-confirm", job.stem);
2863 let cache = self.state.config.cache_dir();
2864 let ctx = WaveCtx {
2865 carry_seats: false,
2866 pauses: Some((&self.pause, &self.interrupt)),
2867 run: run_id,
2868 node: "implement",
2869 prompts,
2870 cache: cache.as_deref(),
2871 round: None,
2872 };
2873 let (resumed_seat, resumed) =
2874 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2875 *seat = resumed_seat;
2876 *out = resumed;
2877 }
2878 }
2879
2880 async fn continue_fix_report(
2901 &mut self,
2902 mut seat: SeatState,
2903 parse_err: String,
2904 job: &SeatJob,
2905 prompts: &Prompts,
2906 run_id: &str,
2907 round: usize,
2908 ) -> (
2909 SeatState,
2910 Option<FixReport>,
2911 Option<String>,
2912 ContinuationRecord,
2913 ) {
2914 let mut last_err = parse_err;
2915 let mut cumulative_wait_ms = 0u64;
2916 let mut attempts = 0usize;
2917 loop {
2918 if !has_context(&job.spec, &seat, job.sessions) {
2919 self.state.event(
2920 "fix",
2921 format!(
2922 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2923 session left to resume into"
2924 ),
2925 );
2926 let outcome = if attempts == 0 {
2927 ContinuationOutcome::NoSession
2928 } else {
2929 ContinuationOutcome::Exhausted
2930 };
2931 return (
2932 seat,
2933 None,
2934 Some(format!("unparsable fix report: {last_err}")),
2935 ContinuationRecord {
2936 attempts,
2937 cumulative_wait_ms,
2938 outcome,
2939 },
2940 );
2941 }
2942 if attempts >= MAX_FIX_CONTINUATIONS {
2943 self.state.event(
2944 "fix",
2945 format!(
2946 "round {round}: fixer's reply still had no adoption report after \
2947 {attempts} continuation(s) ({last_err}); giving up"
2948 ),
2949 );
2950 return (
2951 seat,
2952 None,
2953 Some(format!(
2954 "unparsable fix report after {attempts} continuation(s): {last_err}"
2955 )),
2956 ContinuationRecord {
2957 attempts,
2958 cumulative_wait_ms,
2959 outcome: ContinuationOutcome::Exhausted,
2960 },
2961 );
2962 }
2963 attempts += 1;
2964 self.state.event(
2965 "fix",
2966 format!(
2967 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2968 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2969 ),
2970 );
2971 let mut retry = job.clone();
2972 retry.seat = seat.clone();
2973 retry.prompt = prompt::resume_incomplete(&last_err);
2974 retry.timeout = retry_budget(job.timeout, true);
2975 retry.stem = format!("{}-continue{attempts}", job.stem);
2976 let cache = self.state.config.cache_dir();
2977 let ctx = WaveCtx {
2978 carry_seats: false,
2979 pauses: Some((&self.pause, &self.interrupt)),
2980 run: run_id,
2981 node: "fix",
2982 prompts,
2983 cache: cache.as_deref(),
2984 round: Some(round),
2985 };
2986 let (resumed_seat, resumed_out) = run_one(
2987 retry,
2988 Arc::clone(&self.sem),
2989 &ctx,
2990 &mut self.state,
2991 attempts,
2992 )
2993 .await;
2994 seat = resumed_seat;
2995 match resumed_out {
2996 AgentOutcome::Ok(o) => {
2997 cumulative_wait_ms += o.duration_ms;
2998 match verdict::extract_json::<FixReport>(&o.text) {
2999 Ok(report) if !has_unconfirmed_command(&o.commands) => {
3000 self.state.event(
3001 "fix",
3002 format!(
3003 "round {round}: fixer's adoption report recovered after \
3004 {attempts} continuation(s)"
3005 ),
3006 );
3007 return (
3008 seat,
3009 Some(report),
3010 None,
3011 ContinuationRecord {
3012 attempts,
3013 cumulative_wait_ms,
3014 outcome: ContinuationOutcome::Resumed,
3015 },
3016 );
3017 }
3018 Ok(_) => {
3026 last_err = "the reply parsed, but it reported a command whose own CLI \
3027 never confirmed an exit status"
3028 .to_owned();
3029 }
3030 Err(e) => last_err = e.to_string(),
3031 }
3032 }
3033 AgentOutcome::Quota(o) => {
3034 cumulative_wait_ms += o.duration_ms;
3035 self.state.quota.push(QuotaLoss {
3036 seat: seat.key.clone(),
3037 node: "fix".to_owned(),
3038 at: Timestamp::now(),
3039 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3040 });
3041 self.state.event(
3042 "fix",
3043 format!(
3044 "round {round}: continuation rate limited (quota); not retrying now"
3045 ),
3046 );
3047 return (
3048 seat,
3049 None,
3050 Some("rate limited (quota) while recovering the fix report".to_owned()),
3051 ContinuationRecord {
3052 attempts,
3053 cumulative_wait_ms,
3054 outcome: ContinuationOutcome::QuotaLost,
3055 },
3056 );
3057 }
3058 AgentOutcome::Dropped(o) => {
3059 cumulative_wait_ms += o.duration_ms;
3060 let why = o
3061 .dropped
3062 .as_ref()
3063 .map(|d| d.why.as_str())
3064 .unwrap_or("the CLI ended the stream without delivering its answer");
3065 last_err = format!("the CLI dropped the stream ({why})");
3066 }
3067 AgentOutcome::Failed(e) => last_err = e,
3068 AgentOutcome::ResourceBlocked(e) => {
3071 return (
3072 seat,
3073 None,
3074 Some(format!("the continuation was not started: {e}")),
3075 ContinuationRecord {
3076 attempts: attempts.saturating_sub(1),
3077 cumulative_wait_ms,
3078 outcome: ContinuationOutcome::NoSession,
3079 },
3080 );
3081 }
3082 }
3083 }
3084 }
3085
3086 fn after_implement(&mut self) -> Result<()> {
3087 if !self.state.candidate_preservation.is_empty() {
3088 bail!("candidate preservation incomplete; judging is unsafe");
3089 }
3090 if self.state.leaks.is_empty() {
3092 let cfg = self.state.config.blind.clone();
3093 let mut leaks = Vec::new();
3094 for c in &self.state.candidates {
3095 let Some(patch) =
3096 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
3097 else {
3098 continue;
3099 };
3100 leaks.extend(blind::scan(
3101 &format!("candidate {} patch", c.label),
3102 &patch,
3103 &cfg.vendor_tokens,
3104 ));
3105 }
3106 if !leaks.is_empty() {
3107 let summary = leaks
3108 .iter()
3109 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
3110 .collect::<Vec<_>>()
3111 .join(", ");
3112 match cfg.on_leak {
3113 LeakPolicy::Fail => {
3114 self.state.status = RunStatus::Failed;
3115 self.state
3116 .event("blind", format!("vendor text in a patch: {summary}"));
3117 self.state.leaks = leaks;
3118 self.state.save()?;
3119 self.settle_questions();
3120 bail!(
3121 "blind.on_leak = \"fail\" and vendor text reached a \
3122 judged patch: {summary}"
3123 );
3124 }
3125 LeakPolicy::Redact => self.state.event(
3126 "blind",
3127 format!("redacting vendor text for judging: {summary}"),
3128 ),
3129 LeakPolicy::Warn => self.state.event(
3130 "blind",
3131 format!("vendor text present in a judged patch (shown as-is): {summary}"),
3132 ),
3133 }
3134 self.state.leaks = leaks;
3135 }
3136 }
3137
3138 if self.state.viable().is_empty() {
3139 if self.state.all_candidates_verified_noop() {
3140 self.state.status = RunStatus::VerifiedNoop;
3151 self.state.save()?;
3152 self.settle_questions();
3153 return Ok(());
3154 }
3155 self.state.status = RunStatus::Failed;
3156 self.state.save()?;
3157 self.settle_questions();
3158 bail!("no candidate produced a change; nothing to judge");
3159 }
3160 self.state.status = RunStatus::Judging;
3161 self.state.save()?;
3162 Ok(())
3163 }
3164
3165 async fn judge(&mut self) -> Result<()> {
3168 let run_id = self.state.id.clone();
3173 let prompts = self.state.config.prompts.clone();
3174 if !self.state.judgements.is_empty() || self.state.judge_skipped {
3175 return Ok(());
3176 }
3177 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3178 if viable.len() == 1 {
3179 self.state.judge_skipped = true;
3186 self.state.event(
3187 "judge",
3188 format!(
3189 "only candidate {} produced a change; judging skipped",
3190 viable[0].label
3191 ),
3192 );
3193 self.state.save()?;
3194 return Ok(());
3195 }
3196 self.state.status = RunStatus::Judging;
3197
3198 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3199 let language = self.state.config.graph.language.clone();
3200 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3201 let sessions = self.state.config.graph.sessions;
3202 let artifacts = agent::artifacts_dir(&self.state.dir());
3203 let root = self.state.worktree_root();
3204 let base_short = short(&self.state.base_commit);
3205
3206 let mut jobs = Vec::new();
3207 let mut orders = Vec::new();
3208 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3209 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3210 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3211 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3212 let seat_key = format!("judge-{}", j + 1);
3213 let seat = self.seat(&seat_key, &spec.id);
3214 jobs.push(SeatJob {
3215 prompt: prompt::judge(
3216 &self.state.instruction,
3217 &views,
3218 self.roles.judges.len(),
3219 &base_short,
3220 &language,
3221 ),
3222 spec,
3223 seat,
3224 cwd: root.join(format!("judge-{}", j + 1)),
3225 timeout,
3226 allow_write: false,
3227 sessions,
3228 artifacts: artifacts.clone(),
3229 stem: format!("judge-{}", j + 1),
3230 handover: None,
3231 });
3232 }
3233
3234 self.state.event(
3235 "judge",
3236 format!(
3237 "{} judges ranking {} candidates blind",
3238 jobs.len(),
3239 viable.len()
3240 ),
3241 );
3242 let labels_for_check = labels.clone();
3243 let mut quota_losses = Vec::new();
3244 let cache = self.state.config.cache_dir();
3245 let ctx = WaveCtx {
3246 carry_seats: false,
3247 pauses: Some((&self.pause, &self.interrupt)),
3248 run: &run_id,
3249 node: "judge",
3250 prompts: &prompts,
3251 cache: cache.as_deref(),
3252 round: None,
3253 };
3254 let results = ask_json_wave::<Ranking>(
3255 jobs,
3256 Arc::clone(&self.sem),
3257 self.state.config.graph.retries,
3258 &self.roles.judge_roster,
3259 &ctx,
3260 &mut quota_losses,
3261 &mut self.state,
3262 &move |r: &Ranking| r.validate(&labels_for_check),
3263 )
3264 .await;
3265 if self.state.resource_block.is_some() {
3266 return self.stop_for_resource();
3267 }
3268 self.state.quota.extend(quota_losses);
3269
3270 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3271 let agent_id = seat.agent.clone();
3272 self.state.seats.insert(seat.key.clone(), seat);
3273 let mut record = Judgement {
3274 judge: j + 1,
3275 seat: format!("judge-{}", j + 1),
3276 agent: agent_id,
3277 ranking: Vec::new(),
3278 reasons: BTreeMap::new(),
3279 confidence: None,
3280 strengths: BTreeMap::new(),
3281 order: orders[j].clone(),
3282 failed: None,
3283 duration_ms: 0,
3284 };
3285 match res {
3286 Ok((ranking, out)) => {
3287 record.ranking = ranking.normalized();
3288 record.reasons = ranking.reasons;
3289 record.strengths = ranking.strengths;
3290 record.confidence = ranking.confidence;
3291 record.duration_ms = out.duration_ms;
3292 self.state.event(
3293 "judge",
3294 format!(
3295 "judge {} ranked {}",
3296 j + 1,
3297 record.ranking.iter().collect::<String>()
3298 ),
3299 );
3300 }
3301 Err(e) => {
3302 record.failed = Some(e.to_string());
3303 self.state
3304 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3305 }
3306 }
3307 self.state.judgements.push(record);
3308 self.state.save()?;
3309 }
3310 Ok(())
3311 }
3312
3313 async fn deliberate(&mut self) -> Result<()> {
3316 let run_id = self.state.id.clone();
3321 let prompts = self.state.config.prompts.clone();
3322 if !self.state.deliberation.is_empty() {
3323 return Ok(());
3324 }
3325 let tops: Vec<char> = self
3326 .state
3327 .judgements
3328 .iter()
3329 .filter_map(|j| j.ranking.first().copied())
3330 .collect();
3331 let rounds = self.state.config.graph.deliberate_rounds;
3332 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3333 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3334 self.state.event(
3335 "deliberate",
3336 format!("judges agreed on {} outright; no deliberation", tops[0]),
3337 );
3338 }
3339 self.state.status = RunStatus::Voting;
3340 self.state.save()?;
3341 return Ok(());
3342 }
3343
3344 self.state.status = RunStatus::Deliberating;
3345 self.state.event(
3346 "deliberate",
3347 format!(
3348 "split: first choices were {} — opening {rounds} round(s)",
3349 tops.iter().collect::<String>()
3350 ),
3351 );
3352
3353 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3354 let language = self.state.config.graph.language.clone();
3355 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3356 let sessions = self.state.config.graph.sessions;
3357 let artifacts = agent::artifacts_dir(&self.state.dir());
3358 let root = self.state.worktree_root();
3359 let base_short = short(&self.state.base_commit);
3360
3361 for round in 1..=rounds {
3365 let mut turns: Vec<DeliberationTurn> = Vec::new();
3366 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3367 if self.state.judgements[j].failed.is_some() {
3368 continue;
3369 }
3370 let seat_key = format!("judge-{}", j + 1);
3371 let spec = self.occupant(&seat_key, spec);
3372 let mut seat = self.seat(&seat_key, &spec.id);
3373 let transcript = self.transcript(&turns, j);
3374 let build = |context: Option<&str>| {
3375 prompt::deliberate(
3376 &self.state.instruction,
3377 context,
3378 &transcript,
3379 round,
3380 rounds,
3381 &language,
3382 )
3383 };
3384 let block = self.candidate_block(&viable, &base_short);
3385 let full = build(Some(&block));
3386 let text = if has_context(&spec, &seat, sessions) {
3387 build(None)
3388 } else {
3389 full.clone()
3390 };
3391 let job = SeatJob {
3392 spec,
3393 seat: seat.clone(),
3394 prompt: text,
3395 cwd: root.join(format!("judge-{}", j + 1)),
3396 timeout,
3397 allow_write: false,
3398 sessions,
3399 artifacts: artifacts.clone(),
3400 stem: format!("delib-{round}-judge-{}", j + 1),
3401 handover: Some(full),
3402 };
3403 let cache = self.state.config.cache_dir();
3404 let ctx = WaveCtx {
3405 carry_seats: false,
3406 pauses: Some((&self.pause, &self.interrupt)),
3407 run: &run_id,
3408 node: "deliberate",
3409 prompts: &prompts,
3410 cache: cache.as_deref(),
3411 round: None,
3412 };
3413 let mut losses = Vec::new();
3417 let mut results = ask_wave_with::<String>(
3418 vec![job],
3419 Arc::clone(&self.sem),
3420 0,
3421 &self.roles.judge_roster,
3422 &ctx,
3423 &mut losses,
3424 &mut self.state,
3425 &|text: &str| {
3426 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3427 },
3428 )
3429 .await;
3430 self.state.quota.extend(losses);
3431 let (updated, res, _) = results.pop().expect("one job in, one result out");
3432 seat = updated;
3433 let agent_id = seat.agent.clone();
3434 self.state.seats.insert(seat.key.clone(), seat);
3435 let body = match res {
3436 Ok((body, _)) => body,
3437 Err(e) => {
3440 self.state
3441 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3442 continue;
3443 }
3444 };
3445 let tentative = verdict::extract_json::<Position>(&body)
3446 .ok()
3447 .and_then(|p| p.tentative)
3448 .and_then(|s| s.trim().chars().next())
3449 .map(|c| c.to_ascii_uppercase());
3450 self.state.event(
3451 "deliberate",
3452 format!(
3453 "round {round}: judge {} now favours {}",
3454 j + 1,
3455 tentative.map_or("—".to_owned(), |c| c.to_string())
3456 ),
3457 );
3458 turns.push(DeliberationTurn {
3459 judge: j + 1,
3460 agent: agent_id,
3461 body: blind::sanitize_prose(&body, &self.state.config.blind),
3462 tentative,
3463 });
3464 }
3465 self.state
3466 .deliberation
3467 .push(DeliberationRound { round, turns });
3468 self.state.save()?;
3469 }
3470
3471 self.state.status = RunStatus::Voting;
3472 self.state.save()?;
3473 Ok(())
3474 }
3475
3476 async fn vote(&mut self) -> Result<()> {
3479 let run_id = self.state.id.clone();
3484 let prompts = self.state.config.prompts.clone();
3485 if !self.state.votes.is_empty() {
3486 return Ok(());
3487 }
3488 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3489 if viable.len() == 1 {
3490 return Ok(());
3491 }
3492 self.state.status = RunStatus::Voting;
3493
3494 let language = self.state.config.graph.language.clone();
3495 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3496 let sessions = self.state.config.graph.sessions;
3497 let artifacts = agent::artifacts_dir(&self.state.dir());
3498 let root = self.state.worktree_root();
3499 let base_short = short(&self.state.base_commit);
3500 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3501
3502 let mut jobs = Vec::new();
3503 let mut seats_at = Vec::new();
3504 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3505 if self
3506 .state
3507 .judgements
3508 .get(j)
3509 .is_some_and(|r| r.failed.is_some())
3510 {
3511 continue;
3512 }
3513 let seat_key = format!("judge-{}", j + 1);
3514 let spec = self.occupant(&seat_key, spec);
3515 let seat = self.seat(&seat_key, &spec.id);
3516 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3517 let text = if has_context(&spec, &seat, sessions) {
3518 prompt::final_vote(&viable, &language)
3519 } else {
3520 full.clone()
3521 };
3522 jobs.push(SeatJob {
3523 spec,
3524 seat,
3525 prompt: text,
3526 cwd: root.join(format!("judge-{}", j + 1)),
3527 timeout,
3528 allow_write: false,
3529 sessions,
3530 artifacts: artifacts.clone(),
3531 stem: format!("vote-judge-{}", j + 1),
3532 handover: Some(full),
3533 });
3534 seats_at.push(j);
3535 }
3536
3537 self.state.event(
3538 "vote",
3539 format!(
3540 "collecting {} final votes one by one, privately",
3541 jobs.len()
3542 ),
3543 );
3544 let allowed = viable.clone();
3545 let mut quota_losses = Vec::new();
3546 let cache = self.state.config.cache_dir();
3547 let ctx = WaveCtx {
3548 carry_seats: false,
3549 pauses: Some((&self.pause, &self.interrupt)),
3550 run: &run_id,
3551 node: "vote",
3552 prompts: &prompts,
3553 cache: cache.as_deref(),
3554 round: None,
3555 };
3556 let results = ask_json_wave::<FinalVote>(
3557 jobs,
3558 Arc::clone(&self.sem),
3559 self.state.config.graph.retries,
3560 &self.roles.judge_roster,
3561 &ctx,
3562 &mut quota_losses,
3563 &mut self.state,
3564 &move |v: &FinalVote| match v.label() {
3565 Some(c) if allowed.contains(&c) => Ok(()),
3566 other => bail!("vote {other:?} is not one of {allowed:?}"),
3567 },
3568 )
3569 .await;
3570 if self.state.resource_block.is_some() {
3571 return self.stop_for_resource();
3572 }
3573 self.state.quota.extend(quota_losses);
3574
3575 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3576 let agent_id = seat.agent.clone();
3577 self.state.seats.insert(seat.key.clone(), seat);
3578 let initial = self
3579 .state
3580 .judgements
3581 .get(j)
3582 .and_then(|r| r.ranking.first().copied());
3583 let mut record = VoteRecord {
3584 judge: j + 1,
3585 agent: agent_id,
3586 vote: None,
3587 reason: String::new(),
3588 changed: false,
3589 };
3590 match res {
3591 Ok((v, _)) => {
3592 record.vote = v.label();
3593 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3594 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3595 self.state.event(
3596 "vote",
3597 format!(
3598 "judge {} voted {}{}",
3599 j + 1,
3600 record.vote.unwrap_or('?'),
3601 if record.changed { " (changed)" } else { "" }
3602 ),
3603 );
3604 }
3605 Err(e) => {
3606 self.state
3607 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3608 }
3609 }
3610 self.state.votes.push(record);
3611 self.state.save()?;
3612 }
3613 Ok(())
3614 }
3615
3616 fn tally(&mut self) -> Result<()> {
3619 if self.state.tally.is_some() {
3620 return Ok(());
3621 }
3622 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3623 let tops: Vec<char> = self
3624 .state
3625 .judgements
3626 .iter()
3627 .filter_map(|j| j.ranking.first().copied())
3628 .collect();
3629 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3630
3631 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3634 let mut cast: Vec<char> = Vec::new();
3635 for (i, j) in self.state.judgements.iter().enumerate() {
3636 let vote = self
3637 .state
3638 .votes
3639 .iter()
3640 .find(|v| v.judge == i + 1)
3641 .and_then(|v| v.vote)
3642 .or_else(|| j.ranking.first().copied());
3643 if let Some(v) = vote {
3644 *first_choice.entry(v).or_insert(0) += 1;
3645 cast.push(v);
3646 }
3647 }
3648
3649 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3650 for j in &self.state.judgements {
3651 let n = j.ranking.len();
3652 for (pos, label) in j.ranking.iter().enumerate() {
3653 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3654 }
3655 }
3656
3657 let best = first_choice.values().copied().max().unwrap_or(0);
3658 let mut leaders: Vec<char> = first_choice
3659 .iter()
3660 .filter(|(_, v)| **v == best)
3661 .map(|(k, _)| *k)
3662 .collect();
3663 let mut tie_break = None;
3664 if leaders.len() > 1 {
3665 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3666 let borda_leaders: Vec<char> = leaders
3667 .iter()
3668 .copied()
3669 .filter(|l| borda[l] == top_borda)
3670 .collect();
3671 tie_break = Some(if borda_leaders.len() == 1 {
3672 format!(
3673 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3674 leaders.len()
3675 )
3676 } else {
3677 format!(
3678 "{} way tie on both first-choice votes and Borda points, broken by label order",
3679 leaders.len()
3680 )
3681 });
3682 leaders = borda_leaders;
3683 leaders.sort_unstable();
3684 }
3685 let winner = *leaders
3686 .first()
3687 .or(viable.first())
3688 .context("no candidate to declare a winner from")?;
3689
3690 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3691 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3692 let deliberated = !self.state.deliberation.is_empty();
3693
3694 let quota_seats: std::collections::BTreeSet<&str> =
3698 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3699 let mut present = 0usize;
3700 for (i, j) in self.state.judgements.iter().enumerate() {
3701 if quota_seats.contains(j.seat.as_str()) {
3702 continue;
3703 }
3704 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3705 let voted = self
3706 .state
3707 .votes
3708 .iter()
3709 .any(|v| v.judge == i + 1 && v.vote.is_some());
3710 if ranked || voted {
3711 present += 1;
3712 }
3713 }
3714 let needs_quorum = viable.len() > 1;
3720 let judges_total = if needs_quorum {
3721 self.roles.judges.len()
3722 } else {
3723 0
3724 };
3725 let quorum = if needs_quorum {
3726 judges_total / 2 + 1
3727 } else {
3728 0
3729 };
3730 let met_quorum = !needs_quorum || present >= quorum;
3731 let uncontested = (!needs_quorum).then(|| {
3732 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3733 });
3734
3735 self.state.event(
3736 "tally",
3737 match &uncontested {
3738 Some(reason) => format!("winner {winner} — {reason}"),
3739 None => format!(
3740 "winner {winner} — votes {} | initial {} | {} changed | \
3741 {present}/{judges_total} judges{}",
3742 first_choice
3743 .iter()
3744 .map(|(k, v)| format!("{k}:{v}"))
3745 .collect::<Vec<_>>()
3746 .join(" "),
3747 if unanimous_initial {
3748 "unanimous"
3749 } else {
3750 "split"
3751 },
3752 changed_votes,
3753 if met_quorum {
3754 String::new()
3755 } else {
3756 format!(" — below quorum ({quorum} required)")
3757 },
3758 ),
3759 },
3760 );
3761 if !met_quorum {
3762 self.state.event(
3763 "stall",
3764 format!(
3765 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3766 the run stops here, resumable"
3767 ),
3768 );
3769 }
3770 self.state.tally = Some(Tally {
3771 first_choice,
3772 borda,
3773 winner,
3774 rankings: tops.len(),
3775 unanimous_initial,
3776 deliberated,
3777 changed_votes,
3778 unanimous_final,
3779 tie_break,
3780 judges: judges_total,
3781 present,
3782 quorum,
3783 met_quorum,
3784 uncontested,
3785 });
3786 self.state.status = if met_quorum {
3787 RunStatus::Reviewing
3788 } else {
3789 RunStatus::Stalled
3790 };
3791 self.state.save()?;
3792 Ok(())
3793 }
3794
3795 #[allow(clippy::too_many_lines)]
3816 async fn recover_stall(&mut self) -> Result<bool> {
3817 let run_id = self.state.id.clone();
3822 let prompts = self.state.config.prompts.clone();
3823 let quota_seats: BTreeSet<&str> =
3828 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3829 let absent: Vec<String> = self
3830 .state
3831 .judgements
3832 .iter()
3833 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3834 .map(|j| j.seat.clone())
3835 .collect();
3836 if absent.is_empty() {
3837 return Ok(false);
3838 }
3839 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3840 if viable.len() <= 1 {
3841 return Ok(false);
3842 }
3843 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3844 let language = self.state.config.graph.language.clone();
3845 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3846 let sessions = self.state.config.graph.sessions;
3847 let artifacts = agent::artifacts_dir(&self.state.dir());
3848 let root = self.state.worktree_root();
3849 let base_short = short(&self.state.base_commit);
3850 let candidates: Vec<Candidate> = viable.clone();
3851
3852 let mut positions: Vec<usize> = absent
3854 .iter()
3855 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3856 .collect();
3857 if positions.is_empty() {
3858 return Ok(false);
3859 }
3860 positions.sort_unstable();
3861 positions.dedup();
3862
3863 let mut judge_jobs = Vec::new();
3865 for &j in &positions {
3866 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3867 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3868 let seat_key = format!("judge-{}", j + 1);
3869 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3870 let seat = self.seat(&seat_key, &spec.id);
3871 judge_jobs.push(SeatJob {
3872 spec,
3873 seat,
3874 prompt: prompt::judge(
3875 &self.state.instruction,
3876 &views,
3877 self.roles.judges.len(),
3878 &base_short,
3879 &language,
3880 ),
3881 cwd: root.join(seat_key),
3882 timeout,
3883 allow_write: false,
3884 sessions,
3885 artifacts: artifacts.clone(),
3886 stem: format!("judge-{}-recover", j + 1),
3887 handover: None,
3888 });
3889 }
3890
3891 let labels_for_check = labels.clone();
3892 let mut judge_losses = Vec::new();
3893 let retries = self.state.config.graph.retries;
3894 let cache = self.state.config.cache_dir();
3895 let ctx = WaveCtx {
3896 carry_seats: false,
3897 pauses: Some((&self.pause, &self.interrupt)),
3898 run: &run_id,
3899 node: "judge",
3900 prompts: &prompts,
3901 cache: cache.as_deref(),
3902 round: None,
3903 };
3904 let results = ask_json_wave::<Ranking>(
3905 judge_jobs,
3906 Arc::clone(&self.sem),
3907 retries,
3908 &self.roles.judge_roster,
3909 &ctx,
3910 &mut judge_losses,
3911 &mut self.state,
3912 &move |r: &Ranking| r.validate(&labels_for_check),
3913 )
3914 .await;
3915 if self.state.resource_block.is_some() {
3916 self.stop_for_resource()?;
3917 return Ok(false);
3918 }
3919
3920 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3922 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3923 let agent_id = seat.agent.clone();
3924 self.state.seats.insert(seat.key.clone(), seat);
3925 let record = &mut self.state.judgements[j];
3926 match res {
3927 Ok((ranking, out)) => {
3928 record.agent = agent_id;
3929 record.ranking = ranking.normalized();
3930 record.reasons = ranking.reasons;
3931 record.strengths = ranking.strengths;
3932 record.confidence = ranking.confidence;
3933 record.failed = None;
3934 record.duration_ms = out.duration_ms;
3935 recovered.insert(j);
3936 self.state.event(
3937 "recover",
3938 format!("judge {} ranked again after the limit", j + 1),
3939 );
3940 }
3941 Err(e) => {
3942 self.state
3943 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3944 }
3945 }
3946 }
3947
3948 let mut vote_jobs = Vec::new();
3950 let mut vote_pos: Vec<usize> = Vec::new();
3951 for &j in &recovered {
3952 let seat_key = format!("judge-{}", j + 1);
3953 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3954 let seat = self.seat(&seat_key, &spec.id);
3955 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3956 let text = if has_context(&spec, &seat, sessions) {
3957 prompt::final_vote(&labels, &language)
3958 } else {
3959 full.clone()
3960 };
3961 vote_jobs.push(SeatJob {
3962 spec,
3963 seat,
3964 prompt: text,
3965 cwd: root.join(seat_key),
3966 timeout,
3967 allow_write: false,
3968 sessions,
3969 artifacts: artifacts.clone(),
3970 stem: format!("vote-judge-{}-recover", j + 1),
3971 handover: Some(full),
3972 });
3973 vote_pos.push(j);
3974 }
3975 let allowed = labels.clone();
3976 let mut vote_losses = Vec::new();
3977 let vote_retries = self.state.config.graph.retries;
3978 let vote_cache = self.state.config.cache_dir();
3979 let ctx = WaveCtx {
3980 carry_seats: false,
3981 pauses: Some((&self.pause, &self.interrupt)),
3982 run: &run_id,
3983 node: "vote",
3984 prompts: &prompts,
3985 cache: vote_cache.as_deref(),
3986 round: None,
3987 };
3988 let votes = ask_json_wave::<FinalVote>(
3989 vote_jobs,
3990 Arc::clone(&self.sem),
3991 vote_retries,
3992 &self.roles.judge_roster,
3993 &ctx,
3994 &mut vote_losses,
3995 &mut self.state,
3996 &move |v: &FinalVote| match v.label() {
3997 Some(c) if allowed.contains(&c) => Ok(()),
3998 other => bail!("vote {other:?} is not one of {allowed:?}"),
3999 },
4000 )
4001 .await;
4002 if self.state.resource_block.is_some() {
4003 self.stop_for_resource()?;
4004 return Ok(false);
4005 }
4006 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
4007 let agent_id = seat.agent.clone();
4008 self.state.seats.insert(seat.key.clone(), seat);
4009 match res {
4010 Ok((v, _)) => {
4011 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
4012 rec.vote = v.label();
4013 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
4014 } else {
4015 self.state.votes.push(VoteRecord {
4016 judge: j + 1,
4017 agent: agent_id,
4018 vote: v.label(),
4019 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
4020 changed: false,
4021 });
4022 }
4023 self.state.event(
4024 "recover",
4025 format!("judge {} voted again after the limit", j + 1),
4026 );
4027 }
4028 Err(e) => {
4029 self.state
4030 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
4031 }
4032 }
4033 }
4034
4035 let recovered_keys: BTreeSet<String> = recovered
4039 .iter()
4040 .map(|&j| format!("judge-{}", j + 1))
4041 .collect();
4042 self.state
4043 .quota
4044 .retain(|q| !recovered_keys.contains(&q.seat));
4045 for loss in judge_losses.into_iter().chain(vote_losses) {
4049 if recovered_keys.contains(&loss.seat) {
4050 continue;
4051 }
4052 self.state.quota.retain(|q| q.seat != loss.seat);
4053 self.state.quota.push(loss);
4054 }
4055
4056 self.state.tally = None;
4058 self.tally()?;
4059 Ok(self
4060 .state
4061 .tally
4062 .as_ref()
4063 .map(|t| t.met_quorum)
4064 .unwrap_or(false))
4065 }
4066
4067 async fn graft(&mut self) -> Result<()> {
4084 let Err(e) = self.graft_inner().await else {
4085 return Ok(());
4086 };
4087 if e.is::<crate::repo_lock::ResourceBlocked>() {
4088 return Err(e);
4089 }
4090 if e.downcast_ref::<GraftRestoreFailed>().is_some() {
4091 self.state.status = RunStatus::Blocked;
4092 self.state.event("graft", format!("blocked: {e:#}"));
4093 self.state.save()?;
4094 return Err(e);
4095 }
4096 if let Some(rec) = self.state.graft.clone()
4101 && !rec.outcome.settled()
4102 {
4103 if let Some(winner) = self.state.winner().cloned() {
4104 if let Err(re) = self.graft_restore(&winner.worktree, &rec.pre).await {
4105 if re.is::<crate::repo_lock::ResourceBlocked>() {
4106 return Err(re);
4107 }
4108 self.state.status = RunStatus::Blocked;
4109 self.state.event("graft", format!("blocked: {re:#}"));
4110 self.state.save()?;
4111 return Err(re);
4112 }
4113 }
4114 if let Some(g) = self.state.graft.as_mut() {
4115 g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
4116 }
4117 }
4118 self.state.event("graft", format!("skipped: {e:#}"));
4119 if let Err(e) = self.state.save() {
4120 tracing::warn!("save after graft: {e:#}");
4121 }
4122 Ok(())
4123 }
4124
4125 async fn graft_restore(&mut self, wt: &Path, pre: &str) -> Result<()> {
4128 let _coordination = self.coordinate(wt).await?;
4129 crate::recovery::restore_repair(wt, pre)
4130 .await
4131 .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
4132 }
4133
4134 fn graft_skipped(&mut self, why: &str) {
4136 let message = format!("skipped: {why}");
4137 if !self
4138 .state
4139 .events
4140 .iter()
4141 .any(|e| e.node == "graft" && e.message == message)
4142 {
4143 self.state.event("graft", message);
4144 }
4145 }
4146
4147 async fn graft_inner(&mut self) -> Result<()> {
4148 if let Some(rec) = self.state.graft.clone()
4151 && !rec.outcome.settled()
4152 {
4153 if self.state.reviews.is_empty()
4157 && let Some(winner) = self.state.winner().cloned()
4158 {
4159 self.graft_restore(&winner.worktree, &rec.pre).await?;
4160 }
4161 if rec.outcome == GraftOutcome::WaitingForRestore {
4162 self.state.graft = None;
4163 self.state.save()?;
4164 } else {
4165 if let Some(g) = self.state.graft.as_mut() {
4166 g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
4167 }
4168 self.state.event(
4169 "graft",
4170 "rolled back: the previous round was interrupted; the winner is untouched",
4171 );
4172 self.state.save()?;
4173 return Ok(());
4174 }
4175 }
4176 let plan = match graft::plan(&self.state) {
4177 Ok(plan) => plan,
4178 Err(skip) => {
4179 if skip != graft::Skip::AlreadyDone {
4180 self.graft_skipped(skip.reason());
4181 self.state.save()?;
4182 }
4183 return Ok(());
4184 }
4185 };
4186 let Some(winner) = self.state.winner().cloned() else {
4187 return Ok(());
4188 };
4189 let wt = winner.worktree.clone();
4190 if !git::is_clean(&wt).await? {
4191 self.graft_skipped("the winner's worktree has uncommitted changes");
4192 self.state.save()?;
4193 return Ok(());
4194 }
4195 let pre = git::rev_parse(&wt, "HEAD").await?;
4196 let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
4197 else {
4198 self.graft_skipped("the winner's diff could not be measured");
4199 self.state.save()?;
4200 return Ok(());
4201 };
4202 let limit = graft::limit(winner_lines);
4203 self.state.graft = Some(GraftRecord {
4206 pre: pre.clone(),
4207 post: None,
4208 lines: None,
4209 limit: Some(limit),
4210 agent: None,
4211 outcome: GraftOutcome::Running,
4212 requested: graft::requested(&plan),
4213 adoption: None,
4214 adoption_error: None,
4215 });
4216 self.state.event(
4217 "graft",
4218 format!(
4219 "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4220 plan.strengths
4221 .iter()
4222 .map(|(l, _, _)| l.to_string())
4223 .collect::<Vec<_>>()
4224 .join(", "),
4225 plan.winner
4226 ),
4227 );
4228 self.state.save()?;
4229
4230 let fix_prompt = prompt::graft(
4231 &self.state.instruction,
4232 plan.winner,
4233 &plan.strengths,
4234 limit,
4235 &self.state.config.graph.language,
4236 );
4237 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4238 let sessions = self.state.config.graph.sessions;
4239 let artifacts = agent::artifacts_dir(&self.state.dir());
4240 let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4241 let (_, seat, out) = self
4242 .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4243 SeatJob {
4244 prompt: fix_prompt.clone(),
4245 spec,
4246 seat,
4247 cwd: wt.clone(),
4248 timeout,
4249 allow_write: true,
4250 sessions,
4251 artifacts: artifacts.clone(),
4252 stem: "graft".to_owned(),
4253 handover: None,
4254 }
4255 })
4256 .await;
4257 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4258 self.state.graft.as_mut().expect("graft record").outcome =
4261 GraftOutcome::WaitingForRestore;
4262 return self.stop_for_resource();
4263 }
4264 let mut why: Option<String> = None;
4265 match &out {
4266 AgentOutcome::Ok(o) if agent::output_advances(o) => {
4267 why = Some("the fixer produced nothing usable".to_owned());
4268 }
4269 AgentOutcome::Ok(_) => {}
4270 AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4271 AgentOutcome::Quota(o) => {
4272 self.state.quota.push(QuotaLoss {
4273 seat: seat.key.clone(),
4274 node: "graft".to_owned(),
4275 at: Timestamp::now(),
4276 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4277 });
4278 why = Some("rate limited (quota)".to_owned());
4279 }
4280 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4281 why = Some(format!(
4282 "the fixer failed: {}",
4283 e.lines()
4284 .next()
4285 .unwrap_or("")
4286 .chars()
4287 .take(160)
4288 .collect::<String>()
4289 ));
4290 }
4291 }
4292 let agent_id = seat.agent.clone();
4293 self.state.seats.insert(seat.key.clone(), seat);
4294
4295 let mut post = None;
4296 let mut lines = None;
4297 let _coordination = self.coordinate(&wt).await?;
4298 if why.is_none() {
4299 let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4300 if let Ok(r) = &rescued {
4301 self.state.note_withheld("graft", &r.withheld);
4302 }
4303 let head = git::rev_parse(&wt, "HEAD").await?;
4304 let dirty = match &rescued {
4308 Ok(r) => git::status_porcelain(&wt)
4309 .await
4310 .map(|st| {
4311 st.lines().any(|l| {
4312 !r.withheld
4313 .iter()
4314 .any(|w| l.trim_end().ends_with(w.path.as_str()))
4315 })
4316 })
4317 .unwrap_or(true),
4318 Err(_) => true,
4319 };
4320 if dirty {
4321 why = Some("the fixer left changes that could not be committed".to_owned());
4322 } else if head == pre {
4323 why = Some("the fixer changed nothing".to_owned());
4324 } else {
4325 lines = git::changed_lines(&wt, &pre, &head).await;
4326 if graft::within_budget(lines, limit) {
4327 post = Some(head);
4328 } else {
4329 why = Some(format!(
4330 "{} changed lines against a limit of {limit}",
4331 lines.map_or_else(
4332 || "an unmeasurable number of".to_owned(),
4333 |l| l.to_string()
4334 )
4335 ));
4336 }
4337 }
4338 }
4339 let (adoption, adoption_error) = match &out {
4340 AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4341 _ => (None, None),
4342 };
4343 let requested = graft::requested(&plan);
4344 let outcome = match why {
4345 None => {
4346 self.state.event(
4347 "graft",
4348 format!(
4349 "applied: {} changed lines (limit {limit})",
4350 lines.unwrap_or(0)
4351 ),
4352 );
4353 GraftOutcome::Applied
4354 }
4355 Some(why) => {
4356 crate::recovery::restore_repair(&wt, &pre)
4359 .await
4360 .map_err(|e| GraftRestoreFailed(format!("{e:#}")))?;
4361 self.state.event(
4362 "graft",
4363 format!("rolled back: {why}; the winner is untouched"),
4364 );
4365 GraftOutcome::RolledBack(why)
4366 }
4367 };
4368 self.state.graft = Some(GraftRecord {
4369 pre,
4370 post,
4371 lines,
4372 limit: Some(limit),
4373 agent: Some(agent_id),
4374 outcome,
4375 requested,
4376 adoption,
4377 adoption_error,
4378 });
4379 self.state.save()?;
4380 Ok(())
4381 }
4382
4383 async fn fold_losers(&mut self) -> Result<()> {
4386 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4387 return Ok(());
4388 };
4389 if !self.state.candidate_preservation.is_empty() {
4390 bail!("candidate preservation incomplete; refusing cleanup");
4391 }
4392 let repo = self.state.repo.clone();
4393 let _coordination = self.coordinate(&repo).await?;
4394 let mut folded = Vec::new();
4395 for i in 0..self.state.candidates.len() {
4396 let c = &self.state.candidates[i];
4397 if c.label == winner || c.folded {
4398 continue;
4399 }
4400 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4401 remove_preserved_worktree(&repo, &wt).await?;
4402 if git::branch_exists(&repo, &branch).await?
4403 && !git::branch_delete(&repo, &branch).await?
4404 {
4405 bail!("candidate branch removal refused");
4406 }
4407 self.state.candidates[i].folded = true;
4408 self.state.save()?;
4409 folded.push(label.to_string());
4410 }
4411 let root = self.state.worktree_root();
4413 for j in 1..=self.roles.judges.len() {
4414 let wt = root.join(format!("judge-{j}"));
4415 if wt.exists() {
4416 remove_preserved_worktree(&repo, &wt).await?;
4417 }
4418 }
4419 if self.state.config.graph.advise {
4422 for k in 1..=self.state.config.graph.advisors {
4423 let wt = root.join(format!("advisor-{k}"));
4424 if wt.exists() {
4425 remove_preserved_worktree(&repo, &wt).await?;
4426 }
4427 }
4428 }
4429 if !folded.is_empty() {
4430 self.state
4431 .event("fold", format!("folded candidates {}", folded.join(", ")));
4432 self.state.save()?;
4433 }
4434 Ok(())
4435 }
4436
4437 async fn sync_to_base(&mut self) -> Result<()> {
4477 if self.state.status == RunStatus::AlreadyInBase {
4478 return Ok(());
4479 }
4480 let conflicted = self
4481 .state
4482 .base_sync
4483 .as_ref()
4484 .is_some_and(|s| s.conflict.is_some());
4485 let Some(winner) = self.state.winner().cloned() else {
4486 return Ok(());
4487 };
4488
4489 let repo = self.state.repo.clone();
4490 let remote = self.state.config.merge.remote.clone();
4491 let base_branch = self.state.base_branch.clone();
4492 let tracking = format!("{remote}/{base_branch}");
4493
4494 git::fetch(&repo, &remote, &base_branch).await.ok();
4495 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4500 return Ok(());
4501 };
4502
4503 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4504 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4505 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4506
4507 if (behind > 0 || conflicted || head == tip)
4516 && self
4517 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4518 .await?
4519 {
4520 return Ok(());
4521 }
4522 if conflicted {
4523 return Ok(());
4524 }
4525
4526 if behind == 0 {
4527 if let Some(from) = self
4536 .state
4537 .rebase_fixes
4538 .iter()
4539 .rev()
4540 .find_map(|r| r.from.clone())
4541 && from != head
4542 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4543 .await
4544 .is_ok_and(|o| o.ok())
4545 {
4546 git::sync_to_head(&winner.worktree).await?;
4547 }
4548 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4554 if let Some(why) = &conflict {
4555 self.state.status = RunStatus::Blocked;
4556 self.state.event("land", why.clone());
4557 }
4558 self.state.base_sync = Some(BaseSync {
4559 tip,
4560 behind: 0,
4561 attempts,
4562 conflict,
4563 already_in: None,
4564 });
4565 self.state.save()?;
4566 return Ok(());
4567 }
4568
4569 if attempts >= BASE_SYNC_ROUNDS {
4570 let why = format!(
4571 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4572 rebase(s); rebasing again would only race it",
4573 winner.branch
4574 );
4575 self.state.status = RunStatus::Blocked;
4576 self.state.base_sync = Some(BaseSync {
4577 tip,
4578 behind,
4579 attempts,
4580 conflict: Some(why.clone()),
4581 already_in: None,
4582 });
4583 self.state.event("land", why);
4584 self.state.save()?;
4585 return Ok(());
4586 }
4587
4588 self.state.event(
4589 "land",
4590 format!(
4591 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4592 winner.branch
4593 ),
4594 );
4595 self.state.save()?;
4596
4597 let branch_tracking = format!("{remote}/{}", winner.branch);
4603 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4604 let remote_tip = if self.state.task_pr_branch().is_none()
4605 && matches!(&fetched_branch, Ok(o) if o.ok())
4606 {
4607 git::rev_parse(&repo, &branch_tracking).await.ok()
4608 } else {
4609 None
4610 };
4611 if let Some(theirs) = &remote_tip
4615 && !git::is_ancestor(&repo, theirs, &head).await
4616 && !crate::reconcile::origin_missing(&repo, &head, theirs)
4617 .await
4618 .is_ok_and(|missing| missing.is_empty())
4619 {
4620 let why = format!(
4621 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4622 them",
4623 short(theirs),
4624 winner.branch
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 self.state.save()?;
4636 return Ok(());
4637 }
4638
4639 let scratch = self.state.dir().join("base-sync");
4640 let rebased = match crate::rebase::rebase_with_fixer(
4641 &mut self.state,
4642 &scratch,
4643 &winner.branch,
4644 &tracking,
4645 )
4646 .await
4647 {
4648 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4649 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4650 Err(e) => Err(e),
4651 };
4652 let attempts = attempts + 1;
4653 match rebased {
4654 Ok(None) => {
4655 git::sync_to_head(&winner.worktree).await?;
4659 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4660 let mut conflict = None;
4661 if let Some(pinned) = &remote_tip {
4662 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4663 match pushed {
4664 Ok(o) if o.ok() => self.state.event(
4665 "land",
4666 format!("pushed rebased {} to {remote}", winner.branch),
4667 ),
4668 Ok(o) => {
4669 conflict = Some(format!(
4670 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
4671 winner.branch,
4672 short(pinned),
4673 o.stderr.chars().take(600).collect::<String>()
4674 ));
4675 }
4676 Err(e) => {
4677 conflict = Some(format!(
4678 "rebased {} locally but could not push it: {e:#}",
4679 winner.branch
4680 ));
4681 }
4682 }
4683 }
4684 if let Some(why) = &conflict {
4685 self.state.status = RunStatus::Blocked;
4686 self.state.event("land", why.clone());
4687 }
4688 self.state.base_sync = Some(BaseSync {
4689 tip: tip.clone(),
4690 behind: 0,
4691 attempts,
4692 conflict,
4693 already_in: None,
4694 });
4695 self.state
4696 .event("land", format!("rebased {} onto {tracking}", winner.branch));
4697 }
4698 Ok(Some(conflict)) => {
4699 let why = format!(
4700 "{} conflicts with {tracking} and did not rebase: {}",
4701 winner.branch,
4702 conflict.chars().take(600).collect::<String>()
4703 );
4704 self.state.status = RunStatus::Blocked;
4705 self.state.base_sync = Some(BaseSync {
4706 tip,
4707 behind,
4708 attempts,
4709 conflict: Some(why.clone()),
4710 already_in: None,
4711 });
4712 self.state.event("land", why);
4713 }
4714 Err(e) => {
4715 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4716 self.state.status = RunStatus::Blocked;
4717 self.state.base_sync = Some(BaseSync {
4718 tip,
4719 behind,
4720 attempts,
4721 conflict: Some(why.clone()),
4722 already_in: None,
4723 });
4724 self.state.event("land", why);
4725 }
4726 }
4727 self.state.save()?;
4728 Ok(())
4729 }
4730
4731 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4737 if self.state.task_pr_branch().is_some() {
4740 return None;
4741 }
4742 let repo = self.state.repo.clone();
4743 let remote = self.state.config.merge.remote.clone();
4744 let fetched = git::fetch(&repo, &remote, branch).await;
4745 if !matches!(&fetched, Ok(o) if o.ok()) {
4746 return None;
4747 }
4748 let branch_tracking = format!("{remote}/{branch}");
4749 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4750 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4751 return None;
4752 }
4753 if !crate::reconcile::origin_missing(&repo, head, &theirs)
4754 .await
4755 .is_ok_and(|missing| missing.is_empty())
4756 {
4757 return Some(format!(
4758 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4759 them",
4760 short(&theirs)
4761 ));
4762 }
4763 match git::push_pinned(&repo, &remote, branch, &theirs).await {
4764 Ok(o) if o.ok() => {
4765 self.state
4766 .event("land", format!("pushed rebased {branch} to {remote}"));
4767 None
4768 }
4769 Ok(o) => Some(format!(
4770 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4771 someone may have pushed): {}",
4772 short(&theirs),
4773 o.stderr.chars().take(600).collect::<String>()
4774 )),
4775 Err(e) => Some(format!(
4776 "{branch} is rebased locally but could not be pushed: {e:#}"
4777 )),
4778 }
4779 }
4780
4781 async fn settle_already_in(
4798 &mut self,
4799 branch: &str,
4800 tip: &str,
4801 head: &str,
4802 attempts: usize,
4803 behind: usize,
4804 ) -> Result<bool> {
4805 let repo = self.state.repo.clone();
4806 let remote = self.state.config.merge.remote.clone();
4807 let start = self.state.base_commit.clone();
4808 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4809 Ok(Some(e)) => e,
4810 Ok(None) => return Ok(false),
4811 Err(e) => {
4812 tracing::warn!("already-in-base check for {branch}: {e:#}");
4813 return Ok(false);
4814 }
4815 };
4816 let mut verified = vec![head.to_owned()];
4817 let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4818 let fetched = git::fetch(&repo, &remote, &public_branch).await;
4819 if matches!(&fetched, Ok(o) if o.ok())
4820 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4821 && theirs != head
4822 {
4823 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4824 Ok(Some(_)) => verified.push(theirs),
4825 _ => return Ok(false),
4826 }
4827 }
4828 let base_branch = self.state.base_branch.clone();
4829 let message = format!(
4830 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4831 land",
4832 evidence.names(),
4833 evidence.proof.as_str()
4834 );
4835 let closed =
4836 crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4837 .await;
4838 match closed {
4839 Ok(Ok(url)) => self
4840 .state
4841 .event("land", format!("closed {url}: superseded on {base_branch}")),
4842 Ok(Err(why)) => self
4843 .state
4844 .event("land", format!("did not close a pull request: {why}")),
4845 Err(e) => {
4846 let why = format!(
4847 "{branch} is already in {remote}/{base_branch}, but its pull request could \
4848 not be closed ({e:#}); resume to retry"
4849 );
4850 self.state.status = RunStatus::Blocked;
4851 self.state.base_sync = Some(BaseSync {
4852 tip: tip.to_owned(),
4853 behind,
4854 attempts,
4855 conflict: Some(why.clone()),
4856 already_in: None,
4857 });
4858 self.state.event("land", why);
4859 self.state.save()?;
4860 return Ok(true);
4861 }
4862 }
4863 self.state.status = RunStatus::AlreadyInBase;
4864 self.state.base_sync = Some(BaseSync {
4865 tip: tip.to_owned(),
4866 behind,
4867 attempts,
4868 conflict: None,
4869 already_in: Some(evidence),
4870 });
4871 self.state.event("land", message);
4872 self.state.save()?;
4873 self.settle_questions();
4874 Ok(true)
4875 }
4876
4877 fn landing_base(&self) -> String {
4887 self.state
4888 .base_sync
4889 .as_ref()
4890 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4891 }
4892
4893 pub async fn fix_selected(
4926 &mut self,
4927 ids: &[String],
4928 reason: &str,
4929 allow_stale: bool,
4930 ) -> Result<()> {
4931 let reason = reason.trim();
4932 if reason.is_empty() {
4933 bail!("a fix request needs a reason — that is the operator's own record of why");
4934 }
4935 if ids.is_empty() {
4936 bail!("no finding id given");
4937 }
4938 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4939 bail!(
4940 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4941 has already concluded — can be given a targeted fix. A run still \
4942 in progress should simply be resumed; a `merged` run's branch has \
4943 already landed, so its answer is a fresh `magi review <branch>`, \
4944 not reopening this run's own record",
4945 self.state.id,
4946 self.state.status.as_str()
4947 );
4948 }
4949 let Some(winner) = self.state.winner().cloned() else {
4950 bail!("run {} has no winning candidate to fix", self.state.id);
4951 };
4952 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4953 bail!(
4954 "branch `{}` no longer exists; this run cannot be extended",
4955 winner.branch
4956 );
4957 }
4958 let home = crate::run::home();
4959 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4960 bail!(
4961 "run {} is currently being worked on by another magi process",
4962 self.state.id
4963 );
4964 }
4965 let _claim = FixClaim::acquire(&self.state.dir())?;
4971
4972 let mut seen = BTreeSet::new();
4976 let mut findings = Vec::new();
4977 let mut missing = Vec::new();
4978 for id in ids {
4979 if !seen.insert(id.clone()) {
4980 continue;
4981 }
4982 match self.state.finding(id) {
4983 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4984 id: f.id.clone(),
4985 severity: f.severity,
4986 reviewer_vote: rec.vote,
4987 round: round.round,
4988 round_head: round.head.clone(),
4989 reviewer: rec.reviewer,
4990 agent: rec.agent.clone(),
4991 file: f.file.clone(),
4992 line: f.line,
4993 title: f.title.clone(),
4994 detail: f.detail.clone(),
4995 outcome: OperatorFixOutcome::Pending,
4996 }),
4997 None => missing.push(id.clone()),
4998 }
4999 }
5000 if !missing.is_empty() {
5001 bail!(
5002 "unknown finding id(s): {}; nothing was changed",
5003 missing.join(", ")
5004 );
5005 }
5006
5007 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
5008 let stale_details: Vec<(String, String)> = findings
5009 .iter()
5010 .filter(|f| f.round_head != head_at_request)
5011 .map(|f| (f.id.clone(), f.round_head.clone()))
5012 .collect();
5013 let stale = !stale_details.is_empty();
5014 if stale && !allow_stale {
5015 bail!(
5016 "the branch has moved since some finding(s) were raised — {} — now \
5017 at {}; pass --allow-stale to fix anyway, or re-run review first",
5018 stale_details
5019 .iter()
5020 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
5021 .collect::<Vec<_>>()
5022 .join(", "),
5023 short(&head_at_request)
5024 );
5025 }
5026
5027 let request = OperatorFixRequest {
5028 requested_at: Timestamp::now(),
5029 reason: reason.to_owned(),
5030 findings,
5031 head_at_request: head_at_request.clone(),
5032 allow_stale,
5033 stale,
5034 fix: None,
5035 result_head: None,
5036 follow_up_review_run: None,
5037 };
5038 self.state.event(
5039 "fix",
5040 format!(
5041 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
5042 request.findings.len(),
5043 request
5044 .findings
5045 .iter()
5046 .map(|f| f.id.as_str())
5047 .collect::<Vec<_>>()
5048 .join(", "),
5049 ),
5050 );
5051 self.state.operator_fixes.push(request);
5058 self.state.save()?;
5059 let request_index = self.state.operator_fixes.len() - 1;
5060
5061 let repo = self.state.repo.clone();
5070 let coordination = self.coordinate(&repo).await?;
5071 if winner.worktree.exists() {
5072 let dirty = git::git(
5075 &winner.worktree,
5076 &["status", "--porcelain", "--untracked-files=all"],
5077 )
5078 .await?;
5079 let only_withheld = dirty.lines().all(|l| {
5080 l.strip_prefix("?? ")
5081 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
5082 });
5083 if !only_withheld {
5084 bail!(
5085 "`{}` has uncommitted changes; refusing to touch it — commit or \
5086 discard them first",
5087 winner.worktree.display()
5088 );
5089 }
5090 git::worktree_remove(&self.state.repo, &winner.worktree)
5091 .await
5092 .ok();
5093 }
5094 let fix_worktree = self.state.worktree_root().join("operator-fix");
5095 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
5096 git::git(
5097 &self.state.repo,
5098 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
5099 )
5100 .await
5101 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
5102 if !git::is_clean(&fix_worktree).await? {
5103 git::worktree_remove(&self.state.repo, &fix_worktree)
5104 .await
5105 .ok();
5106 bail!(
5107 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
5108 winner.branch
5109 );
5110 }
5111 let repo_for_setup = self.state.repo.clone();
5112 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
5113 Ok(()) => {
5114 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
5115 }
5116 Err(e) => Err(e),
5117 };
5118 if let Err(e) = setup {
5119 git::worktree_remove(&self.state.repo, &fix_worktree)
5120 .await
5121 .ok();
5122 return Err(e);
5123 }
5124
5125 let run_id = self.state.id.clone();
5126 let prompts = self.state.config.prompts.clone();
5127 let language = self.state.config.graph.language.clone();
5128 let sessions = self.state.config.graph.sessions;
5129 let artifacts = agent::artifacts_dir(&self.state.dir());
5130 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
5131 .findings
5132 .iter()
5133 .map(|f| Finding {
5134 id: f.id.clone(),
5135 severity: f.severity,
5136 file: f.file.clone(),
5137 line: f.line,
5138 title: f.title.clone(),
5139 detail: f.detail.clone(),
5140 })
5141 .collect();
5142 let fix_prompt = prompt::operator_fix_in(
5143 &self.state.instruction,
5144 &finding_list,
5145 reason,
5146 &stale_details,
5147 &head_at_request,
5148 &language,
5149 &self.state.config.graph.github_language,
5150 );
5151 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5152 drop(coordination);
5153 let (job, seat, out) = self
5154 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
5155 prompt: fix_prompt.clone(),
5156 spec,
5157 seat,
5158 cwd: fix_worktree.clone(),
5159 timeout,
5160 allow_write: true,
5161 sessions,
5162 artifacts: artifacts.clone(),
5163 stem: "operator-fix".to_owned(),
5164 handover: None,
5165 })
5166 .await;
5167 let agent_id = seat.agent.clone();
5168
5169 let mut fix = FixRecord {
5170 agent: agent_id,
5171 addressed: Vec::new(),
5172 rejected: Vec::new(),
5173 notes: String::new(),
5174 committed: false,
5175 failed: None,
5176 duration_ms: 0,
5177 continuation: None,
5178 };
5179 let mut final_seat = seat.clone();
5180 match out {
5181 AgentOutcome::Ok(o) => {
5182 fix.duration_ms = o.duration_ms;
5183 let parsed = verdict::extract_json::<FixReport>(&o.text);
5184 let incomplete_reason = match &parsed {
5185 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5186 "the reply parsed, but it reported a command whose own CLI \
5187 never confirmed an exit status"
5188 .to_owned(),
5189 ),
5190 Ok(_) => None,
5191 Err(e) => Some(e.to_string()),
5192 };
5193 match incomplete_reason {
5194 None => {
5195 let report = parsed.expect("checked Ok above");
5196 fix.addressed = report.addressed;
5197 fix.rejected = report.rejected;
5198 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5199 }
5200 Some(reason) => {
5201 let (resumed_seat, resolved, failure, cont) = self
5202 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
5203 .await;
5204 fix.duration_ms += cont.cumulative_wait_ms;
5205 fix.continuation = Some(cont);
5206 final_seat = resumed_seat;
5207 match resolved {
5208 Some(report) => {
5209 fix.addressed = report.addressed;
5210 fix.rejected = report.rejected;
5211 fix.notes =
5212 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5213 }
5214 None => fix.failed = failure,
5215 }
5216 }
5217 }
5218 }
5219 AgentOutcome::Dropped(o) => {
5220 fix.duration_ms = o.duration_ms;
5221 let why = o
5222 .dropped
5223 .as_ref()
5224 .map(|d| d.why.as_str())
5225 .unwrap_or("the CLI ended the stream without delivering its answer");
5226 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5227 }
5228 AgentOutcome::Quota(o) => {
5229 self.state.quota.push(QuotaLoss {
5230 seat: final_seat.key.clone(),
5231 node: "fix".to_owned(),
5232 at: Timestamp::now(),
5233 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5234 });
5235 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5236 }
5237 AgentOutcome::Failed(e) => fix.failed = Some(e),
5238 AgentOutcome::ResourceBlocked(e) => {
5239 fix.failed = Some(format!("the fixer was not started: {e}"));
5240 }
5241 }
5242 if fix.continuation.is_none() {
5243 fix.continuation = Some(ContinuationRecord::not_needed());
5244 }
5245 self.state.seats.insert(final_seat.key.clone(), final_seat);
5246
5247 let rescue_message = format!(
5248 "magi: operator-selected fix ({}) (uncommitted work)",
5249 self.state.operator_fixes[request_index]
5250 .findings
5251 .iter()
5252 .map(|f| f.id.as_str())
5253 .collect::<Vec<_>>()
5254 .join(", ")
5255 );
5256 let repo = self.state.repo.clone();
5257 let coordination = self.coordinate(&repo).await?;
5258 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5259 self.state.note_withheld("fix", &r.withheld);
5260 }
5261 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5262 fix.committed = after != head_at_request;
5263 git::worktree_remove(&self.state.repo, &fix_worktree)
5264 .await
5265 .ok();
5266 drop(coordination);
5267
5268 self.state.event(
5269 "fix",
5270 match &fix.failed {
5271 Some(reason) => format!(
5272 "operator fix: adoption report was lost ({reason}); {}",
5273 if fix.committed {
5274 "committed"
5275 } else {
5276 "NO new commit"
5277 }
5278 ),
5279 None => format!(
5280 "operator fix: {} addressed, {} rejected, {}",
5281 fix.addressed.len(),
5282 fix.rejected.len(),
5283 if fix.committed {
5284 "committed"
5285 } else {
5286 "NO new commit"
5287 }
5288 ),
5289 },
5290 );
5291
5292 for f in &mut self.state.operator_fixes[request_index].findings {
5299 f.outcome = if fix.failed.is_some() {
5300 OperatorFixOutcome::Unreported
5301 } else if fix.addressed.contains(&f.id) {
5302 OperatorFixOutcome::Addressed
5303 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5304 OperatorFixOutcome::Rejected { why: r.why.clone() }
5305 } else {
5306 OperatorFixOutcome::Unreported
5307 };
5308 }
5309
5310 let committed = fix.committed;
5311 if committed {
5312 self.state.operator_fixes[request_index].result_head = Some(after.clone());
5313 }
5314 self.state.operator_fixes[request_index].fix = Some(fix);
5315 self.state.save()?;
5318
5319 if committed {
5320 self.state.event(
5321 "fix",
5322 format!(
5323 "operator fix committed {}; opening a follow-up review-only run",
5324 short(&after)
5325 ),
5326 );
5327 let origin =
5330 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5331 match Self::review(
5332 &self.state.repo,
5333 &winner.branch,
5334 self.state.config.clone(),
5335 origin,
5336 )
5337 .await
5338 {
5339 Ok(mut follow_up) => {
5340 follow_up.state.event(
5341 "start",
5342 format!(
5343 "requested by an operator fix on run {} for finding(s) {}",
5344 self.state.id,
5345 self.state.operator_fixes[request_index]
5346 .findings
5347 .iter()
5348 .map(|f| f.id.as_str())
5349 .collect::<Vec<_>>()
5350 .join(", "),
5351 ),
5352 );
5353 follow_up.state.save()?;
5354 let follow_up_id = follow_up.state.id.clone();
5355 let adopted = match crate::direct::adopt(
5358 &follow_up.state,
5359 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5360 ) {
5361 Ok(a) => a,
5362 Err(e) => {
5363 self.state.event(
5366 "fix",
5367 format!(
5368 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5369 ),
5370 );
5371 self.state.save()?;
5372 return Ok(());
5373 }
5374 };
5375 let executed = follow_up.execute().await;
5376 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5377 if let Some(a) = adopted {
5378 a.finish(&follow_up.state, executed);
5379 }
5380 if let Some(e) = failure {
5381 self.state.event(
5382 "fix",
5383 format!(
5384 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5385 ),
5386 );
5387 }
5388 self.state.operator_fixes[request_index].follow_up_review_run =
5389 Some(follow_up_id);
5390 }
5391 Err(e) => {
5392 self.state.event(
5393 "fix",
5394 format!("committed the fix but could not open a follow-up review: {e:#}"),
5395 );
5396 }
5397 }
5398 self.state.save()?;
5399 }
5400
5401 Ok(())
5402 }
5403
5404 async fn ask_fixer(
5424 &mut self,
5425 winner: &Candidate,
5426 node: &'static str,
5427 round: Option<usize>,
5428 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5429 ) -> (SeatJob, SeatState, AgentOutcome) {
5430 let attempts = fixer::attempts(&self.state, &self.roles, winner);
5431 self.ask_fixer_among(attempts, node, round, None, build)
5432 .await
5433 }
5434
5435 async fn ask_fixer_among(
5440 &mut self,
5441 attempts: Vec<(AgentSpec, String)>,
5442 node: &'static str,
5443 round: Option<usize>,
5444 restore: Option<(&Path, &str)>,
5445 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5446 ) -> (SeatJob, SeatState, AgentOutcome) {
5447 let run_id = self.state.id.clone();
5448 let prompts = self.state.config.prompts.clone();
5449 let cache = self.state.config.cache_dir();
5450 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5451 let mut last = None;
5452 for (i, (spec, key)) in attempts.into_iter().enumerate() {
5453 let seat = self.seat(&key, &spec.id);
5454 let mut job = build(spec.clone(), seat);
5455 if i > 0 {
5456 job.stem = format!("{}-{}", job.stem, spec.id);
5457 }
5458 let ctx = WaveCtx {
5459 carry_seats: false,
5460 pauses: Some((&self.pause, &self.interrupt)),
5461 run: &run_id,
5462 node,
5463 prompts: &prompts,
5464 cache: cache.as_deref(),
5465 round,
5466 };
5467 let (seat, out) =
5468 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5469 let advances = match &out {
5472 AgentOutcome::Ok(o) => agent::output_advances(o),
5473 AgentOutcome::ResourceBlocked(_) => false,
5474 _ => true,
5475 };
5476 if let Some(next) = ids.get(i + 1)
5477 && advances
5478 {
5479 self.state.seats.insert(seat.key.clone(), seat.clone());
5480 let class =
5481 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5482 let reason = match &out {
5483 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5484 other => fail_reason(other),
5485 };
5486 if let Some((wt, rev)) = restore
5490 && let Err(e) = self.graft_restore(wt, rev).await
5491 {
5492 let why =
5493 format!("could not restore the worktree before the next fixer: {e:#}");
5494 tracing::warn!("{why}");
5495 let out = if e.is::<crate::repo_lock::ResourceBlocked>() {
5496 AgentOutcome::ResourceBlocked(why)
5497 } else {
5498 AgentOutcome::Failed(why)
5499 };
5500 last = Some((job, seat, out));
5501 break;
5502 }
5503 record_handover(
5504 &mut self.state,
5505 node,
5506 &seat.key,
5507 &spec.id,
5508 next,
5509 &class,
5510 &reason,
5511 );
5512 continue;
5513 }
5514 last = Some((job, seat, out));
5515 break;
5516 }
5517 last.expect("the fixer chain always has an entry")
5518 }
5519
5520 async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5531 use crate::recovery::{Mode, Repair};
5532 if !self.state.reviews.is_empty() {
5533 return Ok(());
5534 }
5535 let Some(mut rec) = self.state.recovery.clone() else {
5536 return Ok(());
5537 };
5538 if rec.mode != Mode::VerifyRepair {
5539 return Ok(());
5540 }
5541 let wt = winner.worktree.clone();
5542 let mut head = git::rev_parse(&wt, "HEAD").await?;
5543 match rec.repair.clone() {
5544 Repair::Done { .. } => return Ok(()),
5545 Repair::WaitingForRestore { before } => {
5546 self.graft_restore(&wt, &before).await?;
5547 head = before;
5548 rec.repair = Repair::NotStarted;
5549 self.state.recovery = Some(rec.clone());
5550 self.state.save()?;
5551 }
5552 Repair::Started { before } => {
5553 if let Err(e) = self.graft_restore(&wt, &before).await {
5557 if e.is::<crate::repo_lock::ResourceBlocked>() {
5558 return Err(e);
5559 }
5560 return self
5561 .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5562 .await;
5563 }
5564 rec.repair = Repair::Done {
5565 before: before.clone(),
5566 after: before,
5567 committed: false,
5568 failed: Some("interrupted; rolled back".to_owned()),
5569 };
5570 self.state
5571 .event("recovery", "the repair was interrupted and rolled back");
5572 self.state.recovery = Some(rec);
5573 self.state.save()?;
5574 return Ok(());
5575 }
5576 Repair::NotStarted => {}
5577 }
5578 if crate::recovery::recheck(&mut rec, &head) {
5579 self.state.event("recovery", rec.summary());
5580 self.state.recovery = Some(rec);
5581 self.state.save()?;
5582 return Ok(());
5583 }
5584 rec.repair = Repair::Started {
5585 before: head.clone(),
5586 };
5587 self.state.recovery = Some(rec.clone());
5588 self.state.save()?;
5589
5590 let language = self.state.config.graph.language.clone();
5591 let sessions = self.state.config.graph.sessions;
5592 let artifacts = agent::artifacts_dir(&self.state.dir());
5593 let fix_prompt = prompt::recovery_fix(
5594 &self.state.instruction,
5595 &rec,
5596 self.state.config.graph.review_rounds,
5597 &language,
5598 );
5599 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5600 self.state.event(
5601 "recovery",
5602 format!(
5603 "repair: the fixer starts from the failed verification on {}",
5604 short(&head)
5605 ),
5606 );
5607 let (_job, seat, out) = self
5608 .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5609 prompt: fix_prompt.clone(),
5610 spec,
5611 seat,
5612 cwd: wt.clone(),
5613 timeout,
5614 allow_write: true,
5615 sessions,
5616 artifacts: artifacts.clone(),
5617 stem: "fix-recovery".to_owned(),
5618 handover: None,
5619 })
5620 .await;
5621 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5622 rec.repair = Repair::WaitingForRestore { before: head };
5623 self.state.recovery = Some(rec);
5624 return self.stop_for_resource();
5625 }
5626 let failed = match &out {
5627 AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5628 AgentOutcome::Quota(o) => {
5629 self.state.quota.push(QuotaLoss {
5630 seat: seat.key.clone(),
5631 node: "fix".to_owned(),
5632 at: Timestamp::now(),
5633 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5634 });
5635 Some("rate limited (quota); the fixer could not run".to_owned())
5636 }
5637 AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5638 AgentOutcome::Failed(e) => Some(e.clone()),
5639 };
5640 self.state.seats.insert(seat.key.clone(), seat);
5641 let coordination = self.coordinate(&wt).await?;
5642 let failed = match failed {
5643 Some(why) => {
5645 if let Err(e) = git::restore_to(&wt, &head).await {
5646 return self
5647 .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5648 .await;
5649 }
5650 Some(why)
5651 }
5652 None => {
5653 if let Ok(r) = git::rescue_commit(
5654 &wt,
5655 "magi: recovery repair of the failed verification (uncommitted work)",
5656 )
5657 .await
5658 {
5659 self.state.note_withheld("fix", &r.withheld);
5660 }
5661 None
5662 }
5663 };
5664 let after = git::rev_parse(&wt, "HEAD").await?;
5665 drop(coordination);
5666 self.state.event(
5667 "recovery",
5668 match &failed {
5669 Some(why) => format!("repair: nothing kept ({why})"),
5670 None if after != head => {
5671 format!("repair: committed {}", short(&after))
5672 }
5673 None => "repair: the fixer made no commit".to_owned(),
5674 },
5675 );
5676 rec.repair = Repair::Done {
5677 before: head.clone(),
5678 committed: after != head,
5679 after,
5680 failed,
5681 };
5682 self.state.recovery = Some(rec);
5683 self.state.save()?;
5684 Ok(())
5685 }
5686
5687 async fn stop_recovery(
5690 &mut self,
5691 mut rec: crate::recovery::RecoveryRecord,
5692 head: &str,
5693 why: String,
5694 ) -> Result<()> {
5695 rec.repair = crate::recovery::Repair::Started {
5697 before: head.to_owned(),
5698 };
5699 self.state.recovery = Some(rec);
5700 self.state
5701 .event("recovery", format!("repair could not be undone: {why}"));
5702 self.state.status = RunStatus::Blocked;
5703 self.state.save()?;
5704 bail!("recovery repair could not be rolled back: {why}")
5705 }
5706
5707 async fn review_loop(&mut self) -> Result<()> {
5708 if self
5713 .state
5714 .base_sync
5715 .as_ref()
5716 .is_some_and(|s| s.conflict.is_some())
5717 {
5718 return Ok(());
5719 }
5720 let run_id = self.state.id.clone();
5725 let prompts = self.state.config.prompts.clone();
5726 let Some(winner) = self.state.winner().cloned() else {
5727 return Ok(());
5728 };
5729 let max_rounds = self.state.config.graph.review_rounds;
5730 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5740 if status == RunStatus::Gating {
5743 self.record_contested_handoff();
5744 }
5745 self.state.status = status;
5746 self.state.save()?;
5747 return Ok(());
5748 }
5749 self.state.status = RunStatus::Reviewing;
5750 if self
5760 .state
5761 .reviews
5762 .last()
5763 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5764 {
5765 let shell = self.state.config.shell();
5766 return self
5767 .stop_reviewing(
5768 "the last round's own verification never resolved",
5769 &shell,
5770 &winner.worktree,
5771 )
5772 .await;
5773 }
5774
5775 self.recovery_repair(&winner).await?;
5776 if self.resource_halt()? {
5777 return Ok(());
5778 }
5779
5780 let repo = self.state.repo.clone();
5781 let root = self.state.worktree_root();
5782 let language = self.state.config.graph.language.clone();
5783 let sessions = self.state.config.graph.sessions;
5784 let artifacts = self.merge_repair_artifacts();
5785 let base = self.landing_base();
5786 let base_short = short(&base);
5787 let reviewers = self.roles.reviewers.clone();
5788 let shell = self.state.config.shell();
5789
5790 for round in (self.state.reviews.len() + 1)..=max_rounds {
5791 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5792 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5793 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5794 let prev_verification = self
5803 .state
5804 .reviews
5805 .last()
5806 .and_then(|r| r.verification_summary(&head));
5807 let prior_section = match (&self.state.recovery, round) {
5810 (Some(rec), 1) if self.state.reviews.is_empty() => {
5811 let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5812 (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5813 Some(crate::recovery::head_of(
5814 &git::diff(&winner.worktree, reviewed, "HEAD")
5815 .await
5816 .unwrap_or_default(),
5817 crate::recovery::SINCE_DIFF_MAX,
5818 ))
5819 }
5820 _ => None,
5821 };
5822 Some(prompt::recovery_review(rec, since.as_deref()))
5823 }
5824 _ => None,
5825 };
5826
5827 let mut jobs = Vec::new();
5831 let coordination = self.coordinate(&repo).await?;
5832 for (r, spec) in reviewers.iter().cloned().enumerate() {
5833 let wt = root.join(format!("review-{}", r + 1));
5834 if wt.exists() {
5837 git::reset_detached(&wt, &head).await?;
5838 } else {
5839 git::worktree_add_detached(&repo, &wt, &head).await?;
5840 }
5841 self.setup_seat_worktree(&repo, &wt, false).await?;
5842 let seat_key = format!("review-{}", r + 1);
5843 let spec = pick_start_spec(
5846 &self.roles.reviewer_roster,
5847 spec,
5848 self.state.seat_history.get(&seat_key),
5849 );
5850 let seat = self.seat(&seat_key, &spec.id);
5851 jobs.push(SeatJob {
5852 prompt: prompt::review(&prompt::ReviewCtx {
5853 instruction: &self.state.instruction,
5854 branch: &winner.branch,
5855 base_short: &base_short,
5856 stat: &stat,
5857 patch: &patch,
5858 verification: prev_verification.as_ref(),
5859 prior: prior_section.as_deref(),
5860 reviewers: reviewers.len(),
5861 round,
5862 rounds: max_rounds,
5863 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5866 lens: Lens::for_seat(r),
5867 language: &language,
5868 github_language: &self.state.config.graph.github_language,
5869 }),
5870 spec,
5871 seat,
5872 cwd: wt,
5873 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5874 allow_write: false,
5875 sessions,
5876 artifacts: artifacts.clone(),
5877 stem: format!("review-{round}-{}", r + 1),
5878 handover: None,
5879 });
5880 }
5881
5882 drop(coordination);
5883 self.state.event(
5884 "review",
5885 format!(
5886 "round {round}: {} reviewers on {}",
5887 jobs.len(),
5888 short(&head)
5889 ),
5890 );
5891 let mut quota_losses = Vec::new();
5892 let review_retries = self.state.config.graph.retries;
5893 let review_cache = self.state.config.cache_dir();
5894 let ctx = WaveCtx {
5895 carry_seats: true,
5896 pauses: Some((&self.pause, &self.interrupt)),
5897 run: &run_id,
5898 node: "review",
5899 prompts: &prompts,
5900 cache: review_cache.as_deref(),
5901 round: Some(round),
5902 };
5903 let results = ask_json_wave::<Review>(
5904 jobs,
5905 Arc::clone(&self.sem),
5906 review_retries,
5907 &self.roles.reviewer_roster,
5908 &ctx,
5909 &mut quota_losses,
5910 &mut self.state,
5911 &|_: &Review| Ok(()),
5912 )
5913 .await;
5914 if self.state.resource_block.is_some() {
5915 return self.stop_for_resource();
5916 }
5917 let round_quota_missing = quota_losses.len();
5921 self.state.quota.extend(quota_losses);
5922
5923 let mut records = Vec::new();
5924 let mut all_findings = Vec::new();
5925 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5926 let agent_id = seat.agent.clone();
5927 self.state.seats.insert(seat.key.clone(), seat);
5928 let mut record = ReviewRecord {
5929 reviewer: r + 1,
5930 agent: agent_id,
5931 summary: String::new(),
5932 findings: Vec::new(),
5933 vote: None,
5934 failed: None,
5935 duration_ms: 0,
5936 attempts,
5942 };
5943 match res {
5944 Ok((review, out)) => {
5945 record.summary =
5953 blind::sanitize_prose(&review.summary, &self.state.config.blind);
5954 record.vote = Some(review.vote);
5955 record.duration_ms = out.duration_ms;
5956 for (n, mut f) in review.findings.into_iter().enumerate() {
5957 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5960 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5961 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5962 f.file = f
5968 .file
5969 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5970 all_findings.push(f.clone());
5971 record.findings.push(f);
5972 }
5973 self.state.event(
5974 "review",
5975 format!(
5976 "round {round}: reviewer {} voted {} with {} finding(s)",
5977 r + 1,
5978 review.vote.label(),
5979 record.findings.len()
5980 ),
5981 );
5982 }
5983 Err(e) => {
5984 record.failed = Some(e.to_string());
5985 self.state.event(
5986 "review",
5987 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5988 );
5989 }
5990 }
5991 records.push(record);
5992 }
5993
5994 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
6001 let vote_split =
6002 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
6003 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
6004 if vote_split {
6005 self.state.event(
6006 "review",
6007 format!(
6008 "round {round}: votes split ({}) — one round of reconsideration",
6009 initial_votes
6010 .iter()
6011 .map(|v| v.label())
6012 .collect::<Vec<_>>()
6013 .join(", ")
6014 ),
6015 );
6016 let panel: Vec<ReviewSeatReport<'_>> = records
6019 .iter()
6020 .filter_map(|r| {
6021 r.vote.map(|vote| ReviewSeatReport {
6022 reviewer: r.reviewer,
6023 vote,
6024 summary: &r.summary,
6025 findings: &r.findings,
6026 })
6027 })
6028 .collect();
6029
6030 let mut jobs = Vec::new();
6031 let mut seats_at = Vec::new();
6032 for (r, spec) in reviewers.iter().cloned().enumerate() {
6033 if records[r].vote.is_none() {
6037 continue;
6038 }
6039 let wt = root.join(format!("review-{}", r + 1));
6040 let seat_key = format!("review-{}", r + 1);
6041 let spec = self.occupant(&seat_key, spec);
6042 let seat = self.seat(&seat_key, &spec.id);
6043 let build = |with_patch: bool| {
6051 prompt::review_reconsider(&ReviewReconsiderCtx {
6052 instruction: &self.state.instruction,
6053 reviewer: r + 1,
6054 lens: Lens::for_seat(r),
6055 panel: &panel,
6056 patch: with_patch.then_some(ReviewPatch {
6057 branch: &winner.branch,
6058 base_short: &base_short,
6059 stat: &stat,
6060 patch: &patch,
6061 }),
6062 round,
6063 rounds: max_rounds,
6064 language: &language,
6065 })
6066 };
6067 let full = build(true);
6068 let prompt = if has_context(&spec, &seat, sessions) {
6069 build(false)
6070 } else {
6071 full.clone()
6072 };
6073 jobs.push(SeatJob {
6074 prompt,
6075 spec,
6076 seat,
6077 cwd: wt,
6078 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
6079 allow_write: false,
6080 sessions,
6081 artifacts: artifacts.clone(),
6082 stem: format!("review-{round}-reconsider-{}", r + 1),
6083 handover: Some(full),
6084 });
6085 seats_at.push(r);
6086 }
6087
6088 let mut recon_quota_losses = Vec::new();
6089 let recon_cache = self.state.config.cache_dir();
6090 let recon_ctx = WaveCtx {
6091 carry_seats: true,
6092 pauses: Some((&self.pause, &self.interrupt)),
6093 run: &run_id,
6094 node: "review",
6095 prompts: &prompts,
6096 cache: recon_cache.as_deref(),
6097 round: Some(round),
6098 };
6099 let recon_results = ask_json_wave::<ReviewRevote>(
6100 jobs,
6101 Arc::clone(&self.sem),
6102 review_retries,
6103 &self.roles.reviewer_roster,
6104 &recon_ctx,
6105 &mut recon_quota_losses,
6106 &mut self.state,
6107 &|_: &ReviewRevote| Ok(()),
6108 )
6109 .await;
6110 if self.state.resource_block.is_some() {
6111 return self.stop_for_resource();
6112 }
6113 self.state.quota.extend(recon_quota_losses);
6114
6115 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
6116 let agent_id = seat.agent.clone();
6117 self.state.seats.insert(seat.key.clone(), seat);
6118 let mut rec = ReviewRevoteRecord {
6119 reviewer: r + 1,
6120 agent: agent_id,
6121 vote: None,
6122 reason: String::new(),
6123 failed: None,
6124 };
6125 match res {
6126 Ok((rv, _)) => {
6127 rec.vote = Some(rv.vote);
6128 rec.reason =
6129 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
6130 self.state.event(
6131 "review",
6132 format!(
6133 "round {round}: reviewer {} revoted {}",
6134 r + 1,
6135 rv.vote.label()
6136 ),
6137 );
6138 }
6139 Err(e) => {
6140 rec.failed = Some(e.to_string());
6141 self.state.event(
6142 "review",
6143 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
6144 );
6145 }
6146 }
6147 reconsideration.push(rec);
6148 }
6149 } else if initial_votes.len() > 1 {
6150 self.state.event(
6151 "review",
6152 format!(
6153 "round {round}: votes agreed ({}) — no reconsideration",
6154 initial_votes[0].label()
6155 ),
6156 );
6157 }
6158
6159 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
6160 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6161 let defer_e2e =
6172 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
6173 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
6174 let reason =
6175 format!("{blocking} blocking finding(s) already required a fix this round");
6176 self.state.event(
6177 "verify",
6178 format!(
6179 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
6180 {}); it will run once a round has none left",
6181 short(&head)
6182 ),
6183 );
6184 (Vec::new(), false, true, Some(reason))
6185 } else {
6186 let e2e_commands = self.state.config.verify.e2e.clone();
6187 let cache_dir = self.state.config.cache_dir();
6188 let context = format!("round {round}");
6189 let wait = self.repository_wait();
6190 let (e2e, verify_retried) = with_cache_lease(
6191 &mut self.state,
6192 cache_dir.as_deref(),
6193 "e2e",
6194 "e2e",
6195 &winner.worktree,
6196 &head,
6197 verify_timeout,
6198 &context,
6199 &wait,
6200 |state, budget| {
6201 let shell = shell.clone();
6202 let e2e_commands = e2e_commands.clone();
6203 let worktree = winner.worktree.clone();
6204 let context = context.clone();
6205 async move {
6206 run_e2e_with_retry(
6207 state,
6208 &shell,
6209 &e2e_commands,
6210 &worktree,
6211 budget,
6212 &context,
6213 )
6214 .await
6215 }
6216 },
6217 )
6218 .await?;
6219 (e2e, verify_retried, false, None)
6220 };
6221
6222 let expected = records.len();
6223 let answered = records.iter().filter(|r| r.failed.is_none()).count();
6224 let incomplete = answered < expected;
6225 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
6226 let policy = self.state.config.graph.incomplete_review;
6227 let clean = round_is_clean(
6228 blocking,
6229 e2e_ok,
6230 answered,
6231 expected,
6232 round_quota_missing,
6233 policy,
6234 );
6235
6236 let mut round_record = ReviewRound {
6237 round,
6238 head: head.clone(),
6239 verified_head: None,
6240 verified_at: None,
6241 reviews: records,
6242 e2e,
6243 verify_retried,
6244 e2e_deferred,
6245 e2e_defer_reason,
6246 fix: None,
6247 blocking,
6248 answered,
6249 expected,
6250 clean,
6251 progressed: false,
6252 vote_split,
6253 reconsideration,
6254 verdict: None,
6255 };
6256 round_record.verdict = ReviewVote::worst(
6260 round_record
6261 .final_votes()
6262 .into_iter()
6263 .map(|(_, _, vote)| vote),
6264 );
6265 if !matches!(
6276 round_record.e2e_status(),
6277 E2eStatus::Deferred | E2eStatus::NotConfigured
6278 ) {
6279 round_record.verified_head = Some(head.clone());
6280 round_record.verified_at = Some(Timestamp::now());
6281 }
6282 let this_round_verification = round_record.verification_summary(&head);
6283
6284 if incomplete {
6285 let missing: Vec<String> = round_record
6286 .reviews
6287 .iter()
6288 .filter(|r| r.failed.is_some())
6289 .map(|r| format!("review-{}", r.reviewer))
6290 .collect();
6291 self.state.event(
6292 "review",
6293 format!(
6294 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6295 missing.join(", ")
6296 ),
6297 );
6298 }
6299
6300 if clean {
6301 self.state.event(
6302 "review",
6303 if incomplete && policy == IncompleteReviewPolicy::Warn {
6304 format!(
6305 "round {round}: clean (warn policy, incomplete panel) — no \
6306 blocking findings from the seats that answered, verification green"
6307 )
6308 } else if incomplete {
6309 format!(
6310 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6311 quorum) — no blocking findings from the seats that answered, \
6312 verification green",
6313 expected - answered
6314 )
6315 } else {
6316 format!("round {round}: clean — no blocking findings, verification green")
6317 },
6318 );
6319 self.state.reviews.push(round_record);
6320 self.state.status = RunStatus::Gating;
6321 self.state.save()?;
6322 return Ok(());
6323 }
6324
6325 if incomplete && blocking == 0 && e2e_ok {
6333 self.state.reviews.push(round_record);
6334 self.state.save()?;
6335 if round == max_rounds {
6336 self.state.status = RunStatus::Blocked;
6337 self.state.event(
6338 "review",
6339 format!(
6340 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6341 refusing to call it clean",
6342 expected - answered
6343 ),
6344 );
6345 return Ok(());
6346 }
6347 continue;
6348 }
6349
6350 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6362 self.state.reviews.push(round_record);
6363 return self
6364 .stop_reviewing(
6365 "the round's own verification could not run",
6366 &shell,
6367 &winner.worktree,
6368 )
6369 .await;
6370 }
6371
6372 if round == max_rounds {
6373 self.state.reviews.push(round_record);
6374 return self
6375 .stop_reviewing(
6376 &format!(
6377 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6378 ),
6379 &shell,
6380 &winner.worktree,
6381 )
6382 .await;
6383 }
6384
6385 let blocking_findings: Vec<_> = all_findings
6388 .iter()
6389 .filter(|f| f.severity.blocks())
6390 .cloned()
6391 .collect();
6392 let fix_prompt = prompt::fix_in(
6393 &self.state.instruction,
6394 &blocking_findings,
6395 this_round_verification.as_ref(),
6396 round,
6397 max_rounds,
6398 &language,
6399 &self.state.config.graph.github_language,
6400 );
6401 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6402 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6403 let (job, seat, out) = self
6404 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6405 prompt: fix_prompt.clone(),
6406 spec,
6407 seat,
6408 cwd: winner.worktree.clone(),
6409 timeout,
6410 allow_write: true,
6411 sessions,
6412 artifacts: artifacts.clone(),
6413 stem: format!("fix-{round}"),
6414 handover: None,
6415 })
6416 .await;
6417 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6420 return self.stop_for_resource();
6421 }
6422 let agent_id = seat.agent.clone();
6423
6424 let mut fix = FixRecord {
6425 agent: agent_id,
6426 addressed: Vec::new(),
6427 rejected: Vec::new(),
6428 notes: String::new(),
6429 committed: false,
6430 failed: None,
6431 duration_ms: 0,
6432 continuation: None,
6433 };
6434 let mut continuation = ContinuationRecord::not_needed();
6435 let mut final_seat = seat.clone();
6436 match out {
6437 AgentOutcome::Ok(o) => {
6438 fix.duration_ms = o.duration_ms;
6439 let parsed = verdict::extract_json::<FixReport>(&o.text);
6440 let incomplete_reason = match &parsed {
6447 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6448 "the reply parsed, but it reported a command whose own CLI never \
6449 confirmed an exit status"
6450 .to_owned(),
6451 ),
6452 Ok(_) => None,
6453 Err(e) => Some(e.to_string()),
6454 };
6455 match incomplete_reason {
6456 None => {
6457 let report = parsed.expect("checked Ok above");
6458 fix.addressed = report.addressed;
6459 fix.rejected = report.rejected;
6460 fix.notes =
6461 blind::sanitize_prose(&report.notes, &self.state.config.blind);
6462 }
6463 Some(reason) => {
6464 let (resumed_seat, resolved, failure, cont) = self
6465 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6466 .await;
6467 fix.duration_ms += cont.cumulative_wait_ms;
6468 continuation = cont;
6469 final_seat = resumed_seat;
6470 match resolved {
6471 Some(report) => {
6472 fix.addressed = report.addressed;
6473 fix.rejected = report.rejected;
6474 fix.notes = blind::sanitize_prose(
6475 &report.notes,
6476 &self.state.config.blind,
6477 );
6478 }
6479 None => fix.failed = failure,
6480 }
6481 }
6482 }
6483 }
6484 AgentOutcome::Dropped(o) => {
6486 fix.duration_ms = o.duration_ms;
6487 let why = o
6488 .dropped
6489 .as_ref()
6490 .map(|d| d.why.as_str())
6491 .unwrap_or("the CLI ended the stream without delivering its answer");
6492 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6493 }
6494 AgentOutcome::Quota(o) => {
6495 self.state.quota.push(QuotaLoss {
6496 seat: final_seat.key.clone(),
6497 node: "fix".to_owned(),
6498 at: Timestamp::now(),
6499 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6500 });
6501 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6502 }
6503 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6504 }
6505 fix.continuation = Some(continuation);
6506 self.state.seats.insert(final_seat.key.clone(), final_seat);
6507 let coordination = self.coordinate(&winner.worktree).await?;
6508 if let Ok(r) = git::rescue_commit(
6509 &winner.worktree,
6510 &format!("magi: review round {round} fixes (uncommitted work)"),
6511 )
6512 .await
6513 {
6514 self.state.note_withheld("fix", &r.withheld);
6515 }
6516 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6517 fix.committed = after != before;
6518 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6526 drop(coordination);
6527 let progressed = diff_after != patch;
6528 let commit_note = if fix.committed {
6529 "committed"
6530 } else {
6531 "NO new commit"
6532 };
6533 let tree_note = if progressed {
6534 "changed vs base"
6535 } else {
6536 "unchanged vs base"
6537 };
6538 self.state.event(
6539 "fix",
6540 match &fix.failed {
6541 Some(reason) => {
6547 format!(
6548 "round {round}: fixer's adoption report was lost ({reason}); \
6549 {commit_note}, tree {tree_note}"
6550 )
6551 }
6552 None => format!(
6553 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6554 {tree_note}{}",
6555 fix.addressed.len(),
6556 fix.rejected.len(),
6557 if continuation.outcome == ContinuationOutcome::Resumed {
6558 format!(
6559 " (adoption report recovered after {} continuation(s))",
6560 continuation.attempts
6561 )
6562 } else {
6563 String::new()
6564 },
6565 ),
6566 },
6567 );
6568 round_record.fix = Some(fix);
6569 round_record.progressed = progressed;
6570 self.state.reviews.push(round_record);
6571 self.state.save()?;
6572
6573 if matches!(
6586 continuation.outcome,
6587 ContinuationOutcome::Exhausted
6588 | ContinuationOutcome::QuotaLost
6589 | ContinuationOutcome::NoSession
6590 ) {
6591 return self
6592 .stop_reviewing(
6593 "the fixer's adoption report never came back, even after resuming its \
6594 own seat; refusing to start another round against the same worktree \
6595 while that is unresolved",
6596 &shell,
6597 &winner.worktree,
6598 )
6599 .await;
6600 }
6601
6602 let streak = self
6603 .state
6604 .reviews
6605 .iter()
6606 .rev()
6607 .take_while(|r| !r.progressed)
6608 .count();
6609 if streak >= STAGNANT_LIMIT {
6610 return self
6611 .stop_reviewing(
6612 &format!(
6613 "the tree has not moved against base for {streak} round(s) in a row"
6614 ),
6615 &shell,
6616 &winner.worktree,
6617 )
6618 .await;
6619 }
6620 }
6621 Ok(())
6622 }
6623
6624 fn record_contested_handoff(&mut self) {
6658 if self.state.contested_handoff.is_some() {
6659 return;
6660 }
6661 let Some(contested) = self
6662 .state
6663 .reviews
6664 .last()
6665 .and_then(ReviewRound::contested_handoff)
6666 else {
6667 return;
6668 };
6669 self.state.event(
6670 "review",
6671 format!(
6672 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6673 wait for the owner's approval",
6674 contested.findings.len(),
6675 contested.rejecters.len()
6676 ),
6677 );
6678 self.state.contested_handoff = Some(contested);
6679 }
6680
6681 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6682 let round_idx = self.state.reviews.len() - 1;
6683 let needs_catchup_run = matches!(
6691 self.state.reviews[round_idx].e2e_status(),
6692 E2eStatus::Deferred | E2eStatus::ResourceBlocked
6693 );
6694 if needs_catchup_run {
6695 let round = self.state.reviews[round_idx].round;
6696 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6697 let commands = self.state.config.verify.e2e.clone();
6698 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6699 let cache_dir = self.state.config.cache_dir();
6700 let context = format!(
6701 "round {round}: verification unresolved, catching up before the final decision"
6702 );
6703 let wait = self.repository_wait();
6704 let (outcomes, verify_retried) = with_cache_lease(
6705 &mut self.state,
6706 cache_dir.as_deref(),
6707 "e2e",
6708 "e2e",
6709 worktree,
6710 &attempted_head,
6711 timeout,
6712 &context,
6713 &wait,
6714 |state, budget| {
6715 let shell = shell.to_vec();
6716 let commands = commands.clone();
6717 let context = context.clone();
6718 async move {
6719 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6720 .await
6721 }
6722 },
6723 )
6724 .await?;
6725 let last = &mut self.state.reviews[round_idx];
6726 last.e2e = outcomes;
6727 last.verify_retried = verify_retried;
6728 last.verified_head = Some(attempted_head);
6735 last.verified_at = Some(Timestamp::now());
6736 if verify_inconclusive(&last.e2e) {
6737 self.state.save()?;
6744 return Ok(());
6745 }
6746 last.e2e_deferred = false;
6747 }
6748 let last = &self.state.reviews[round_idx];
6749 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6750
6751 match last.e2e_status() {
6752 E2eStatus::Failed => {
6753 let red: Vec<String> = last
6754 .e2e
6755 .iter()
6756 .filter(|o| !o.ok())
6757 .map(|o| {
6758 format!(
6759 "`{}` -> {:?}\n{}",
6760 o.command,
6761 o.code,
6762 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6763 )
6764 })
6765 .collect();
6766 self.state
6767 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6768 self.state.status = RunStatus::Blocked;
6769 }
6770 E2eStatus::ResourceBlocked => {
6775 self.state.event(
6776 "review",
6777 format!(
6778 "{why}; e2e could not run (shared build cache unavailable); not \
6779 deciding yet"
6780 ),
6781 );
6782 }
6783 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6784 self.state.event(
6785 "review",
6786 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6787 );
6788 self.record_contested_handoff();
6789 self.state.status = RunStatus::Gating;
6790 }
6791 }
6792 self.state.save()?;
6793 Ok(())
6794 }
6795
6796 async fn gate(&mut self) -> Result<()> {
6799 if self.state.status == RunStatus::Failed
6811 || self
6812 .state
6813 .base_sync
6814 .as_ref()
6815 .is_some_and(|s| s.conflict.is_some())
6816 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6817 != Some(RunStatus::Gating)
6818 {
6819 return Ok(());
6820 }
6821 if self.state.gate_ran {
6822 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6833 self.state.status = RunStatus::Blocked;
6834 self.state.save()?;
6835 }
6836 return Ok(());
6837 }
6838 let Some(winner) = self.state.winner().cloned() else {
6839 return Ok(());
6840 };
6841 self.state.status = RunStatus::Gating;
6842 let mut outcomes = self.run_gate(&winner).await?;
6843 loop {
6844 if verify_inconclusive(&outcomes) {
6855 self.state.save()?;
6856 return Ok(());
6857 }
6858 if outcomes.iter().all(CommandOutcome::ok) {
6859 break;
6860 }
6861 match self.gate_fix_round(&winner, &outcomes).await? {
6862 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6863 GateFix::Stop => break,
6864 GateFix::Defer => {
6865 self.state.save()?;
6866 return Ok(());
6867 }
6868 }
6869 }
6870 let passed = outcomes.iter().all(CommandOutcome::ok);
6871 self.state.gate = outcomes;
6872 self.state.gate_ran = true;
6873 self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6874 if !passed {
6875 self.state.status = RunStatus::Blocked;
6876 let spent = self.state.gate_fixes.len();
6877 self.state.event(
6878 "gate",
6879 if spent == 0 {
6880 "gate failed; not merging".to_owned()
6881 } else {
6882 format!("gate failed after {spent} gate-fix round(s); not merging")
6883 },
6884 );
6885 }
6886 self.state.save()?;
6887 Ok(())
6888 }
6889
6890 async fn run_pre_gate(state: &mut RunState, winner: &Candidate) {
6900 let commands = state.config.verify.pre_gate.clone();
6901 if commands.is_empty() {
6902 return;
6903 }
6904 let shell = state.config.shell();
6905 let timeout = Duration::from_secs(state.config.graph.verify_timeout());
6906 let (outcomes, _) = run_commands(
6907 state,
6908 "pre_gate",
6909 "pre_gate",
6910 0,
6911 &shell,
6912 &commands,
6913 &winner.worktree,
6914 timeout,
6915 )
6916 .await;
6917 for o in &outcomes {
6918 if !o.ok() {
6919 tracing::warn!(
6920 "pre_gate `{}` failed ({:?}); the gate decides",
6921 o.command,
6922 o.code
6923 );
6924 }
6925 state.event(
6926 "pre_gate",
6927 format!(
6928 "`{}` -> {}",
6929 o.command,
6930 if o.ok() {
6931 "pass".to_owned()
6932 } else {
6933 format!(
6934 "FAIL ({:?})\n{}",
6935 o.code,
6936 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6937 )
6938 }
6939 ),
6940 );
6941 }
6942 state.pre_gate = outcomes;
6943 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6944 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6945 Ok(head) => {
6946 state.event("pre_gate", format!("committed mechanical fixes ({head})"));
6947 state.pre_gate_commit = Some(head);
6948 }
6949 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6950 },
6951 Ok(false) => {}
6952 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6953 }
6954 if let Err(e) = state.save() {
6955 tracing::warn!("could not persist the pre_gate record: {e:#}");
6956 }
6957 }
6958
6959 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6962 let shell = self.state.config.shell();
6963 let gate_commands = self.state.config.verify.gate.clone();
6964 let has_pre_gate = !self.state.config.verify.pre_gate.is_empty();
6965 let outcomes = if gate_commands.is_empty() && !has_pre_gate {
6966 Vec::new()
6967 } else {
6968 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6969 let cache_dir = self.state.config.cache_dir();
6970 let wait = self.repository_wait();
6971 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6972 let (outcomes, _) = with_cache_lease(
6973 &mut self.state,
6974 cache_dir.as_deref(),
6975 "gate",
6976 "gate",
6977 &winner.worktree,
6978 &head,
6979 timeout,
6980 "final gate",
6981 &wait,
6982 |state, budget| {
6983 let shell = shell.clone();
6984 let gate_commands = gate_commands.clone();
6985 let winner = winner.clone();
6986 async move {
6987 Self::run_pre_gate(state, &winner).await;
6988 let (outcomes, timed_out_pids) = run_commands(
6989 state,
6990 "gate",
6991 "gate",
6992 0,
6993 &shell,
6994 &gate_commands,
6995 &winner.worktree,
6996 budget,
6997 )
6998 .await;
6999 (outcomes, false, timed_out_pids)
7000 }
7001 },
7002 )
7003 .await?;
7004 outcomes
7005 };
7006 if outcomes.is_empty() {
7007 self.state.event(
7012 "gate",
7013 "no gate commands configured; nothing to check, passing",
7014 );
7015 }
7016 for o in &outcomes {
7017 self.state.event(
7018 "gate",
7019 format!(
7020 "`{}` -> {}",
7021 o.command,
7022 if o.ok() {
7023 "pass".to_owned()
7024 } else {
7025 format!(
7026 "FAIL ({:?})\n{}",
7027 o.code,
7028 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
7029 )
7030 }
7031 ),
7032 );
7033 }
7034 Ok(outcomes)
7035 }
7036
7037 async fn gate_fix_round(
7050 &mut self,
7051 winner: &Candidate,
7052 outcomes: &[CommandOutcome],
7053 ) -> Result<GateFix> {
7054 let cap = self.state.config.graph.gate_fix_rounds;
7055 let spent = self.state.gate_fixes.len();
7056 if spent >= cap {
7057 if cap > 0 {
7058 self.state.event(
7059 "gate",
7060 format!("{spent} gate-fix round(s) spent and the gate still fails"),
7061 );
7062 }
7063 return Ok(GateFix::Stop);
7064 }
7065 if !gate_fixable(outcomes) {
7066 self.state.event(
7067 "gate",
7068 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
7069 command or similar); not spending a fix round on it",
7070 );
7071 return Ok(GateFix::Stop);
7072 }
7073 let min_free = self.state.config.disk.min_free_bytes;
7074 if min_free > 0 {
7075 match crate::disk::free_bytes(&winner.worktree) {
7076 Ok(free) if crate::disk::enough_space(free, min_free) => {}
7077 Ok(free) => {
7078 self.state.event(
7079 "gate",
7080 format!(
7081 "only {free} bytes free ({min_free} required by `[disk] \
7082 min_free_bytes`); not spending a fix round on a failure the disk \
7083 may explain"
7084 ),
7085 );
7086 return Ok(GateFix::Stop);
7087 }
7088 Err(e) => {
7089 self.state.event(
7090 "gate",
7091 format!("free disk space could not be measured ({e:#}); no fix round"),
7092 );
7093 return Ok(GateFix::Stop);
7094 }
7095 }
7096 }
7097
7098 let attempt = spent + 1;
7099 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
7100 let base = self.landing_base();
7101 let fix_prompt = prompt::gate_fix_in(
7102 &self.state.instruction,
7103 &failed,
7104 attempt,
7105 cap,
7106 &self.state.config.graph.language,
7107 &self.state.config.graph.github_language,
7108 );
7109 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
7110 let sessions = self.state.config.graph.sessions;
7111 let artifacts = self.merge_repair_artifacts();
7112 self.state.event(
7113 "gate",
7114 format!("gate failed; gate-fix round {attempt} of {cap}"),
7115 );
7116 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
7117 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
7118 let (_, seat, out) = self
7119 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
7120 prompt: fix_prompt.clone(),
7121 spec,
7122 seat,
7123 cwd: winner.worktree.clone(),
7124 timeout,
7125 allow_write: true,
7126 sessions,
7127 artifacts: artifacts.clone(),
7128 stem: format!("gate-fix-{attempt}"),
7129 handover: None,
7130 })
7131 .await;
7132 if matches!(out, AgentOutcome::ResourceBlocked(_)) {
7135 return Ok(GateFix::Defer);
7136 }
7137 let mut record = GateFixRecord {
7138 agent: seat.agent.clone(),
7139 failed,
7140 notes: String::new(),
7141 committed: false,
7142 error: None,
7143 };
7144 match out {
7145 AgentOutcome::Ok(o) => {
7146 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
7149 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
7150 }
7151 }
7152 AgentOutcome::Dropped(_) => {
7153 record.error = Some("the CLI dropped the stream".to_owned());
7154 }
7155 AgentOutcome::Quota(o) => {
7156 self.state.quota.push(QuotaLoss {
7157 seat: seat.key.clone(),
7158 node: "gate-fix".to_owned(),
7159 at: Timestamp::now(),
7160 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
7161 });
7162 record.error = Some("rate limited (quota); fixer could not run".to_owned());
7163 }
7164 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
7165 }
7166 self.state.seats.insert(seat.key.clone(), seat);
7167 let coordination = self.coordinate(&winner.worktree).await?;
7168 if let Ok(r) = git::rescue_commit(
7169 &winner.worktree,
7170 &format!("magi: gate fix {attempt} (uncommitted work)"),
7171 )
7172 .await
7173 {
7174 self.state.note_withheld("gate-fix", &r.withheld);
7175 }
7176 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
7177 record.committed = after != before;
7178 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
7179 let note = record.error.clone();
7180 self.state.gate_fixes.push(record);
7181 self.state.save()?;
7182 if !changed {
7183 self.state.event(
7184 "gate",
7185 match note {
7186 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
7187 None => format!("gate-fix round {attempt}: the tree did not change"),
7188 },
7189 );
7190 return Ok(GateFix::Stop);
7191 }
7192 self.state.event(
7193 "gate",
7194 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
7195 );
7196
7197 drop(coordination);
7198 let commands = self.state.config.verify.e2e.clone();
7199 if !commands.is_empty() {
7200 let shell = self.state.config.shell();
7201 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
7202 let cache_dir = self.state.config.cache_dir();
7203 let context = format!("gate-fix round {attempt}");
7204 let wait = self.repository_wait();
7205 let (e2e, _) = with_cache_lease(
7206 &mut self.state,
7207 cache_dir.as_deref(),
7208 "e2e",
7209 "e2e",
7210 &winner.worktree,
7211 &after,
7212 timeout,
7213 &context,
7214 &wait,
7215 |state, budget| {
7216 let shell = shell.clone();
7217 let commands = commands.clone();
7218 let context = context.clone();
7219 let worktree = winner.worktree.clone();
7220 async move {
7221 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
7222 .await
7223 }
7224 },
7225 )
7226 .await?;
7227 if verify_inconclusive(&e2e) {
7228 return Ok(GateFix::Defer);
7229 }
7230 if e2e.iter().any(|o| !o.ok()) {
7231 self.state.event(
7232 "gate",
7233 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7234 );
7235 return Ok(GateFix::Stop);
7236 }
7237 }
7238 Ok(GateFix::Retry)
7239 }
7240
7241 async fn guarded_pr_message(
7248 &mut self,
7249 winner: &Candidate,
7250 facts: Option<&BranchFacts>,
7251 posting: bool,
7252 ) -> Result<Option<PrMessage>> {
7253 let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7254 let head = match final_head.as_deref() {
7255 Some(h) => crate::pr_outcome::FinalHead::At(h),
7256 None => crate::pr_outcome::FinalHead::Unknown,
7257 };
7258 let summarized = if posting {
7260 Some(self.summarize_change(winner, final_head.as_deref()).await)
7261 } else {
7262 None
7263 };
7264 if self.state.resource_block.is_some() {
7269 self.state.event(
7270 "pr-summary",
7271 "a shared resource was busy; nothing was published, resume to summarize again",
7272 );
7273 return Ok(None);
7274 }
7275 if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7276 anyhow::bail!(
7277 "the final commit moved during public description generation; retry before publishing"
7278 );
7279 }
7280 let note = match (&summarized, &self.state.change_summary) {
7281 (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7282 (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7283 (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7284 (None, _) => ChangeNote::NotAsked,
7285 };
7286 let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7287 if !posting {
7288 let identity = crate::scrub::Identity::current();
7289 return Ok(Some(PrMessage {
7290 title: crate::scrub::scrub(&pr.title, &identity),
7291 body: crate::scrub::scrub(&pr.body, &identity),
7292 }));
7293 }
7294 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7297 if let Some(known) = self
7298 .state
7299 .github_text
7300 .clone()
7301 .filter(|g| g.fingerprint == raw_fp)
7302 {
7303 return self.settle_withheld(winner, &pr, known).await;
7304 }
7305 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7306 let mut violations =
7307 crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7308 let identity = crate::scrub::Identity::current();
7309 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7310 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7311 && !violations.contains(&crate::github_text::Violation::SensitiveData)
7312 {
7313 violations.push(crate::github_text::Violation::SensitiveData);
7314 }
7315 let has_language = violations
7319 .iter()
7320 .any(|v| *v != crate::github_text::Violation::SensitiveData);
7321 if self.state.config.graph.github_text_guard && !violations.is_empty() {
7322 self.state.event(
7323 "github-text",
7324 if has_language {
7325 format!("description rejected: {violations:?}; requesting one rewrite")
7326 } else {
7327 format!("description rejected: {violations:?}; asking the owner")
7328 },
7329 );
7330 let mut rewritten = false;
7331 let mut rejected_rewrite = None;
7332 if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7333 {
7334 let output = text;
7335 if let Some(title) = summary_title(&output) {
7336 let raw_body = summary_without_title(&output);
7339 let clean_title = crate::scrub::scrub(&title, &identity);
7340 let mut body = crate::scrub::scrub(&raw_body, &identity);
7341 let redacted = clean_title != title || body != raw_body;
7342 let title = clean_title;
7343 let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7344 if !complete {
7345 self.state.event(
7346 "github-text",
7347 "description rewrite omitted required findings",
7348 );
7349 }
7350 if !body.trim().is_empty() {
7351 body.push_str("\n\n");
7352 body.push_str(&public_pr_evidence(&self.state, head));
7353 }
7354 let marker = format!("magi:run/{}", self.state.id);
7355 if !body.trim().is_empty() && !body.contains(&marker) {
7356 body.push_str(&format!("\n\n{marker}"));
7357 }
7358 let rewrite = PrMessage {
7359 title: title.clone(),
7360 body: body.clone(),
7361 };
7362 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7363 let mut failed = crate::github_text::check_for(
7364 &self.state.config.graph,
7365 &title,
7366 &body,
7367 redecision,
7368 );
7369 if (!crate::github_text::shareable(&title)
7370 || !crate::github_text::shareable(&body))
7371 && !failed.contains(&crate::github_text::Violation::SensitiveData)
7372 {
7373 failed.push(crate::github_text::Violation::SensitiveData);
7374 }
7375 if complete
7376 && !title.trim().is_empty()
7377 && !body.trim().is_empty()
7378 && !failed.is_empty()
7379 {
7380 rejected_rewrite = Some(crate::run::GithubTextRewrite {
7381 title: title.clone(),
7382 body: body.clone(),
7383 failed: failed
7384 .iter()
7385 .map(|v| crate::github_text::category(*v).to_owned())
7386 .collect(),
7387 });
7388 }
7389 if complete
7390 && !title.trim().is_empty()
7391 && !raw_body.trim().is_empty()
7392 && failed.is_empty()
7393 {
7394 decision = redecision;
7395 pr = PrMessage { title, body };
7396 rewritten = true;
7397 self.state
7398 .event("github-text", "description rewrite passed");
7399 if redacted {
7400 self.state.event(
7401 "github-text",
7402 "sensitive data removed from description rewrite",
7403 );
7404 }
7405 }
7406 }
7407 }
7408 if !rewritten {
7409 self.state.event(
7410 "github-text",
7411 "description rewrite unavailable or rejected; asking the owner",
7412 );
7413 if let Some(w) =
7414 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7415 {
7416 self.retire_github_text_question("the withheld text changed");
7419 let record = crate::run::GithubTextAsk {
7420 rewrite: rejected_rewrite,
7421 fingerprint: raw_fp,
7422 title: w.title,
7423 body: w.body,
7424 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7425 question: None,
7426 asks: 0,
7427 resolved: false,
7428 chosen_title: None,
7429 choice: None,
7430 };
7431 return self.settle_withheld(winner, &pr, record).await;
7432 }
7433 }
7434 }
7435 let (title, body) =
7436 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7437 Ok(Some(PrMessage { title, body }))
7438 }
7439
7440 async fn summarize_change(
7451 &mut self,
7452 winner: &Candidate,
7453 head: Option<&str>,
7454 ) -> std::result::Result<(), String> {
7455 let Some(head) = head else {
7456 return Err("the final commit could not be read".to_owned());
7457 };
7458 let remote = self.state.config.merge.remote.clone();
7459 let tracking = format!("{remote}/{}", self.state.base_branch);
7460 if !git::rev_exists(&winner.worktree, &tracking).await {
7461 return Err(format!("the remote base `{tracking}` could not be read"));
7462 }
7463 let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7464 Ok(b) if !b.is_empty() => b,
7465 _ => return Err("the merge base with the remote base could not be read".to_owned()),
7466 };
7467 let evidence = crate::pr_outcome::summary_evidence(
7468 &self.state,
7469 crate::pr_outcome::FinalHead::At(head),
7470 );
7471 let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7472 if self
7473 .state
7474 .change_summary
7475 .as_ref()
7476 .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7477 {
7478 return Ok(());
7479 }
7480 if self.state.change_summary.take().is_some() {
7481 self.state.event(
7482 "pr-summary",
7483 "the final diff or evidence changed; the earlier change summary was discarded",
7484 );
7485 }
7486 if let Err(e) = write_artifact(
7488 &self.state,
7489 &format!("public-description-{fingerprint}.md"),
7490 &format!("Final diff: {base}..{head}\n\n{evidence}"),
7491 ) {
7492 tracing::warn!("could not save public description evidence: {e:#}");
7493 }
7494 let chain = match agent::pick_chain(
7495 &self.state.config.agents,
7496 self.state.config.roles.synthesizer.as_ref(),
7497 &agent::installed,
7498 "synthesizer",
7499 ) {
7500 Ok(chain) => chain,
7501 Err(e) => {
7502 tracing::warn!("no agent for the change summary: {e:#}");
7503 self.state.event("pr-summary", "no summary agent available");
7504 return Err("no agent was available to summarize it".to_owned());
7505 }
7506 };
7507 let subjects = git::subjects(&winner.worktree, &base, head)
7508 .await
7509 .unwrap_or_default();
7510 let stat = git::diff_stat(&winner.worktree, &base, head)
7511 .await
7512 .unwrap_or_default();
7513 let stat: String = stat.chars().take(6_000).collect();
7514 let mut prompt = crate::prompt::change_summary_in(
7515 &base,
7516 head,
7517 &subjects,
7518 stat.trim(),
7519 &self.state.config.graph.github_language,
7520 );
7521 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."));
7522 let sessions = self.state.config.graph.sessions;
7523 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7524 let prompts = self.state.config.prompts.clone();
7525 let cache = self.state.config.cache_dir();
7526 let run = self.state.id.clone();
7527 for (i, spec) in chain.into_iter().enumerate() {
7528 let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7529 let job = SeatJob {
7530 spec: spec.clone(),
7531 seat,
7532 cwd: winner.worktree.clone(),
7533 prompt: prompt.clone(),
7534 timeout,
7535 allow_write: false,
7536 sessions,
7537 artifacts: agent::artifacts_dir(&self.state.dir()),
7538 stem: if i == 0 {
7539 "change-summary".to_owned()
7540 } else {
7541 format!("change-summary-{}", spec.id)
7542 },
7543 handover: None,
7544 };
7545 let ctx = WaveCtx {
7546 run: &run,
7547 node: "pr-summary",
7548 prompts: &prompts,
7549 cache: cache.as_deref(),
7550 round: None,
7551 carry_seats: false,
7552 pauses: Some((&self.pause, &self.interrupt)),
7553 };
7554 let (seat, outcome) =
7555 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7556 self.state.seats.insert(seat.key.clone(), seat);
7557 let text = match outcome {
7558 AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7559 AgentOutcome::ResourceBlocked(why) => {
7560 if self.state.resource_block.is_none() {
7563 self.state.resource_block = Some(crate::run::ResourceBlock {
7564 node: "pr-summary".to_owned(),
7565 kind: "shared resource".to_owned(),
7566 detail: why,
7567 at: Timestamp::now(),
7568 });
7569 }
7570 return Err("a shared resource was busy".to_owned());
7571 }
7572 _ => continue,
7573 };
7574 let blind = &self.state.config.blind;
7575 let Some(title) = summary_title(&text) else {
7576 continue;
7577 };
7578 let body: String = summary_without_title(&text)
7579 .lines()
7580 .filter(|l| !l.contains("magi:run/"))
7581 .collect::<Vec<_>>()
7582 .join("\n");
7583 let body = blind::sanitize_prose(body.trim(), blind);
7584 let title = blind::sanitize_prose(&title, blind);
7585 if body.trim().is_empty() || title.trim().is_empty() {
7586 continue;
7587 }
7588 if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7589 continue;
7590 }
7591 if git::rev_parse(&winner.worktree, "HEAD")
7593 .await
7594 .ok()
7595 .as_deref()
7596 != Some(head)
7597 {
7598 return Err("the branch moved while the summary was written".to_owned());
7599 }
7600 if git::merge_base(&winner.worktree, &tracking, head)
7601 .await
7602 .ok()
7603 .as_deref()
7604 != Some(base.as_str())
7605 || crate::pr_outcome::summary_evidence(
7606 &self.state,
7607 crate::pr_outcome::FinalHead::At(head),
7608 ) != evidence
7609 {
7610 return Err("the summary evidence changed while it was written".to_owned());
7611 }
7612 self.state.change_summary = Some(crate::run::ChangeSummary {
7613 head: head.to_owned(),
7614 base,
7615 evidence: fingerprint,
7616 title,
7617 body,
7618 });
7619 self.state.event(
7620 "pr-summary",
7621 format!("change summary written by {} for the final diff", spec.id),
7622 );
7623 if let Err(e) = self.state.save() {
7624 tracing::warn!("could not save the change summary: {e:#}");
7625 }
7626 return Ok(());
7627 }
7628 self.state
7629 .event("pr-summary", "no agent produced a change summary");
7630 Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7631 }
7632
7633 async fn rewrite_pr_text(
7641 &mut self,
7642 winner: &Candidate,
7643 pr: &PrMessage,
7644 violations: &[crate::github_text::Violation],
7645 ) -> Option<String> {
7646 let prompt = format!(
7647 "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{}",
7648 pr.title, pr.body
7649 );
7650 let sessions = self.state.config.graph.sessions;
7651 let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7652 let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7654 let key = format!("impl-{}", winner.label);
7655 if let Some(spec) = self
7656 .state
7657 .config
7658 .agents
7659 .iter()
7660 .find(|a| a.id == winner.agent)
7661 .cloned()
7662 && let Some(existing) = self.state.seats.get(&key)
7663 && existing.agent == spec.id
7664 && agent::has_session(spec.kind, existing, sessions)
7665 {
7666 attempts.push((spec, existing.clone(), true));
7667 } else {
7668 let chain = agent::pick_chain(
7669 &self.state.config.agents,
7670 self.state.config.roles.language_judge.as_ref(),
7671 &agent::installed,
7672 "language_judge",
7673 );
7674 match chain {
7675 Ok(chain) => {
7676 for spec in chain {
7677 let seat = crate::fixer::seat_for(
7678 &mut self.state,
7679 "github-text-rewrite",
7680 &spec.id,
7681 );
7682 attempts.push((spec, seat, false));
7683 }
7684 }
7685 Err(e) => {
7686 tracing::warn!("no agent for the description rewrite: {e:#}");
7687 self.state
7688 .event("github-text", "no rewrite agent available");
7689 return None;
7690 }
7691 }
7692 }
7693 let prompts = self.state.config.prompts.clone();
7694 let cache = self.state.config.cache_dir();
7695 let run = self.state.id.clone();
7696 for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7697 let job = SeatJob {
7698 spec: spec.clone(),
7699 seat,
7700 cwd: winner.worktree.clone(),
7701 prompt: prompt.clone(),
7702 timeout: retry_budget(timeout_full, resumed),
7703 allow_write: false,
7704 sessions,
7705 artifacts: agent::artifacts_dir(&self.state.dir()),
7706 stem: if i == 0 {
7707 "github-text-rewrite".to_owned()
7708 } else {
7709 format!("github-text-rewrite-{}", spec.id)
7710 },
7711 handover: None,
7712 };
7713 let ctx = WaveCtx {
7714 run: &run,
7715 node: "github-text",
7716 prompts: &prompts,
7717 cache: cache.as_deref(),
7718 round: None,
7719 carry_seats: false,
7720 pauses: Some((&self.pause, &self.interrupt)),
7721 };
7722 let (seat, outcome) =
7723 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7724 self.state.seats.insert(seat.key.clone(), seat);
7725 match outcome {
7726 AgentOutcome::Ok(o)
7727 if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7728 {
7729 return Some(o.text);
7730 }
7731 AgentOutcome::ResourceBlocked(_) => return None,
7732 _ => {}
7733 }
7734 }
7735 None
7736 }
7737
7738 fn retire_github_text_question(&mut self, why: &str) {
7740 let Some(id) = self
7741 .state
7742 .github_text
7743 .as_ref()
7744 .and_then(|g| g.question.clone())
7745 else {
7746 return;
7747 };
7748 let _ = ask::Questions::open().update(&id, |q| {
7749 q.abandon(why);
7750 Ok(())
7751 });
7752 }
7753
7754 async fn settle_withheld(
7758 &mut self,
7759 winner: &Candidate,
7760 pr: &PrMessage,
7761 mut g: crate::run::GithubTextAsk,
7762 ) -> Result<Option<PrMessage>> {
7763 use crate::github_text as gt;
7764 let store = ask::Questions::open();
7765 let timeout = self.state.config.graph.answer_timeout;
7766 if !g.resolved {
7767 let mut standing = g
7768 .question
7769 .as_ref()
7770 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7771 if let Some(q) = standing.as_ref()
7772 && q.status.open()
7773 && gt::expired(q, timeout)
7774 {
7775 let why = format!("no answer within {}s of asking", timeout.max(1));
7776 standing = store
7777 .update(&q.id, |q| {
7778 q.abandon(&why);
7779 Ok(())
7780 })
7781 .ok()
7782 .map(|(q, ())| q);
7783 }
7784 let mut ask_again: Option<Option<Vec<String>>> = None;
7785 match standing {
7786 None if g.asks <= 1 => {
7789 g.asks = 0;
7790 ask_again = Some(None);
7791 }
7792 None => {
7793 g.resolved = true;
7794 g.chosen_title = None;
7795 g.choice = Some(GithubTextChoice::Fallback);
7796 }
7797 Some(q) if q.status.open() => {
7798 self.park_on_github_text(&g)?;
7799 return Ok(None);
7800 }
7801 Some(q) => match gt::read_reply(&q) {
7802 gt::Reply::Fallback => {
7803 g.resolved = true;
7804 g.chosen_title = None;
7805 g.choice = Some(GithubTextChoice::Fallback);
7806 }
7807 gt::Reply::Original => {
7808 g.resolved = true;
7809 g.chosen_title = None;
7810 g.choice = Some(GithubTextChoice::Original);
7811 }
7812 gt::Reply::Rewrite => {
7813 g.resolved = true;
7814 g.chosen_title = None;
7815 g.choice = Some(if g.rewrite.is_some() {
7816 GithubTextChoice::Rewrite
7817 } else {
7818 GithubTextChoice::Fallback
7819 });
7820 }
7821 gt::Reply::Title(text) => {
7822 let first = text
7823 .lines()
7824 .map(str::trim)
7825 .find(|l| !l.is_empty())
7826 .unwrap_or("");
7827 let decision = if first.is_empty() {
7828 None
7829 } else {
7830 let candidate = PrMessage {
7831 title: first.to_owned(),
7832 body: String::new(),
7833 };
7834 self.judge_pr_language(&winner.worktree, &candidate).await
7835 };
7836 match gt::vet_title(
7837 &text,
7838 decision,
7839 self.state.config.graph.github_english(),
7840 ) {
7841 Ok(title) => {
7842 g.resolved = true;
7843 g.chosen_title = Some(title);
7844 g.choice = Some(GithubTextChoice::MyTitle);
7845 }
7846 Err(cats) if g.asks < 2 => {
7847 ask_again = Some(Some(
7848 cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7849 ))
7850 }
7851 Err(_) => {
7852 g.resolved = true;
7853 g.chosen_title = None;
7854 g.choice = Some(GithubTextChoice::Fallback);
7855 }
7856 }
7857 }
7858 },
7859 }
7860 if let Some(rejected) = ask_again {
7861 let w = gt::Withheld {
7864 title: g.title,
7865 body: g.body,
7866 categories: g.categories.clone(),
7867 };
7868 let artifact = match crate::run::write_artifact(
7871 &self.state,
7872 >::artifact_name(>::fingerprint(&pr.title, &pr.body)),
7873 >::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7874 ) {
7875 Ok(p) => Some(p),
7876 Err(e) => {
7877 tracing::warn!("could not write the withheld-text artifact: {e:#}");
7878 None
7879 }
7880 };
7881 let (detail, comparison) = gt::question_content(
7882 &w,
7883 &pr.title,
7884 &pr.body,
7885 &self.state.id,
7886 rejected.as_deref(),
7887 artifact.as_deref(),
7888 g.rewrite.as_ref(),
7889 );
7890 let mut q = ask::Question::new(
7891 self.state.id.clone(),
7892 gt::ASK_NODE.to_owned(),
7893 gt::ASK_SEAT.to_owned(),
7894 gt::question_summary(&self.state.config.graph.language, &w),
7895 detail,
7896 gt::question_choices(&w, g.rewrite.as_ref()),
7897 );
7898 q.posting_comparison = Some(comparison);
7899 q.answer_timeout = timeout;
7900 g.question = Some(q.id.clone());
7903 g.asks += 1;
7904 self.state.github_text = Some(g.clone());
7905 self.state.event(
7906 "github-text",
7907 format!("asking the owner about the withheld text ({})", q.short()),
7908 );
7909 self.state.save()?;
7910 store
7911 .put(&mut q)
7912 .context("file the posting-gate question")?;
7913 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7914 tracing::warn!(
7915 "could not notify about posting-gate question {}: {e:#}",
7916 q.short()
7917 );
7918 }
7919 self.park_on_github_text(&g)?;
7920 return Ok(None);
7921 }
7922 }
7923 let resolved = g.chosen_title.clone();
7924 let original = g.choice == Some(GithubTextChoice::Original);
7925 self.state.event(
7927 "github-text",
7928 if original {
7929 format!(
7930 "posting the original text for: {}",
7931 match (g.title, g.body) {
7932 (true, true) => "title, description",
7933 (true, false) => "title",
7934 _ => "description",
7935 }
7936 )
7937 } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7938 "posting the saved rewrite title and description".to_owned()
7939 } else if resolved.is_some() {
7940 "posting the owner's replacement title".to_owned()
7941 } else {
7942 "posting the neutral text for the withheld fields".to_owned()
7943 },
7944 );
7945 self.state.github_text = Some(g.clone());
7946 self.state.save()?;
7947 if g.choice == Some(GithubTextChoice::Rewrite)
7948 && let Some(r) = g.rewrite.as_ref()
7949 {
7950 return Ok(Some(PrMessage {
7951 title: r.title.clone(),
7952 body: r.body.clone(),
7953 }));
7954 }
7955 let w = gt::Withheld {
7956 title: g.title,
7957 body: g.body,
7958 categories: g.categories.clone(),
7959 };
7960 let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7961 if let Some(t) = resolved {
7962 title = t;
7963 }
7964 let english = gt::LanguageDecision {
7966 title_english: true,
7967 body_english: true,
7968 body_complete: true,
7969 };
7970 let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7971 if original {
7972 if g.title {
7975 title = pr.title.clone();
7976 }
7977 if g.body {
7978 body = pr.body.clone();
7979 }
7980 }
7981 Ok(Some(PrMessage { title, body }))
7982 }
7983
7984 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7985 self.state.github_text = Some(g.clone());
7986 self.state.end_pass_parked(ParkCause::Question);
7987 self.state.parked = true;
7988 self.state.event(
7989 "github-text",
7990 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7991 );
7992 self.state.save()
7993 }
7994
7995 async fn judge_pr_language(
7998 &mut self,
7999 cwd: &Path,
8000 pr: &PrMessage,
8001 ) -> Option<crate::github_text::LanguageDecision> {
8002 self.state.config.roles.language_judge.as_ref()?;
8003 if !self.state.config.graph.language_gate() {
8004 return None;
8005 }
8006 let decision =
8007 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
8008 self.state.event(
8009 "github-text",
8010 if decision.is_some() {
8011 "language decided by the language judge"
8012 } else {
8013 "language judge unavailable; using built-in heuristics"
8014 },
8015 );
8016 decision
8017 }
8018
8019 async fn merge(&mut self) -> Result<()> {
8020 if self
8035 .state
8036 .base_sync
8037 .as_ref()
8038 .is_some_and(|s| s.conflict.is_some())
8039 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8040 != Some(RunStatus::Gating)
8041 || !self.state.gate_status().ok()
8050 {
8051 return Ok(());
8052 }
8053 if self.state.merge.is_some() && !self.state.pr_publication_pending {
8062 return Ok(());
8063 }
8064 let Some(winner) = self.state.winner().cloned() else {
8065 return Ok(());
8066 };
8067 let repo = self.state.repo.clone();
8068 let base = self.state.base_branch.clone();
8069 let mode = self.state.config.merge.mode;
8070 let style = self.state.config.merge.style;
8071 let facts = if is_review_run(&self.state) {
8072 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
8073 let start = review_base(
8074 &repo,
8075 &self.state.config.merge.remote,
8076 &base,
8077 &self.state.base_commit,
8078 &winner.branch,
8079 )
8080 .await;
8081 branch_facts(&repo, &start, &winner.branch).await
8082 } else {
8083 None
8084 };
8085 let leaked = if is_review_run(&self.state) {
8088 leaked_subjects(&self.state, &winner.branch).await
8089 } else {
8090 Some(Vec::new())
8091 };
8092 let Some(pr) = self
8096 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
8097 .await?
8098 else {
8099 return Ok(());
8100 };
8101 let message = pr.commit_message();
8102
8103 let outcome = match mode {
8104 MergeMode::None => MergeOutcome {
8105 mode,
8106 ok: true,
8107 detail: manual_merge_command(style, &repo, &winner.branch, &message),
8108 empty: false,
8109 },
8110 MergeMode::Pr | MergeMode::Local
8111 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
8112 {
8113 MergeOutcome {
8114 mode,
8115 ok: false,
8116 detail: empty_candidate_detail(&self.state, &base),
8117 empty: true,
8118 }
8119 }
8120 MergeMode::Local => {
8121 let on = git::current_branch(&repo).await?;
8122 if on.as_deref() != Some(base.as_str()) {
8123 MergeOutcome {
8124 mode,
8125 ok: false,
8126 detail: format!(
8127 "{} has {} checked out, not the base branch {base}",
8128 repo.display(),
8129 on.unwrap_or_else(|| "a detached HEAD".to_owned())
8130 ),
8131 empty: false,
8132 }
8133 } else if !git::is_clean(&repo).await? {
8134 MergeOutcome {
8135 mode,
8136 ok: false,
8137 detail: format!("{} is dirty; refusing to merge", repo.display()),
8138 empty: false,
8139 }
8140 } else {
8141 let out = match style {
8142 MergeStyle::Merge => {
8143 git::merge_no_ff(&repo, &winner.branch, &message).await?
8144 }
8145 MergeStyle::Squash => {
8146 git::merge_squash(&repo, &winner.branch, &message).await?
8147 }
8148 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
8149 };
8150 MergeOutcome {
8151 mode,
8152 ok: out.ok(),
8153 detail: if out.ok() { out.stdout } else { out.stderr },
8154 empty: false,
8155 }
8156 }
8157 }
8158 MergeMode::Pr if self.state.task_pr_branch().is_some() => {
8159 self.state.pr_publication_pending = true;
8160 self.state.save()?;
8161 let out =
8162 crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
8163 self.state.pr_publication_pending = out.is_err();
8164 MergeOutcome {
8165 mode,
8166 ok: out.is_ok(),
8167 detail: match out {
8168 Ok(url) => url,
8169 Err(e) => format!("{e:#}"),
8170 },
8171 empty: false,
8172 }
8173 }
8174 MergeMode::Pr => {
8175 let remote = self.state.config.merge.remote.clone();
8176 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
8177 if !pushed.ok() {
8178 MergeOutcome {
8179 mode,
8180 ok: false,
8181 detail: pushed.stderr,
8182 empty: false,
8183 }
8184 } else {
8185 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
8191 let out = match pr_merge_plan(found) {
8192 PrPlan::Create => {
8193 gh_pr_create(
8194 &winner.worktree,
8195 &base,
8196 &winner.branch,
8197 &pr.title,
8198 &pr.body,
8199 )
8200 .await
8201 }
8202 PrPlan::Adopt { url, title } => {
8203 self.state
8204 .event("merge", format!("Pr: adopted open pull request {url}"));
8205 if self.state.github_text.as_ref().is_some_and(|g| {
8206 g.resolved
8207 && g.choice == Some(GithubTextChoice::Rewrite)
8208 && g.rewrite.is_some()
8209 }) {
8210 land::set_pr_text_verbatim(
8213 &winner.worktree,
8214 &url,
8215 &pr.title,
8216 &pr.body,
8217 )
8218 .await
8219 .map(|()| url)
8220 } else {
8221 if title != pr.title
8222 && (!is_review_run(&self.state)
8223 || leaked
8224 .as_deref()
8225 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
8226 && let Err(e) =
8227 if self.state.github_text.as_ref().is_some_and(|g| {
8228 g.resolved
8229 && g.title
8230 && g.choice == Some(GithubTextChoice::Original)
8231 }) {
8232 land::set_pr_title_verbatim(
8234 &winner.worktree,
8235 &url,
8236 &pr.title,
8237 )
8238 .await
8239 } else {
8240 land::set_pr_title(
8241 &mut self.state,
8242 &winner.worktree,
8243 &url,
8244 &pr.title,
8245 )
8246 .await
8247 }
8248 {
8249 tracing::warn!("could not refresh title of {url}: {e:#}");
8250 self.state
8251 .event("merge", format!("Pr: title refresh failed: {e:#}"));
8252 }
8253 Ok(url)
8254 }
8255 }
8256 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8257 };
8258 match out {
8259 Ok(url) => MergeOutcome {
8260 mode,
8261 ok: true,
8262 detail: url,
8263 empty: false,
8264 },
8265 Err(e) => MergeOutcome {
8266 mode,
8267 ok: false,
8268 detail: e.to_string(),
8269 empty: false,
8270 },
8271 }
8272 }
8273 }
8274 };
8275
8276 self.state.status = match (mode, outcome.ok) {
8277 (MergeMode::None, _) => RunStatus::Ready,
8278 (_, true) => RunStatus::Merged,
8279 (_, false) => RunStatus::Blocked,
8280 };
8281 self.state.event(
8282 "merge",
8283 format!(
8284 "{:?}: {}",
8285 mode,
8286 outcome.detail.lines().next().unwrap_or("")
8287 ),
8288 );
8289 self.state.merge = Some(outcome);
8290 self.state.save()?;
8291
8292 if self.state.config.graph.land
8298 && mode == MergeMode::Pr
8299 && self.state.status == RunStatus::Merged
8300 {
8301 self.run_land().await?;
8302 }
8303 self.settle_questions();
8308 Ok(())
8309 }
8310
8311 async fn run_land(&mut self) -> Result<()> {
8322 crate::pr_head::ensure_owner(&self.state)?;
8323 let url = self
8324 .state
8325 .merge
8326 .as_ref()
8327 .map(|m| m.detail.clone())
8328 .unwrap_or_default();
8329 let url = url.lines().next().unwrap_or("").trim().to_owned();
8330 if !url.starts_with("http") {
8331 return Ok(());
8332 }
8333 loop {
8336 match land::land(&mut self.state, &url).await {
8337 Ok(_)
8338 if self
8339 .state
8340 .merge_repairs
8341 .last()
8342 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8343 {
8344 if self.repair_merge_review().await? {
8345 continue;
8346 }
8347 }
8348 Ok(pr) if self.state.parked => {
8349 let _ = pr;
8353 }
8354 Ok(_) if self.state.merge_decision.as_ref().is_some_and(|d| d.closed) => {
8355 self.state.status = RunStatus::Superseded;
8356 self.state.save()?;
8357 }
8358 Ok(pr) => {
8359 self.state.status = match pr.state {
8360 land::PrLifecycle::Merged => RunStatus::Merged,
8361 _ => RunStatus::Blocked,
8362 };
8363 if bump::should_release_bump(self.state.status)
8370 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8371 {
8372 self.state
8378 .event("bump", format!("release bump skipped: {e:#}"));
8379 }
8380 if self.state.status == RunStatus::Merged {
8383 crate::followup::after_merge(&mut self.state, &pr.url).await;
8384 }
8385 self.state.save()?;
8386 }
8387 Err(e) => {
8388 if e.is::<crate::repo_lock::ResourceBlocked>() {
8389 return Err(e);
8390 }
8391 self.state.status = RunStatus::Blocked;
8392 self.state.event("land", format!("gave up: {e}"));
8393 self.state.save()?;
8394 }
8395 }
8396 break;
8397 }
8398 Ok(())
8399 }
8400
8401 async fn repair_merge_review(&mut self) -> Result<bool> {
8404 use crate::run::MergeRepairPhase as Phase;
8405 crate::pr_head::ensure_owner(&self.state)?;
8406 let i = self.state.merge_repairs.len() - 1;
8407 let request = self.state.merge_repairs[i].clone();
8408 if request.phase == Phase::Fixing {
8409 let restored = match self.state.winner().cloned() {
8410 Some(winner) => self.graft_restore(&winner.worktree, &request.head).await,
8411 None => Err(anyhow::anyhow!("repair has no winning branch")),
8412 };
8413 let why = match restored {
8414 Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8415 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
8416 Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8417 };
8418 self.hold_merge_repair(i, why)?;
8419 return Ok(false);
8420 }
8421 let failure = if i >= self.state.config.graph.land_rounds
8422 || self.state.config.graph.review_rounds == 0
8423 {
8424 Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8425 } else {
8426 None
8427 };
8428 if let Some(why) = failure {
8429 self.hold_merge_repair(i, why)?;
8430 return Ok(false);
8431 }
8432 if request.phase == Phase::Requested {
8433 let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8434 Ok(pr) => pr,
8435 Err(e) => {
8436 self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8437 return Ok(false);
8438 }
8439 };
8440 self.state.merge_repairs[i].phase = Phase::Fixing;
8441 self.state.land_approval = None;
8442 self.state.land_armed_head = None;
8443 self.state.save()?;
8444 let evidence = serde_json::to_string_pretty(&request)?;
8447 let reason = format!(
8448 "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}"
8449 );
8450 let budget = self.state.config.graph.land_rounds;
8451 let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8452 match result {
8453 Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8454 Ok(land::Fixed::Failed(why)) => {
8455 self.hold_merge_repair(
8456 i,
8457 format!(
8458 "repair failed: {why}; inspect the branch and run an explicit review"
8459 ),
8460 )?;
8461 return Ok(false);
8462 }
8463 Err(e) => {
8464 self.hold_merge_repair(
8465 i,
8466 format!(
8467 "repair failed: {e:#}; inspect the branch and run an explicit review"
8468 ),
8469 )?;
8470 return Ok(false);
8471 }
8472 }
8473 self.begin_merge_repair_review(i)?;
8477 }
8478 self.review_loop().await?;
8479 self.gate().await?;
8480 if self.resource_halt()? || self.state.parked {
8481 return Ok(false);
8482 }
8483 if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8484 != Some(RunStatus::Gating)
8485 || !self.state.gate_status().ok()
8486 {
8487 self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8488 return Ok(false);
8489 }
8490 let winner = self
8493 .state
8494 .winner()
8495 .cloned()
8496 .context("repair has no winning branch")?;
8497 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8498 if self.state.gate_head.as_deref() != Some(after.as_str())
8499 || !git::is_clean(&winner.worktree).await?
8500 {
8501 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())?;
8502 return Ok(false);
8503 }
8504 let expected = self
8505 .state
8506 .pr_pushed_head
8507 .as_deref()
8508 .unwrap_or(&request.head)
8509 .to_owned();
8510 let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8511 let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8514 let refspec = format!("{after}:refs/heads/{public}");
8515 let push = git::git_raw(
8516 &winner.worktree,
8517 &["push", &lease, &self.state.config.merge.remote, &refspec],
8518 )
8519 .await;
8520 match push {
8521 Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8522 Ok(out) => {
8523 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8524 return Ok(false);
8525 }
8526 Err(e) => {
8527 self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8528 return Ok(false);
8529 }
8530 }
8531 self.state.merge_repairs[i].phase = Phase::Complete;
8532 self.state.status = RunStatus::Landing;
8533 self.state.event(
8534 "repair",
8535 "new review and gate completed; fresh owner merge decision required",
8536 );
8537 self.state.save()?;
8538 Ok(true)
8539 }
8540
8541 fn merge_repair_artifacts(&self) -> PathBuf {
8542 let artifacts = agent::artifacts_dir(&self.state.dir());
8543 match self
8544 .state
8545 .merge_repairs
8546 .last()
8547 .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8548 {
8549 Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8550 None => artifacts,
8551 }
8552 }
8553
8554 fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8555 self.state.reviews.clear();
8556 self.state.gate.clear();
8557 self.state.gate_ran = false;
8558 self.state.gate_head = None;
8559 self.state.gate_fixes.clear();
8560 self.state.pre_gate.clear();
8561 self.state.pre_gate_commit = None;
8562 self.state.contested_handoff = None;
8563 self.state.land_reruns = None;
8564 self.state.land_approval = None;
8565 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8566 self.state.status = RunStatus::Reviewing;
8567 self.state.save()
8568 }
8569
8570 fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8571 self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8572 self.state.merge_repairs[index].failure = Some(why.clone());
8573 self.state.status = RunStatus::Blocked;
8574 self.state.event("repair", why);
8575 self.state.save()
8576 }
8577
8578 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8582 if let Some(existing) = self.state.seats.get(key)
8583 && existing.agent == agent
8584 {
8585 return existing.clone();
8586 }
8587 let fresh = if self.state.seats.contains_key(key) {
8590 handover_seat(key, agent, self.state.next_seat_seed())
8591 } else {
8592 SeatState::new(key, agent, self.state.seed)
8593 };
8594 self.state.seats.insert(key.to_owned(), fresh.clone());
8595 fresh
8596 }
8597
8598 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8604 match self.state.seats.get(key) {
8605 Some(s) if s.agent != spec.id => {
8606 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8607 }
8608 _ => spec,
8609 }
8610 }
8611
8612 fn view(&self, c: &Candidate) -> CandidateView {
8614 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8615 .unwrap_or_default();
8616 let (patch, _) = blind::sanitize_patch(
8617 &format!("candidate {} patch", c.label),
8618 &raw,
8619 &self.state.config.blind,
8620 );
8621 CandidateView {
8622 label: c.label,
8623 branch: c.branch.clone(),
8624 summary: c.summary.clone(),
8625 stat: c.stat.clone(),
8626 patch,
8627 }
8628 }
8629
8630 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8632 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8633 prompt::judge(
8634 "(see above)",
8635 &views,
8636 self.roles.judges.len(),
8637 base_short,
8638 "en",
8639 )
8640 }
8641
8642 fn vote_prompt_full(
8648 &self,
8649 j: usize,
8650 labels: &[char],
8651 language: &str,
8652 candidates: &[Candidate],
8653 base_short: &str,
8654 ) -> String {
8655 let mut text = format!(
8656 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8657 prompt::final_vote(labels, language),
8658 self.state.instruction,
8659 self.candidate_block(candidates, base_short)
8660 );
8661 if let Some(own) = self
8662 .state
8663 .judgements
8664 .get(j)
8665 .filter(|r| !r.ranking.is_empty())
8666 {
8667 let reasons = own
8668 .reasons
8669 .iter()
8670 .map(|(k, v)| format!("- {k}: {v}"))
8671 .collect::<Vec<_>>()
8672 .join("\n");
8673 text.push_str(&format!(
8674 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8675 own.ranking.iter().collect::<String>(),
8676 if reasons.is_empty() {
8677 ""
8678 } else {
8679 ", because:\n"
8680 }
8681 ));
8682 }
8683 if !self.state.deliberation.is_empty() {
8684 text.push_str("\n# What was argued before this vote\n");
8685 for t in self.transcript(&[], j) {
8686 text.push_str(&format!(
8687 "\n## {}{}\n\n{}\n",
8688 t.who,
8689 if t.is_self { " (you)" } else { "" },
8690 t.body.trim()
8691 ));
8692 }
8693 }
8694 text
8695 }
8696
8697 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8704 let mut turns = Vec::new();
8705 for j in &self.state.judgements {
8706 if j.ranking.is_empty() {
8707 continue;
8708 }
8709 let reasons = j
8710 .reasons
8711 .iter()
8712 .map(|(k, v)| format!("- {k}: {v}"))
8713 .collect::<Vec<_>>()
8714 .join("\n");
8715 turns.push(Turn {
8716 who: format!("Judge {} (opening ranking)", j.judge),
8717 is_self: j.judge == self_idx + 1,
8718 body: format!(
8719 "Ranked {}{}{reasons}",
8720 j.ranking.iter().collect::<String>(),
8721 if reasons.is_empty() {
8722 ""
8723 } else {
8724 ", because:\n"
8725 }
8726 ),
8727 });
8728 }
8729 for t in self
8730 .state
8731 .deliberation
8732 .iter()
8733 .flat_map(|r| r.turns.iter())
8734 .chain(current)
8735 {
8736 turns.push(Turn {
8737 who: format!("Judge {}", t.judge),
8738 is_self: t.judge == self_idx + 1,
8739 body: t.body.clone(),
8740 });
8741 }
8742 turns
8743 }
8744}
8745
8746fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8748 agent::has_session(spec.kind, seat, sessions)
8749}
8750
8751fn next_untried_in_roster<'a>(
8772 roster: &'a [AgentSpec],
8773 start: usize,
8774 tried: &BTreeSet<String>,
8775) -> Option<&'a AgentSpec> {
8776 roster
8777 .get(start + 1..)?
8778 .iter()
8779 .find(|s| !tried.contains(&s.id))
8780}
8781
8782fn pick_successor<'a>(
8803 roster: &'a [AgentSpec],
8804 start: usize,
8805 tried: &BTreeSet<String>,
8806 carried: Option<&BTreeSet<String>>,
8807 others: &BTreeSet<String>,
8808) -> Option<&'a AgentSpec> {
8809 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8810 roster
8811 .get(start + 1..)
8812 .and_then(|tail| {
8813 tail.iter()
8814 .filter(free)
8815 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8816 })
8817 .or_else(|| {
8818 carried
8819 .is_some()
8820 .then(|| roster.iter().find(free))
8821 .flatten()
8822 })
8823 .or_else(|| match carried {
8824 Some(c) => next_for_seat(roster, start, tried, c),
8825 None => next_untried_in_roster(roster, start, tried),
8826 })
8827}
8828
8829fn next_for_seat<'a>(
8839 roster: &'a [AgentSpec],
8840 start: usize,
8841 round_tried: &BTreeSet<String>,
8842 carried_failed: &BTreeSet<String>,
8843) -> Option<&'a AgentSpec> {
8844 roster
8845 .get(start + 1..)?
8846 .iter()
8847 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8848 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8849}
8850
8851fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8857 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8858 return spec;
8859 };
8860 let ok = |id: &str| !h.failed.contains(id);
8861 if let Some(last) = h.last_ok.as_deref()
8862 && ok(last)
8863 && let Some(s) = roster.iter().find(|s| s.id == last)
8864 {
8865 return s.clone();
8866 }
8867 if ok(&spec.id) {
8868 return spec;
8869 }
8870 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8871 roster
8872 .get(from + 1..)
8873 .into_iter()
8874 .flatten()
8875 .chain(roster.iter())
8876 .find(|s| ok(&s.id))
8877 .cloned()
8878 .unwrap_or(spec)
8879}
8880
8881pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8885 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8886}
8887
8888const TIMED_OUT: &str = "timed out";
8891
8892impl FailClass {
8893 fn of(out: &AgentOutcome) -> Option<Self> {
8895 match out {
8896 AgentOutcome::Ok(_) => None,
8897 AgentOutcome::Quota(_) => Some(Self::Quota),
8898 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8899 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8900 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8901 AgentOutcome::ResourceBlocked(_) => None,
8904 }
8905 }
8906
8907 fn stem_word(&self) -> &'static str {
8909 match self {
8910 Self::Quota => "quota",
8911 _ => "handover",
8912 }
8913 }
8914}
8915
8916fn failure_signature(msg: &str) -> String {
8920 let mut line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8921 if let Some(code) = line
8924 .strip_prefix("process exited with no usable output (exit code ")
8925 .and_then(|s| s.strip_suffix(')'))
8926 .and_then(|s| s.parse::<i32>().ok())
8927 {
8928 line = format!("exited with some({code}) and no usable output");
8929 } else if matches!(
8930 line.as_str(),
8931 "process exited with no usable output (no exit code reported)"
8932 | "process exited with no usable output (exit code unavailable)"
8933 ) {
8934 line = "exited with none and no usable output".to_owned();
8935 }
8936 let mut out = Vec::new();
8937 for word in line.split_whitespace() {
8938 if word.contains('/') || word.contains('\\') {
8939 out.push("<path>".to_owned());
8940 continue;
8941 }
8942 let mut w = String::new();
8943 let mut in_digits = false;
8944 for c in word.chars() {
8945 if c.is_ascii_digit() {
8946 if !in_digits {
8947 w.push('#');
8948 }
8949 in_digits = true;
8950 } else {
8951 in_digits = false;
8952 w.push(c);
8953 }
8954 }
8955 out.push(w);
8956 }
8957 out.join(" ").chars().take(120).collect()
8958}
8959
8960fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8967 match cur {
8968 FailClass::Quota | FailClass::Timeout => true,
8969 FailClass::Other(_) => prev != Some(cur),
8970 }
8971}
8972
8973fn fail_reason(out: &AgentOutcome) -> String {
8975 match out {
8976 AgentOutcome::Ok(_) => String::new(),
8977 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8978 AgentOutcome::Dropped(o) => format!(
8979 "the CLI dropped the stream ({})",
8980 o.dropped
8981 .as_ref()
8982 .map(|d| d.why.as_str())
8983 .unwrap_or("it ended without delivering its answer")
8984 ),
8985 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8986 e.lines().next().unwrap_or("").chars().take(160).collect()
8987 }
8988 }
8989}
8990
8991pub(crate) fn record_handover(
8994 state: &mut RunState,
8995 node: &str,
8996 seat: &str,
8997 from: &str,
8998 to: &str,
8999 class: &FailClass,
9000 reason: &str,
9001) {
9002 let message = if *class == FailClass::Quota {
9003 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
9004 } else {
9005 format!("{seat}: handed over {from} -> {to} ({reason})")
9006 };
9007 state.event(node, message);
9008 state.handovers.push(Handover {
9009 at: Timestamp::now(),
9010 node: node.to_owned(),
9011 seat: seat.to_owned(),
9012 from: from.to_owned(),
9013 to: to.to_owned(),
9014 reason: reason.to_owned(),
9015 });
9016}
9017
9018fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
9032 commands.iter().any(|c| c.exit_code.is_none())
9033}
9034
9035fn verified_noop_claim(
9048 usable: bool,
9049 commands: &[agent::CommandEvidence],
9050 text: &str,
9051) -> Option<String> {
9052 (usable && !has_unconfirmed_command(commands))
9053 .then(|| verdict::verified_noop(text))
9054 .flatten()
9055}
9056
9057fn short(commit: &str) -> String {
9058 commit.chars().take(7).collect()
9059}
9060
9061fn make_executable(path: &Path) -> Result<()> {
9062 #[cfg(unix)]
9063 {
9064 use std::os::unix::fs::PermissionsExt as _;
9065 let mut perms = std::fs::metadata(path)?.permissions();
9066 perms.set_mode(0o755);
9067 std::fs::set_permissions(path, perms)?;
9068 }
9069 #[cfg(not(unix))]
9070 {
9071 let _ = path;
9072 }
9073 Ok(())
9074}
9075
9076struct WaveCtx<'a> {
9083 run: &'a str,
9086 node: &'a str,
9088 prompts: &'a Prompts,
9089 cache: Option<&'a Path>,
9091 round: Option<usize>,
9094 carry_seats: bool,
9098 pauses: Option<(&'a Pause, &'a Pause)>,
9101}
9102
9103fn repository_wait(
9105 state: &RunState,
9106 pauses: Option<(&Pause, &Pause)>,
9107) -> crate::repo_lock::WaitPolicy {
9108 let pauses = pauses.map(|(a, b)| (a.clone(), b.clone()));
9109 let inherited = crate::repo_lock::WaitPolicy::current();
9110 crate::repo_lock::WaitPolicy::new(
9111 Duration::from_secs(state.config.graph.timeout_resource_wait),
9112 move || {
9113 inherited.cancelled()
9114 || pauses
9115 .as_ref()
9116 .is_some_and(|(a, b)| a.parked() || b.parked())
9117 },
9118 )
9119}
9120
9121fn record_repository_block(state: &mut RunState, error: &anyhow::Error) {
9122 if error.is::<crate::repo_lock::ResourceBlocked>() {
9123 state.resource_block = Some(crate::run::ResourceBlock {
9124 node: "repository".into(),
9125 kind: "repository coordination".into(),
9126 detail: error.to_string(),
9127 at: Timestamp::now(),
9128 });
9129 state.event("resource", error.to_string());
9130 }
9131}
9132
9133async fn coordinate_operation(
9134 state: &mut RunState,
9135 cwd: &Path,
9136 wait: &crate::repo_lock::WaitPolicy,
9137) -> Result<Option<crate::repo_lock::SeatLease>> {
9138 let result =
9139 crate::repo_lock::SeatLease::operation_with(cwd, state.config.coordination.as_ref(), wait)
9140 .await;
9141 if let Err(e) = &result {
9142 record_repository_block(state, e);
9143 }
9144 result
9145}
9146
9147async fn run_one(
9149 job: SeatJob,
9150 sem: Arc<Semaphore>,
9151 ctx: &WaveCtx<'_>,
9152 state: &mut RunState,
9153 attempt: usize,
9154) -> (SeatState, AgentOutcome) {
9155 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
9156 .await
9157 .pop()
9158 .expect("one job in, one result out");
9159 (seat, out)
9160}
9161
9162async fn wave(
9168 jobs: Vec<SeatJob>,
9169 sem: Arc<Semaphore>,
9170 ctx: &WaveCtx<'_>,
9171 state: &mut RunState,
9172 attempt: usize,
9173) -> Vec<(usize, SeatState, AgentOutcome)> {
9174 let WaveCtx {
9175 run,
9176 node,
9177 prompts,
9178 cache,
9179 round,
9180 carry_seats: _,
9181 pauses,
9182 } = *ctx;
9183 for job in &jobs {
9184 state.seat_started(node, &job.seat.key, job.timeout, attempt);
9185 }
9186 if let Err(e) = state.save() {
9187 tracing::warn!("could not persist in-progress seats: {e:#}");
9192 }
9193 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
9209 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
9213 let cache_guard = if let Some(cache_dir) = cache {
9214 if jobs_had_a_writer {
9215 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
9216 let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
9217 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
9218 {
9219 Ok(Some(g)) => Some(g),
9220 Ok(None) => {
9223 state.resource_block = Some(crate::run::ResourceBlock {
9224 node: node.to_owned(),
9225 kind: "shared build cache".to_owned(),
9226 detail: "the run is parking".to_owned(),
9227 at: Timestamp::now(),
9228 });
9229 return block_wave(
9230 jobs,
9231 state,
9232 node,
9233 attempt,
9234 "parked while waiting for the shared build cache",
9235 );
9236 }
9237 Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
9240 Err(e) => {
9241 let why = format!("{e:#}");
9245 state.resource_block = Some(crate::run::ResourceBlock {
9246 node: node.to_owned(),
9247 kind: "shared build cache".to_owned(),
9248 detail: why.clone(),
9249 at: Timestamp::now(),
9250 });
9251 state.event(
9252 "resource",
9253 format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
9254 );
9255 return block_wave(jobs, state, node, attempt, &why);
9256 }
9257 }
9258 } else {
9259 None
9260 }
9261 } else {
9262 None
9263 };
9264 state.seats_spawning(node, attempt);
9267 if let Err(e) = state.save() {
9268 tracing::warn!("could not persist the start of a wave: {e:#}");
9269 }
9270 let slot_limit = resource_limit
9274 + jobs
9275 .iter()
9276 .map(|j| j.timeout)
9277 .max()
9278 .unwrap_or_default()
9279 .saturating_mul(jobs.len() as u32);
9280 let mut set = tokio::task::JoinSet::new();
9281 let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
9284 let overlay = prompts.overlay(node);
9285 let repository_wait = repository_wait(state, pauses);
9286 for (i, mut job) in jobs.into_iter().enumerate() {
9287 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
9288 if cache.is_some() {
9289 job.prompt.push('\n');
9290 job.prompt
9291 .push_str(&prompt::build_cache_note(node, job.allow_write));
9292 }
9293 let sem = Arc::clone(&sem);
9294 let run = run.to_owned();
9295 let node = node.to_owned();
9296 let attachments = if node == "implement" {
9299 state.attachments.clone()
9300 } else {
9301 Vec::new()
9302 };
9303 let cache = cache
9314 .filter(|_| job.allow_write && cache_guard.is_some())
9315 .map(Path::to_path_buf);
9316 let spawned_tx = spawned_tx.clone();
9317 let repository_wait = repository_wait.clone();
9318 let coordination = state.config.coordination.clone();
9319 set.spawn(async move {
9320 let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9324 Ok(p) => p,
9325 Err(_) => {
9326 return (
9327 i,
9328 job.seat,
9329 AgentOutcome::ResourceBlocked(format!(
9330 "no parallel slot became free within {}s",
9331 slot_limit.as_secs()
9332 )),
9333 );
9334 }
9335 };
9336 spawned_tx.send(job.seat.key.clone()).ok();
9337 let mut seat = job.seat;
9338 let out = repository_wait
9339 .scope(agent::invoke(
9340 &job.spec,
9341 &mut seat,
9342 &Invocation {
9343 coordination: coordination.as_ref(),
9344 cwd: &job.cwd,
9345 prompt: &job.prompt,
9346 timeout: job.timeout,
9347 allow_write: job.allow_write,
9348 unsandboxed: false,
9349 sessions: job.sessions,
9350 artifacts: &job.artifacts,
9351 stem: &job.stem,
9352 run: &run,
9353 node: &node,
9354 cache_dir: cache.as_deref(),
9355 attachments: &attachments,
9356 writable: &[],
9357 },
9358 ))
9359 .await;
9360 let out = match out {
9361 Ok(o) if o.usable() => AgentOutcome::Ok(o),
9362 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9363 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9371 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9372 Ok(o) => AgentOutcome::Failed(format!(
9373 "process exited with no usable output ({})",
9374 crate::notices::exit_status(o.exit_code)
9375 )),
9376 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
9377 AgentOutcome::ResourceBlocked(e.to_string())
9378 }
9379 Err(e) => AgentOutcome::Failed(e.to_string()),
9380 };
9381 (i, seat, out)
9382 });
9383 }
9384 drop(spawned_tx);
9385 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9386 loop {
9387 let joined = tokio::select! {
9388 Some(key) = spawned_rx.recv() => {
9389 state.seat_spawned(&key);
9391 if let Err(e) = state.save() {
9392 tracing::warn!("could not persist a seat's start: {e:#}");
9393 }
9394 continue;
9395 }
9396 joined = set.join_next() => joined,
9397 };
9398 let Some(joined) = joined else {
9399 break;
9400 };
9401 let (i, seat, out) = match joined {
9402 Ok(v) => v,
9403 Err(e) => {
9407 tracing::error!("agent task panicked: {e}");
9408 continue;
9409 }
9410 };
9411 state.seat_finished(&seat.key);
9412 if let AgentOutcome::ResourceBlocked(why) = &out
9413 && state.resource_block.is_none()
9414 {
9415 state.resource_block = Some(crate::run::ResourceBlock {
9416 node: node.to_owned(),
9417 kind: if why.contains("repository coordination") {
9418 "repository coordination"
9419 } else {
9420 "parallel slot"
9421 }
9422 .to_owned(),
9423 detail: why.clone(),
9424 at: Timestamp::now(),
9425 });
9426 state.event("resource", format!("{node}: no agent was started - {why}"));
9427 }
9428 record_jobs(state, node, round, &seat.key, &out);
9429 if let Err(e) = state.save() {
9430 tracing::warn!("could not persist a seat's completion: {e:#}");
9431 }
9432 if collected.len() <= i {
9433 collected.resize_with(i + 1, || None);
9434 }
9435 collected[i] = Some((i, seat, out));
9436 }
9437 if state
9443 .active
9444 .values()
9445 .any(|a| a.node == node && a.attempt == attempt)
9446 {
9447 state
9448 .active
9449 .retain(|_, a| !(a.node == node && a.attempt == attempt));
9450 if let Err(e) = state.save() {
9451 tracing::warn!("could not persist the end of a wave: {e:#}");
9452 }
9453 }
9454 if let Some(cache_dir) = cache
9461 && jobs_had_a_writer
9462 {
9463 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9464 }
9465 if let Some(guard) = cache_guard {
9466 guard.release();
9467 }
9468 collected.into_iter().flatten().collect()
9469}
9470
9471fn record_jobs(
9482 state: &mut RunState,
9483 node: &str,
9484 round: Option<usize>,
9485 seat: &str,
9486 out: &AgentOutcome,
9487) {
9488 let commands: &[agent::CommandEvidence] = match out {
9489 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9490 AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9491 };
9492 let checked_at = Timestamp::now();
9493 for c in commands {
9494 state.jobs.push(JobRecord {
9495 node: node.to_owned(),
9496 round,
9497 seat: seat.to_owned(),
9498 id: c.id.clone(),
9499 description: c.description.clone(),
9500 checked_at,
9501 status: match c.exit_code {
9502 Some(0) => JobStatus::Completed,
9503 Some(_) => JobStatus::Failed,
9504 None => JobStatus::Unknown,
9505 },
9506 exit_code: c.exit_code,
9507 result_summary: c.result_summary.clone(),
9508 source: c.source.clone(),
9509 });
9510 }
9511}
9512
9513fn round_is_clean(
9534 blocking: usize,
9535 e2e_ok: bool,
9536 answered: usize,
9537 expected: usize,
9538 quota_missing: usize,
9539 policy: IncompleteReviewPolicy,
9540) -> bool {
9541 if blocking != 0 || !e2e_ok {
9542 return false;
9543 }
9544 if answered == expected || policy == IncompleteReviewPolicy::Warn {
9545 return true;
9546 }
9547 answered > 0 && expected - answered <= quota_missing
9548}
9549
9550fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9574 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9575 return Some(RunStatus::Gating);
9576 }
9577 let last = reviews.last()?;
9578 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9579 if reviews.len() < max_rounds && !stagnant {
9580 return None;
9581 }
9582 if last.incomplete() && last.blocking == 0 {
9583 return Some(RunStatus::Blocked);
9584 }
9585 if last.e2e_status() == E2eStatus::ResourceBlocked {
9586 return None;
9587 }
9588 Some(if last.e2e.iter().all(CommandOutcome::ok) {
9589 RunStatus::Gating
9590 } else {
9591 RunStatus::Blocked
9592 })
9593}
9594
9595pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9610 if nudged {
9611 (full / 4).max(Duration::from_secs(120)).min(full)
9612 } else {
9613 full
9614 }
9615}
9616
9617#[allow(clippy::too_many_arguments)]
9646async fn ask_json_wave<T>(
9647 jobs: Vec<SeatJob>,
9648 sem: Arc<Semaphore>,
9649 retries: usize,
9650 roster: &[AgentSpec],
9651 ctx: &WaveCtx<'_>,
9652 losses: &mut Vec<QuotaLoss>,
9653 state: &mut RunState,
9654 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9655) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9656where
9657 T: serde::de::DeserializeOwned + Send + 'static,
9658{
9659 ask_wave_with(
9660 jobs,
9661 sem,
9662 retries,
9663 roster,
9664 ctx,
9665 losses,
9666 state,
9667 &|text: &str| {
9668 let v = verdict::extract_json::<T>(text)?;
9669 validate(&v)?;
9670 Ok(v)
9671 },
9672 )
9673 .await
9674}
9675
9676#[allow(clippy::too_many_arguments)]
9685async fn ask_wave_with<T>(
9686 jobs: Vec<SeatJob>,
9687 sem: Arc<Semaphore>,
9688 retries: usize,
9689 roster: &[AgentSpec],
9690 ctx: &WaveCtx<'_>,
9691 losses: &mut Vec<QuotaLoss>,
9692 state: &mut RunState,
9693 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9694) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9695where
9696 T: Send + 'static,
9697{
9698 let n = jobs.len();
9699 let originals: Vec<SeatJob> = jobs;
9700 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9701 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9702 let mut nudges: Vec<usize> = vec![0; n];
9709 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9713 let mut tried: Vec<BTreeSet<String>> = specs
9714 .iter()
9715 .map(|s| BTreeSet::from([s.id.clone()]))
9716 .collect();
9717 let starts: Vec<usize> = specs
9718 .iter()
9719 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9720 .collect();
9721 let carry = ctx.carry_seats && !roster.is_empty();
9722 let carried: Vec<BTreeSet<String>> = originals
9725 .iter()
9726 .map(|j| {
9727 state
9728 .seat_history
9729 .get(&j.seat.key)
9730 .filter(|_| carry)
9731 .map(|h| h.failed.clone())
9732 .unwrap_or_default()
9733 })
9734 .collect();
9735 let mut prev: Vec<Option<FailClass>> = originals
9736 .iter()
9737 .map(|j| {
9738 state
9739 .seat_history
9740 .get(&j.seat.key)
9741 .filter(|_| carry)
9742 .and_then(|h| h.last_fail.clone())
9743 })
9744 .collect();
9745 let next_agent =
9746 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9747 let others: BTreeSet<String> = specs
9748 .iter()
9749 .enumerate()
9750 .filter(|(j, _)| *j != i)
9751 .map(|(_, s)| s.id.clone())
9752 .collect();
9753 pick_successor(
9754 roster,
9755 starts[i],
9756 tried,
9757 carry.then(|| &carried[i]),
9758 &others,
9759 )
9760 .cloned()
9761 };
9762 let mut fresh: Vec<Option<String>> = vec![None; n];
9763 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9764 let mut pending: Vec<usize> = (0..n).collect();
9765
9766 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9771 for round in 0..max_rounds {
9772 if pending.is_empty() {
9773 break;
9774 }
9775 let mut batch = Vec::with_capacity(pending.len());
9776 let mut renudged: Vec<&str> = Vec::new();
9777 for &i in &pending {
9778 let src = &originals[i];
9779 let full: &str = match &src.handover {
9784 Some(h) if specs[i].id != src.spec.id => h,
9785 _ => &src.prompt,
9786 };
9787 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9790 (
9791 full.to_owned(),
9792 src.timeout,
9793 format!("{}-{word}-{}", src.stem, specs[i].id),
9794 )
9795 } else if nudges[i] == 0 {
9796 (full.to_owned(), src.timeout, src.stem.clone())
9797 } else {
9798 renudged.push(src.seat.key.as_str());
9799 let why = done[i]
9800 .as_ref()
9801 .and_then(|r| r.as_ref().err().map(ToString::to_string))
9802 .unwrap_or_else(|| "no parsable answer".to_owned());
9803 let nudge = prompt::nudge(&why);
9804 let nudged = has_context(&specs[i], &seats[i], src.sessions);
9805 let prompt = if nudged {
9806 nudge
9807 } else {
9808 format!("{full}\n\n---\n\n{nudge}")
9809 };
9810 (
9811 prompt,
9812 retry_budget(src.timeout, nudged),
9813 format!("{}-retry{}", src.stem, nudges[i]),
9814 )
9815 };
9816 batch.push(SeatJob {
9817 spec: specs[i].clone(),
9818 seat: seats[i].clone(),
9819 cwd: src.cwd.clone(),
9820 prompt,
9821 timeout,
9822 allow_write: src.allow_write,
9823 sessions: src.sessions,
9824 artifacts: src.artifacts.clone(),
9825 stem,
9826 handover: None,
9827 });
9828 }
9829
9830 if !renudged.is_empty() {
9831 state.event(
9832 ctx.node,
9833 format!("retry {round}: re-asking {}", renudged.join(", ")),
9834 );
9835 }
9836 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9837 let mut still = Vec::new();
9838 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9839 seats[i] = seat;
9840 if let AgentOutcome::ResourceBlocked(why) = &out {
9843 done[i] = Some(Err(anyhow::anyhow!("{why}")));
9844 continue;
9845 }
9846 let class = FailClass::of(&out);
9847 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9850 && nudges[i] < retries
9851 && !roster.is_empty();
9852 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9853 let next = should_hand_over(prev[i].as_ref(), &cur)
9854 .then(|| next_agent(i, &tried[i], &specs))
9855 .flatten();
9856 if carry {
9857 let h = state
9858 .seat_history
9859 .entry(originals[i].seat.key.clone())
9860 .or_default();
9861 h.failed.insert(specs[i].id.clone());
9862 h.last_fail = Some(cur.clone());
9863 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9864 h.last_ok = None;
9865 }
9866 if let Err(e) = state.save() {
9869 tracing::warn!("could not persist a seat's failure history: {e:#}");
9870 }
9871 }
9872 if let Some(next) = next {
9873 record_handover(
9874 state,
9875 ctx.node,
9876 &originals[i].seat.key,
9877 &specs[i].id,
9878 &next.id,
9879 &cur,
9880 &fail_reason(&out),
9881 );
9882 tried[i].insert(next.id.clone());
9883 prev[i] = Some(cur.clone());
9884 seats[i] =
9885 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9886 specs[i] = next;
9887 fresh[i] = Some(cur.stem_word().to_owned());
9888 nudges[i] = 0;
9889 done[i] = Some(Err(anyhow::anyhow!(
9890 "handed over after: {}",
9891 fail_reason(&out)
9892 )));
9893 still.push(i);
9894 continue;
9895 }
9896 }
9897 if carry
9898 && !nudge_first
9899 && let Some(cur) = class.clone()
9900 {
9901 let h = state
9903 .seat_history
9904 .entry(originals[i].seat.key.clone())
9905 .or_default();
9906 h.failed.insert(specs[i].id.clone());
9907 h.last_fail = Some(cur);
9908 }
9909 let parsed = match out {
9910 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9911 AgentOutcome::Quota(o) => {
9912 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9913 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9914 }
9915 AgentOutcome::Dropped(o) => {
9919 let why = o
9920 .dropped
9921 .as_ref()
9922 .map(|d| d.why.as_str())
9923 .unwrap_or("the CLI ended the stream without delivering its answer");
9924 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9925 }
9926 AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9927 Err(anyhow::anyhow!(e))
9928 }
9929 };
9930 let quota = class == Some(FailClass::Quota);
9931 let failed = parsed.is_err();
9932 done[i] = Some(parsed);
9933 if carry && !failed {
9934 let h = state
9935 .seat_history
9936 .entry(originals[i].seat.key.clone())
9937 .or_default();
9938 h.failed.remove(&specs[i].id);
9939 h.last_ok = Some(specs[i].id.clone());
9940 h.last_fail = None;
9941 }
9942 let agent_failure = class.is_some()
9950 && !nudge_first
9951 && !roster.is_empty()
9952 && next_agent(i, &tried[i], &specs).is_some();
9953 if failed && !quota && !agent_failure && nudges[i] < retries {
9954 nudges[i] += 1;
9955 still.push(i);
9956 }
9957 }
9958 pending = still;
9959 }
9960
9961 for (i, q) in last_quota.into_iter().enumerate() {
9964 if let Some(reset) = q {
9965 losses.push(QuotaLoss {
9966 seat: originals[i].seat.key.clone(),
9967 node: ctx.node.to_owned(),
9968 at: Timestamp::now(),
9969 reset,
9970 });
9971 }
9972 }
9973
9974 seats
9975 .into_iter()
9976 .zip(done)
9977 .zip(nudges)
9978 .map(|((seat, res), attempts)| {
9979 (
9980 seat,
9981 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9982 attempts,
9983 )
9984 })
9985 .collect()
9986}
9987
9988fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9990 results
9991 .iter()
9992 .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9993}
9994
9995fn block_wave(
9999 jobs: Vec<SeatJob>,
10000 state: &mut RunState,
10001 node: &str,
10002 attempt: usize,
10003 why: &str,
10004) -> Vec<(usize, SeatState, AgentOutcome)> {
10005 state.resource_wait = None;
10006 state
10007 .active
10008 .retain(|_, a| !(a.node == node && a.attempt == attempt));
10009 if let Err(e) = state.save() {
10010 tracing::warn!("could not persist a resource-blocked wave: {e:#}");
10011 }
10012 jobs.into_iter()
10013 .enumerate()
10014 .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
10015 .collect()
10016}
10017
10018async fn acquire_cache_lease(
10031 state: &mut RunState,
10032 cache_dir: &Path,
10033 owner: &crate::cache::Owner,
10034 budget: Duration,
10035 context: &str,
10036 cancel: &(dyn Fn() -> bool + Sync),
10037) -> Result<Option<crate::cache::Guard>> {
10038 let home = crate::run::home();
10039 let started = Instant::now();
10040 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
10041 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
10042 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
10043 Err(e) => {
10044 state.event(
10045 "verify",
10046 format!("{context}: could not check the shared build cache: {e:#}"),
10047 );
10048 if let Err(e2) = state.save() {
10049 tracing::warn!("could not persist a cache-check failure: {e2:#}");
10050 }
10051 return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
10052 }
10053 };
10054 state.event(
10055 "verify",
10056 format!(
10057 "{context}: waiting for the shared build cache at {} ({})",
10058 cache_dir.display(),
10059 busy.describe()
10060 ),
10061 );
10062 state.resource_wait = Some(crate::run::ResourceWait {
10065 node: context.to_owned(),
10066 kind: "shared build cache".to_owned(),
10067 holder: busy.describe(),
10068 since: Timestamp::now(),
10069 limit_secs: budget.as_secs(),
10070 });
10071 if let Err(e) = state.save() {
10072 tracing::warn!("could not persist a cache wait: {e:#}");
10073 }
10074 let remaining = budget.saturating_sub(started.elapsed());
10075 let waited = crate::cache::wait_for_unless(
10076 &home,
10077 cache_dir,
10078 owner,
10079 remaining,
10080 Duration::from_secs(5),
10081 cancel,
10082 )
10083 .await;
10084 state.resource_wait = None;
10085 match waited {
10086 Ok(g) => Ok(g),
10087 Err(e) => {
10088 state.event("verify", format!("{context}: {e:#}"));
10089 if let Err(e2) = state.save() {
10090 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
10091 }
10092 Err(e)
10093 }
10094 }
10095}
10096
10097#[derive(Debug)]
10102struct CacheCheckError(String);
10103
10104impl std::fmt::Display for CacheCheckError {
10105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10106 f.write_str(&self.0)
10107 }
10108}
10109
10110impl std::error::Error for CacheCheckError {}
10111
10112#[allow(clippy::too_many_arguments)]
10133async fn with_cache_lease<'s, F, Fut>(
10134 state: &'s mut RunState,
10135 cache_dir: Option<&Path>,
10136 node: &str,
10137 seat: &str,
10138 worktree: &Path,
10139 head: &str,
10140 budget: Duration,
10141 context: &str,
10142 wait: &crate::repo_lock::WaitPolicy,
10143 body: F,
10144) -> Result<(Vec<CommandOutcome>, bool)>
10145where
10146 F: FnOnce(&'s mut RunState, Duration) -> Fut,
10147 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
10148{
10149 let started = Instant::now();
10152 let home = crate::run::home();
10153 let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
10154 let guard = if let Some(cache_dir) = cache_dir {
10155 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
10156 match acquire_cache_lease(state, cache_dir, &owner, resource_limit, context, &|| {
10157 wait.cancelled()
10158 })
10159 .await
10160 .and_then(|g| {
10161 g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
10162 }) {
10163 Ok(g) => Some(g),
10164 Err(e) => {
10165 return Ok((
10166 vec![CommandOutcome {
10167 command: "(waiting for the shared build cache)".into(),
10168 code: None,
10169 output_tail: e.to_string(),
10170 duration_ms: started.elapsed().as_millis() as u64,
10171 resource_blocked: true,
10172 }],
10173 false,
10174 ));
10175 }
10176 }
10177 } else {
10178 None
10179 };
10180 let _coordination = coordinate_operation(state, worktree, wait).await?;
10181 if let Some(cache_dir) = cache_dir {
10182 let identity = crate::cache::Identity::new(worktree, head);
10183 if let Err(e) = crate::cache::ensure_fresh(
10184 &home,
10185 cache_dir,
10186 &identity,
10187 &state.config.verify.cargo_manifests(),
10188 ) {
10189 state.event(
10190 "verify",
10191 format!(
10192 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
10193 worktree.display(),
10194 short(head)
10195 ),
10196 );
10197 if let Some(guard) = guard {
10198 guard.release();
10199 }
10200 return Ok((
10201 vec![CommandOutcome {
10202 command: "(confirming the shared build cache is fresh)".into(),
10203 code: None,
10204 output_tail: e.to_string(),
10205 duration_ms: started.elapsed().as_millis() as u64,
10206 resource_blocked: true,
10207 }],
10208 false,
10209 ));
10210 }
10211 }
10212 let (outcomes, retried, timed_out_pids) = body(state, budget).await;
10213 if !timed_out_pids.is_empty() {
10214 wait_for_timed_out_children_to_die(&timed_out_pids).await;
10215 }
10216 drop(_coordination);
10219 if let Some(guard) = guard {
10220 guard.release();
10221 }
10222 Ok((outcomes, retried))
10223}
10224
10225async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
10237 wait_for_pids_with(
10238 pids,
10239 crate::proc::pid_alive,
10240 LEASE_RELEASE_POLL,
10241 LEASE_RELEASE_MAX_WAIT,
10242 )
10243 .await;
10244}
10245
10246async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10252 pids: &[u32],
10253 alive: F,
10254 poll: Duration,
10255 max_wait: Duration,
10256) {
10257 let deadline = Instant::now() + max_wait;
10258 loop {
10259 if pids.iter().all(|&pid| !alive(pid)) {
10260 return;
10261 }
10262 if Instant::now() >= deadline {
10263 return;
10264 }
10265 tokio::time::sleep(poll).await;
10266 }
10267}
10268
10269fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10276 outcomes.iter().any(|o| o.resource_blocked)
10277}
10278
10279enum GateFix {
10281 Retry,
10283 Stop,
10286 Defer,
10289}
10290
10291fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10299 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10300 red.peek().is_some()
10301 && red.all(|o| {
10302 !o.resource_blocked
10303 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10304 && !o.output_tail.trim().is_empty()
10305 })
10306}
10307
10308fn e2e_outcome_label(o: &CommandOutcome) -> String {
10312 if o.ok() {
10313 return "pass".to_owned();
10314 }
10315 let reason = if o.build_failed() {
10316 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10317 } else {
10318 format!("FAIL ({:?})", o.code)
10319 };
10320 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10321}
10322
10323async fn run_e2e_with_retry(
10331 state: &mut RunState,
10332 shell: &[String],
10333 commands: &[String],
10334 worktree: &Path,
10335 timeout: Duration,
10336 context: &str,
10337) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10338 let (mut e2e, mut timed_out_pids) = run_commands(
10339 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10340 )
10341 .await;
10342 for o in &e2e {
10343 state.event(
10344 "verify",
10345 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10346 );
10347 }
10348 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10352 if verify_retried {
10353 state.event(
10354 "verify",
10355 format!(
10356 "{context}: verify could not build/link, not a test result — retrying once \
10357 before concluding"
10358 ),
10359 );
10360 let retried = run_commands(
10361 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10362 )
10363 .await;
10364 e2e = retried.0;
10365 timed_out_pids.extend(retried.1);
10368 for o in &e2e {
10369 state.event(
10370 "verify",
10371 format!(
10372 "{context}: retry `{}` -> {}",
10373 o.command,
10374 e2e_outcome_label(o)
10375 ),
10376 );
10377 }
10378 }
10379 (e2e, verify_retried, timed_out_pids)
10380}
10381
10382#[allow(clippy::too_many_arguments)]
10398async fn run_commands(
10399 state: &mut RunState,
10400 node: &str,
10401 task: &str,
10402 attempt: usize,
10403 shell: &[String],
10404 commands: &[String],
10405 cwd: &Path,
10406 timeout: Duration,
10407) -> (Vec<CommandOutcome>, Vec<u32>) {
10408 if commands.is_empty() {
10409 return (Vec::new(), Vec::new());
10414 }
10415 let mut out = Vec::new();
10416 let mut timed_out_pids = Vec::new();
10417 let total = commands.len();
10418 for (idx, command) in commands.iter().enumerate() {
10419 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10420 if let Err(e) = state.save() {
10421 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10422 }
10423 let started = Instant::now();
10424 let mut cmd = tokio::process::Command::new(&shell[0]);
10425 cmd.quiet();
10426 cmd.args(&shell[1..])
10427 .arg(command)
10428 .current_dir(cwd)
10429 .stdin(std::process::Stdio::null())
10430 .stdout(std::process::Stdio::piped())
10431 .stderr(std::process::Stdio::piped())
10432 .kill_on_drop(true);
10433 let spawned = cmd.spawn();
10434 let (code, body) = match spawned {
10435 Ok(child) => {
10436 let pid = child.id();
10441 match tokio::time::timeout(timeout, child.wait_with_output()).await {
10442 Ok(Ok(o)) => {
10443 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10444 body.push_str(&String::from_utf8_lossy(&o.stderr));
10445 (o.status.code(), body)
10446 }
10447 Ok(Err(e)) => (None, format!("failed to run: {e}")),
10448 Err(_) => {
10449 if let Some(pid) = pid {
10450 timed_out_pids.push(pid);
10451 }
10452 (None, format!("timed out after {}s", timeout.as_secs()))
10453 }
10454 }
10455 }
10456 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10457 };
10458 out.push(CommandOutcome {
10459 command: command.clone(),
10460 code,
10461 output_tail: tail(&body, OUTPUT_TAIL),
10462 duration_ms: started.elapsed().as_millis() as u64,
10463 resource_blocked: false,
10464 });
10465 }
10466 state.task_finished(task);
10467 if let Err(e) = state.save() {
10468 tracing::warn!("could not persist the end of {task}: {e:#}");
10469 }
10470 (out, timed_out_pids)
10471}
10472
10473fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10485 let repo = repo.display();
10486 match style {
10487 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10488 MergeStyle::Squash => {
10489 let subject = message
10492 .lines()
10493 .next()
10494 .unwrap_or(branch)
10495 .replace(['\\', '"', '$', '`'], "");
10496 format!(
10497 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10498 )
10499 }
10500 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10501 }
10502}
10503
10504const PR_TITLE_MAX: usize = 240;
10515
10516fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10527 let line = queue::first_line(text)?;
10528 let chars: Vec<char> = line.chars().collect();
10529 let (kept, mark) = if chars.len() <= max {
10530 (line.to_owned(), "")
10531 } else if let Some(end) = sentence_end(&chars, max) {
10532 (chars[..end].iter().collect::<String>(), "")
10533 } else {
10534 let room = max.saturating_sub(3);
10535 let cut = if chars[room].is_whitespace() {
10538 room
10539 } else {
10540 chars[..room]
10541 .iter()
10542 .rposition(|c| c.is_whitespace())
10543 .unwrap_or(room)
10544 };
10545 let head: String = chars[..cut].iter().collect();
10546 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10547 (head.to_owned(), "...")
10548 };
10549 if kept.is_empty() {
10550 return None;
10551 }
10552 if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10553 return None;
10554 }
10555 if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10556 return None;
10557 }
10558 Some(format!("{kept}{mark}"))
10559}
10560
10561fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10565 const MIN: usize = 20;
10566 for i in MIN..max.min(chars.len()) {
10567 if !matches!(chars[i], '.' | '!' | '?') {
10568 continue;
10569 }
10570 let Some(&next) = chars.get(i + 1) else {
10571 continue;
10572 };
10573 if !next.is_whitespace() {
10574 continue;
10575 }
10576 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10577 if after.is_some_and(|c| c.is_ascii_lowercase()) {
10578 continue;
10579 }
10580 let word: String = chars[..i]
10581 .iter()
10582 .rev()
10583 .take_while(|c| !c.is_whitespace())
10584 .collect::<Vec<_>>()
10585 .into_iter()
10586 .rev()
10587 .collect();
10588 let word = word.to_ascii_lowercase();
10589 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10590 continue;
10591 }
10592 let end = chars[..i]
10593 .iter()
10594 .rposition(|c| !c.is_whitespace())
10595 .map_or(i, |p| p + 1);
10596 return Some(end);
10597 }
10598 None
10599}
10600
10601struct PrMessage {
10606 title: String,
10607 body: String,
10608}
10609
10610impl PrMessage {
10611 fn commit_message(&self) -> String {
10615 format!("{}\n\n{}", self.title, self.body)
10616 }
10617}
10618
10619fn title_marker(line: &str) -> Option<&str> {
10621 let line = line.trim();
10622 let head = line.get(..6)?;
10623 head.eq_ignore_ascii_case("title:")
10624 .then(|| line[6..].trim())
10625}
10626
10627fn summary_title(summary: &str) -> Option<String> {
10632 let first = summary.lines().find(|l| !l.trim().is_empty())?;
10633 let raw = title_marker(first)?;
10634 if raw.is_empty() {
10635 return None;
10636 }
10637 let title = queue::title_from(raw, PR_TITLE_MAX);
10638 let lower = title.to_ascii_lowercase();
10639 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10640 return None;
10641 }
10642 Some(title)
10643}
10644
10645const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10647
10648pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10650
10651fn is_review_run(state: &RunState) -> bool {
10655 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10656}
10657
10658fn review_title(state: &RunState) -> Option<String> {
10663 english_subject(
10664 state.reviewed_commits.as_ref()?.first()?,
10665 state.config.graph.github_english(),
10666 )
10667}
10668
10669fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10672 let raw = raw.trim();
10673 if raw.is_empty() {
10674 return None;
10675 }
10676 if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10677 return None;
10678 }
10679 if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10680 return None;
10681 }
10682 let title = queue::title_from(raw, PR_TITLE_MAX);
10683 let lower = title.to_ascii_lowercase();
10684 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10685 return None;
10686 }
10687 Some(title)
10688}
10689
10690#[derive(Debug, Clone, PartialEq, Eq)]
10693struct BranchFacts {
10694 commits: Vec<(String, String)>,
10696 stat: String,
10698}
10699
10700const STAT_FILE_LINES: usize = 25;
10702const COMMITS_MAX_CHARS: usize = 20_000;
10704
10705async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10708 let commits = git::commit_log(repo, base, branch).await.ok()?;
10709 if commits.is_empty() {
10710 return None;
10711 }
10712 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10713 let lines: Vec<&str> = stat.lines().collect();
10714 let stat = if lines.len() > STAT_FILE_LINES + 1 {
10715 let omitted = lines.len() - 1 - STAT_FILE_LINES;
10716 let more = format!(" ... {omitted} more file(s)");
10717 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10718 kept.push(&more);
10719 kept.push(lines[lines.len() - 1]);
10720 kept.join("\n")
10721 } else {
10722 lines.join("\n")
10723 };
10724 Some(BranchFacts { commits, stat })
10725}
10726
10727fn markdown_safe(text: &str) -> String {
10730 text.replace("</details>", "</details>")
10731 .replace("\x60\x60\x60", "~~~")
10732}
10733
10734fn neutral_title(state: &RunState, winner: char) -> String {
10735 format!(
10736 "chore: land candidate {} of run {}",
10737 winner.to_ascii_uppercase(),
10738 state.id
10739 )
10740}
10741
10742pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10748 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10749 ""
10750 } else {
10751 pr_title
10752 }
10753}
10754
10755pub fn landing_subject_source(state: &RunState) -> String {
10759 if is_review_run(state) {
10760 let winner = state.candidates.first().map_or('A', |c| c.label);
10761 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10762 }
10763 state.instruction.clone()
10764}
10765
10766fn summary_without_title(summary: &str) -> String {
10769 let mut lines = summary.trim().lines().peekable();
10770 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10771 lines.next();
10772 }
10773 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10774}
10775
10776#[cfg(test)]
10788fn pr_message(state: &RunState, winner: char) -> PrMessage {
10789 pr_message_with(state, winner, None)
10790}
10791
10792#[cfg(test)]
10795fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10796 let raw = pr_message_raw(
10797 state,
10798 winner,
10799 facts,
10800 crate::pr_outcome::FinalHead::Unreported,
10801 ChangeNote::NotAsked,
10802 );
10803 let id = crate::scrub::Identity::current();
10804 PrMessage {
10805 title: crate::scrub::scrub(&raw.title, &id),
10806 body: crate::scrub::scrub(&raw.body, &id),
10807 }
10808}
10809
10810#[derive(Debug, Clone, Copy)]
10812enum ChangeNote<'a> {
10813 NotAsked,
10815 Summary(&'a crate::run::ChangeSummary),
10817 Unavailable(&'a str),
10819}
10820
10821fn pr_message_raw(
10822 state: &RunState,
10823 winner: char,
10824 facts: Option<&BranchFacts>,
10825 head: crate::pr_outcome::FinalHead<'_>,
10826 change: ChangeNote<'_>,
10827) -> PrMessage {
10828 if !matches!(change, ChangeNote::NotAsked) {
10829 let (title, mut body) = match change {
10830 ChangeNote::Summary(c) => (
10831 c.title.clone(),
10832 format!("## Summary\n\n{}\n\n", c.body.trim()),
10833 ),
10834 ChangeNote::Unavailable(why) => (
10835 neutral_title(state, winner),
10836 format!(
10837 "## 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"
10838 ),
10839 ),
10840 ChangeNote::NotAsked => unreachable!(),
10841 };
10842 body.push_str(&public_pr_evidence(state, head));
10843 body.push_str(&format!(
10844 "\n---\nmagi:run/{} magi:candidate-{}\n",
10845 state.id,
10846 winner.to_ascii_lowercase()
10847 ));
10848 return PrMessage { title, body };
10849 }
10850 pr_preview(state, winner, facts, head)
10851}
10852
10853fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10856 let mut body = crate::pr_outcome::review_rounds(state);
10857 body.push_str(&crate::pr_outcome::final_verification(state, head));
10858 let open = state.last_round_findings();
10859 if !open.is_empty() {
10860 body.push_str("## Open review findings\n\n");
10861 for f in open {
10862 body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10863 }
10864 body.push('\n');
10865 }
10866 if let Some(c) = state.change_summary.as_ref()
10867 && head == crate::pr_outcome::FinalHead::At(&c.head)
10868 {
10869 body.push_str(&format!(
10870 "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10871 c.base, c.head
10872 ));
10873 }
10874 body
10875}
10876
10877fn pr_preview(
10879 state: &RunState,
10880 winner: char,
10881 facts: Option<&BranchFacts>,
10882 head: crate::pr_outcome::FinalHead<'_>,
10883) -> PrMessage {
10884 let summary = state
10885 .candidates
10886 .iter()
10887 .find(|c| c.label == winner)
10888 .map(|c| c.summary.as_str())
10889 .unwrap_or_default();
10890 let review = is_review_run(state);
10893 let title = if review {
10894 facts
10895 .and_then(|f| {
10896 english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10897 })
10898 .or_else(|| review_title(state))
10899 .or_else(|| {
10900 state
10901 .candidates
10902 .iter()
10903 .find(|c| c.label == winner)
10904 .filter(|c| !c.branch.starts_with("magi/"))
10905 .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10906 })
10907 .unwrap_or_else(|| neutral_title(state, winner))
10908 } else {
10909 summary_title(summary).unwrap_or_else(|| {
10910 english_title(
10911 &state.instruction,
10912 PR_TITLE_MAX,
10913 state.config.graph.github_english(),
10914 )
10915 .unwrap_or_else(|| neutral_title(state, winner))
10916 })
10917 };
10918
10919 let mut body = String::new();
10920 let what = summary_without_title(summary);
10921 if !what.is_empty() {
10922 body.push_str("## Summary\n\n");
10923 body.push_str(&what);
10924 body.push_str("\n\n");
10925 }
10926
10927 body.push_str(&crate::pr_outcome::review_rounds(state));
10929 body.push_str(&crate::pr_outcome::final_verification(state, head));
10930 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10931
10932 let open = state.open_findings();
10933 if !open.is_empty() {
10934 body.push_str("## Open review findings\n\n");
10935 for f in &open {
10936 body.push_str(&format!(
10937 "- `{}` [{:?}] {}\n",
10938 f.id, f.severity, "Context retained in local review records."
10939 ));
10940 }
10941 body.push('\n');
10942 }
10943
10944 if let Some(fix) = fix
10945 && !fix.rejected.is_empty()
10946 {
10947 body.push_str("## Declined by the fixer\n\n");
10948 for r in &fix.rejected {
10949 body.push_str(&format!(
10950 "- `{}`: {}\n",
10951 r.id, "Reason retained in local review records."
10952 ));
10953 }
10954 body.push('\n');
10955 }
10956
10957 if review {
10958 body.push_str("## Commits under review\n\n");
10960 if let Some(facts) = facts {
10961 let mut left = COMMITS_MAX_CHARS;
10962 for (i, (subject, text)) in facts.commits.iter().enumerate() {
10963 let mut entry = format!("- {}\n", markdown_safe(subject));
10964 for l in markdown_safe(text).lines() {
10965 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
10966 }
10967 if left == 0 {
10968 body.push_str(&format!(
10969 "- ... {} more commit(s)\n",
10970 facts.commits.len() - i
10971 ));
10972 break;
10973 }
10974 if entry.len() > left {
10975 let mut end = left;
10978 while !entry.is_char_boundary(end) {
10979 end -= 1;
10980 }
10981 entry.truncate(end);
10982 entry.push_str("\n ... (truncated)\n");
10983 left = 0;
10984 } else {
10985 left -= entry.len();
10986 }
10987 body.push_str(&entry);
10988 }
10989 if !facts.stat.trim().is_empty() {
10990 body.push_str(&format!(
10991 "\n## Diff stat\n\n```\n{}\n```\n",
10992 markdown_safe(facts.stat.trim())
10993 ));
10994 }
10995 } else {
10996 match &state.reviewed_commits {
10997 Some(subjects) => {
10998 for s in subjects {
10999 body.push_str(&format!("- {}\n", s.trim()));
11000 }
11001 }
11002 None => {
11003 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
11006 body.push_str(rest.trim());
11007 body.push('\n');
11008 }
11009 }
11010 }
11011 } else {
11012 let task = state.instruction.trim();
11013 let task = if task.is_empty() {
11014 "(empty task)"
11015 } else {
11016 task
11017 };
11018 body.push_str(&format!(
11019 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
11020 task.replace("</details>", "</details>")
11021 ));
11022 }
11023
11024 body.push_str(&format!(
11025 "\n---\nmagi:run/{} magi:candidate-{}\n",
11026 state.id,
11027 winner.to_ascii_lowercase()
11028 ));
11029
11030 PrMessage { title, body }
11031}
11032
11033fn seeded_instruction(state: &RunState) -> String {
11037 match refs::describe(&state.seeds) {
11038 Some(facts) => format!(
11039 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
11040 Candidates start from the unmerged branch named above, when there \
11041 is one, and carry any unmerged commit named by sha as a \
11042 cherry-pick. Check that this is what the task meant before \
11043 building on it.",
11044 state.instruction
11045 ),
11046 None => state.instruction.clone(),
11047 }
11048}
11049
11050async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
11054 let base = &state.base_branch;
11055 let mut against = base.clone();
11056 if mode == MergeMode::Pr {
11057 let remote = &state.config.merge.remote;
11061 let tracking = format!("{remote}/{base}");
11062 let fetched = git::fetch(repo, remote, base).await;
11063 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
11064 against = tracking;
11065 } else {
11066 return false;
11067 }
11068 }
11069 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
11070}
11071
11072fn empty_candidate_detail(state: &RunState, base: &str) -> String {
11075 let mut detail = format!(
11076 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
11077 nothing to open a pull request for"
11078 );
11079 match refs::describe(&state.seeds) {
11080 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
11081 None => detail.push_str(
11082 "\nThe task names no existing branch or commit; if it means to land work \
11083 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
11084 ),
11085 }
11086 detail
11087}
11088
11089#[derive(Debug, PartialEq, Eq)]
11092enum PrPlan {
11093 Create,
11094 Adopt { url: String, title: String },
11095 Stop(String),
11096}
11097
11098fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
11101 match found {
11102 Ok(land::OpenPr::None) => PrPlan::Create,
11103 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
11104 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
11105 "several open pull requests exist for this branch, not picking one: {}",
11106 urls.join(" ")
11107 )),
11108 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
11109 }
11110}
11111
11112pub(crate) async fn gh_pr_create(
11114 cwd: &Path,
11115 base: &str,
11116 head: &str,
11117 title: &str,
11118 body: &str,
11119) -> Result<String> {
11120 let out = tokio::process::Command::new("gh")
11121 .args([
11122 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
11123 ])
11124 .current_dir(cwd)
11125 .quiet()
11126 .stdin(std::process::Stdio::null())
11127 .output()
11128 .await
11129 .context("spawn gh")?;
11130 if out.status.success() {
11131 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
11132 } else {
11133 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
11134 }
11135}
11136
11137async fn remove_preserved_worktree(repo: &Path, path: &Path) -> Result<()> {
11139 match std::fs::symlink_metadata(path) {
11140 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
11141 git::worktree_remove_clean(repo, path).await?;
11144 return Ok(());
11145 }
11146 Err(e) => return Err(e.into()),
11147 Ok(_) => {}
11148 }
11149 if !git::is_clean(path).await? {
11150 bail!(
11151 "worktree has uncommitted work; refusing removal: {}",
11152 path.display()
11153 );
11154 }
11155 if !git::worktree_remove_clean(repo, path).await? {
11156 bail!("worktree removal refused: {}", path.display());
11157 }
11158 Ok(())
11159}
11160
11161pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
11170 if crate::pr_recovery::sources_retained(home, &state.id) {
11171 return Ok(Vec::new());
11172 }
11173 if !state.candidate_preservation.is_empty() {
11174 bail!("candidate preservation incomplete; refusing cleanup");
11175 }
11176 let repo = state.repo.clone();
11177 let wait = repository_wait(state, None);
11180 let _coordination = match coordinate_operation(state, &repo, &wait).await {
11181 Ok(lease) => lease,
11182 Err(e) => {
11183 state.save_under(home)?;
11184 return Err(e);
11185 }
11186 };
11187 let root = state.worktree_root();
11188 let winner = state.tally.as_ref().map(|t| t.winner);
11189 let mut removed = Vec::new();
11190
11191 for i in 0..state.candidates.len() {
11192 let c = state.candidates[i].clone();
11193 let is_winner = Some(c.label) == winner;
11194 if is_winner && !drop_winner {
11195 continue;
11196 }
11197 let existed = c.worktree.exists();
11198 remove_preserved_worktree(&repo, &c.worktree).await?;
11201 if existed {
11202 removed.push(c.worktree.to_string_lossy().into_owned());
11203 }
11204 let handed_over = state.released_branches.contains(&c.branch);
11207 if !handed_over && git::branch_exists(&repo, &c.branch).await? {
11208 if !git::branch_delete(&repo, &c.branch).await? {
11209 bail!("candidate branch removal refused");
11210 }
11211 removed.push(c.branch.clone());
11212 }
11213 state.candidates[i].folded = true;
11214 }
11215
11216 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
11217 let path = name.path();
11218 let keep = !drop_winner
11219 && winner.is_some_and(|w| {
11220 path.file_name()
11221 .is_some_and(|n| n == format!("cand-{w}").as_str())
11222 });
11223 if keep {
11224 continue;
11225 }
11226 remove_preserved_worktree(&repo, &path).await?;
11227 removed.push(path.to_string_lossy().into_owned());
11228 }
11229
11230 remove_if_empty(&root);
11239
11240 if state.enabled_worktree_config && drop_winner {
11241 git::release_worktree_config(&repo).await.ok();
11245 state.enabled_worktree_config = false;
11246 }
11247 state.save_under(home)?;
11248 Ok(removed)
11249}
11250
11251fn remove_if_empty(dir: &Path) {
11262 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
11263 std::fs::remove_dir(dir).ok();
11264 }
11265}
11266
11267pub fn worst_open(state: &RunState) -> Option<Severity> {
11269 state
11270 .reviews
11271 .last()?
11272 .reviews
11273 .iter()
11274 .flat_map(|r| r.findings.iter())
11275 .map(|f| f.severity)
11276 .max()
11277}
11278
11279async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
11283 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
11284 git::acquire_worktree_config(repo).await?;
11285 state.enabled_worktree_config = true;
11286 }
11287 Ok(())
11288}
11289
11290#[cfg(test)]
11291mod tests {
11292 #[test]
11293 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11294 let leaked = vec!["chore(deps): update a crate".to_owned()];
11295 let own = "fix(daemon): apply a chosen action";
11296 assert!(should_retitle("", own, &leaked));
11297 assert!(should_retitle(
11298 &format!("{REVIEW_PROMPT_OPENING} `x`"),
11299 own,
11300 &leaked
11301 ));
11302 assert!(should_retitle(
11303 "chore: land candidate A of run 1",
11304 own,
11305 &leaked
11306 ));
11307 assert!(should_retitle(
11308 "magi: candidate A (uncommitted work)",
11309 own,
11310 &leaked
11311 ));
11312 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11313 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11314 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11315 }
11316
11317 #[test]
11318 fn pr_merge_plan_creates_adopts_or_stops() {
11319 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11320 assert_eq!(
11321 pr_merge_plan(Ok(land::OpenPr::One {
11322 url: "u".into(),
11323 title: "t".into()
11324 })),
11325 PrPlan::Adopt {
11326 url: "u".into(),
11327 title: "t".into()
11328 }
11329 );
11330 let PrPlan::Stop(many) =
11331 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11332 else {
11333 panic!("many must stop");
11334 };
11335 assert!(many.contains('a') && many.contains('b'));
11336 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11337 panic!("a failed lookup must stop");
11338 };
11339 assert!(err.contains("bad token"));
11340 }
11341
11342 use super::*;
11343 use crate::run::GateStatus;
11344 use std::collections::BTreeMap;
11345 use std::time::Duration;
11346
11347 fn conductor() -> AgentSpec {
11348 AgentSpec {
11349 id: "conductor".to_owned(),
11350 kind: crate::config::AgentKind::Command,
11351 model: None,
11352 command: vec!["true".to_owned()],
11353 extra_args: Vec::new(),
11354 env: BTreeMap::new(),
11355 prompt_delivery: None,
11356 }
11357 }
11358
11359 fn spec(id: &str) -> AgentSpec {
11360 AgentSpec {
11361 id: id.to_owned(),
11362 kind: crate::config::AgentKind::Command,
11363 model: None,
11364 command: vec!["true".to_owned()],
11365 extra_args: Vec::new(),
11366 env: BTreeMap::new(),
11367 prompt_delivery: None,
11368 }
11369 }
11370
11371 fn ids(xs: &[&str]) -> BTreeSet<String> {
11372 xs.iter().map(|s| (*s).to_owned()).collect()
11373 }
11374
11375 #[test]
11376 fn pick_successor_skips_an_agent_another_seat_holds() {
11377 let roster = [spec("a"), spec("b"), spec("c")];
11379 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11380 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11381 }
11382
11383 #[test]
11384 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11385 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11387 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11388 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11389 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11390 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11391 }
11392
11393 #[test]
11394 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11395 let roster = [spec("a"), spec("b")];
11396 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11397 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11398 let carried = ids(&["b"]);
11399 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11400 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11401 }
11402
11403 #[test]
11404 fn pick_successor_returns_none_without_any_untried_successor() {
11405 let roster = [spec("a"), spec("b")];
11406 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11407 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11408 let carried = ids(&["a"]);
11409 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11410 }
11411
11412 #[test]
11413 fn pick_successor_rescue_avoids_another_seats_occupant() {
11414 let roster = [spec("a"), spec("b"), spec("c")];
11416 let carried = ids(&["b", "c"]);
11417 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11418 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11419 }
11420
11421 #[test]
11422 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11423 let roster = [spec("a"), spec("b"), spec("c")];
11424 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11425 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11426 }
11427
11428 #[test]
11429 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11430 let roster = [spec("a"), spec("b"), spec("c")];
11431 let failed = ids(&["a", "b", "c"]);
11432 let mut tried = ids(&["b"]);
11434 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11435 assert_eq!(first.id, "a");
11436 tried.insert(first.id.clone());
11437 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11438 assert_eq!(second.id, "c");
11439 tried.insert(second.id.clone());
11440 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11441 }
11442
11443 #[test]
11444 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11445 let roster = [spec("a"), spec("b")];
11446 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11447 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11448 }
11449
11450 #[test]
11451 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11452 let roster = [spec("a"), spec("b"), spec("c")];
11453 let h = SeatHistory {
11454 failed: ids(&["a"]),
11455 last_ok: Some("b".to_owned()),
11456 last_fail: None,
11457 };
11458 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11459 let gone = SeatHistory {
11461 last_ok: Some("zzz".to_owned()),
11462 ..h.clone()
11463 };
11464 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11465 let failed = SeatHistory {
11466 failed: ids(&["a", "b"]),
11467 last_ok: Some("b".to_owned()),
11468 last_fail: None,
11469 };
11470 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11471 }
11472
11473 #[test]
11474 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11475 let first = SeatState::new("review-1", "alpha", 7);
11476 let next = handover_seat("review-1", "gamma", 7);
11477 assert_ne!(first.claude_session, next.claude_session);
11478 }
11479
11480 #[test]
11481 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11482 let mut state = state_with_summary("x", "y");
11483 let a = handover_seat("review-1", "beta", state.next_seat_seed());
11484 let b = handover_seat("review-1", "beta", state.next_seat_seed());
11485 assert_ne!(a.claude_session, b.claude_session);
11486 }
11487
11488 #[test]
11489 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11490 let roster = [spec("a"), spec("b")];
11491 let h = SeatHistory {
11492 failed: ids(&["a", "b"]),
11493 ..SeatHistory::default()
11494 };
11495 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11496 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11497 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11498 }
11499
11500 #[test]
11507 fn failure_signature_ignores_numbers_and_paths() {
11508 assert_eq!(
11509 failure_signature("exited with Some(2) and no usable output"),
11510 failure_signature("exited with Some(137) and no usable output")
11511 );
11512 assert_eq!(
11513 failure_signature("process exited with no usable output (exit code 2)"),
11514 failure_signature("process exited with no usable output (exit code 137)")
11515 );
11516 for (old, new) in [
11517 (
11518 "exited with Some(2) and no usable output",
11519 "process exited with no usable output (exit code 2)",
11520 ),
11521 (
11522 "exited with None and no usable output",
11523 "process exited with no usable output (exit code unavailable)",
11524 ),
11525 ] {
11526 let previous = FailClass::Other(failure_signature(old));
11527 let current = FailClass::Other(failure_signature(new));
11528 assert_eq!(previous, current, "resumption retains the failure class");
11529 assert!(!should_hand_over(Some(&previous), ¤t));
11530 }
11531 assert_eq!(
11532 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11533 failure_signature("cannot open /var/x.txt: denied")
11534 );
11535 assert_ne!(failure_signature("boom"), failure_signature("bang"));
11536 }
11537
11538 #[test]
11539 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11540 let other = FailClass::Other("x".into());
11541 assert!(should_hand_over(None, &FailClass::Quota));
11542 assert!(should_hand_over(Some(&other), &FailClass::Quota));
11543 assert!(should_hand_over(
11544 Some(&FailClass::Timeout),
11545 &FailClass::Timeout
11546 ));
11547 assert!(should_hand_over(None, &other));
11548 assert!(!should_hand_over(Some(&other), &other));
11549 assert!(should_hand_over(
11550 Some(&other),
11551 &FailClass::Other("y".into())
11552 ));
11553 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11555 assert!(should_hand_over(Some(&FailClass::Quota), &other));
11556 }
11557
11558 #[test]
11559 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11560 let a = SeatState::new("judge-1", "alpha", 7);
11561 let b = handover_seat("judge-1", "beta", 7);
11562 assert_ne!(a.claude_session, b.claude_session);
11563 assert_eq!(b.turns, 0);
11564 }
11565
11566 #[test]
11567 fn a_timeout_is_classified_apart_from_other_failures() {
11568 assert_eq!(
11569 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11570 Some(FailClass::Timeout)
11571 );
11572 assert!(matches!(
11573 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11574 Some(FailClass::Other(_))
11575 ));
11576 }
11577
11578 #[test]
11579 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11580 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11581 let tried = BTreeSet::from(["beta".to_owned()]);
11582 let next = next_untried_in_roster(&roster, 1, &tried);
11585 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11586 }
11587
11588 #[test]
11589 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11590 let roster = vec![spec("alpha"), spec("beta")];
11591 let tried = BTreeSet::from(["beta".to_owned()]);
11592 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11596 }
11597
11598 #[test]
11599 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11600 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11601 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11602 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11606 }
11607
11608 #[test]
11609 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11610 let roster = vec![spec("a"), spec("a"), spec("b")];
11611 let tried = BTreeSet::from(["a".to_owned()]);
11612 let next = next_untried_in_roster(&roster, 0, &tried);
11613 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11614 }
11615
11616 #[test]
11617 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11618 let roster = vec![spec("a"), spec("b")];
11619 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11620 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11621 }
11622
11623 #[test]
11624 fn remove_if_empty_only_ever_takes_a_bare_directory() {
11625 let dir = tempfile::tempdir().unwrap();
11626 let bay = dir.path().join("ffff");
11627
11628 remove_if_empty(&bay);
11630 assert!(!bay.exists());
11631
11632 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11635 remove_if_empty(&bay);
11636 assert!(bay.exists(), "non-empty directory must survive");
11637
11638 std::fs::remove_dir(bay.join("cand-A")).unwrap();
11640 remove_if_empty(&bay);
11641 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11642 }
11643
11644 #[test]
11653 fn a_full_panel_that_found_nothing_is_clean() {
11654 assert!(round_is_clean(
11655 0,
11656 true,
11657 2,
11658 2,
11659 0,
11660 IncompleteReviewPolicy::Block
11661 ));
11662 }
11663
11664 #[test]
11665 fn a_missing_seat_is_never_clean_under_the_default_policy() {
11666 assert!(!round_is_clean(
11667 0,
11668 true,
11669 1,
11670 2,
11671 0,
11672 IncompleteReviewPolicy::Block
11673 ));
11674 }
11675
11676 #[test]
11677 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11678 assert!(!round_is_clean(
11679 1,
11680 true,
11681 1,
11682 2,
11683 0,
11684 IncompleteReviewPolicy::Warn
11685 ));
11686 }
11687
11688 #[test]
11689 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11690 assert!(round_is_clean(
11691 0,
11692 true,
11693 1,
11694 2,
11695 0,
11696 IncompleteReviewPolicy::Warn
11697 ));
11698 }
11699
11700 #[test]
11701 fn a_full_panel_with_an_open_finding_is_not_clean() {
11702 assert!(!round_is_clean(
11703 1,
11704 true,
11705 2,
11706 2,
11707 0,
11708 IncompleteReviewPolicy::Block
11709 ));
11710 }
11711
11712 #[test]
11713 fn a_full_panel_with_a_red_e2e_is_not_clean() {
11714 assert!(!round_is_clean(
11715 0,
11716 false,
11717 2,
11718 2,
11719 0,
11720 IncompleteReviewPolicy::Block
11721 ));
11722 }
11723
11724 #[test]
11731 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11732 assert!(round_is_clean(
11735 0,
11736 true,
11737 1,
11738 2,
11739 1,
11740 IncompleteReviewPolicy::Block
11741 ));
11742 }
11743
11744 #[test]
11745 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11746 assert!(!round_is_clean(
11749 0,
11750 true,
11751 1,
11752 2,
11753 0,
11754 IncompleteReviewPolicy::Block
11755 ));
11756 }
11757
11758 #[test]
11759 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11760 assert!(!round_is_clean(
11761 1,
11762 true,
11763 1,
11764 2,
11765 1,
11766 IncompleteReviewPolicy::Block
11767 ));
11768 assert!(!round_is_clean(
11769 0,
11770 false,
11771 1,
11772 2,
11773 1,
11774 IncompleteReviewPolicy::Block
11775 ));
11776 }
11777
11778 #[test]
11779 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11780 assert!(!round_is_clean(
11784 0,
11785 true,
11786 0,
11787 2,
11788 2,
11789 IncompleteReviewPolicy::Block
11790 ));
11791 }
11792
11793 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11794 CommandOutcome {
11795 command: "test".to_owned(),
11796 code,
11797 output_tail: String::new(),
11798 duration_ms: 0,
11799 resource_blocked,
11800 }
11801 }
11802
11803 #[test]
11804 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11805 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11806 assert!(
11807 !verify_inconclusive(&[outcome(Some(1), false)]),
11808 "an ordinary failure is still evidence about the patch"
11809 );
11810 assert!(verify_inconclusive(&[outcome(None, true)]));
11811 assert!(
11812 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11813 "one inconclusive outcome taints the whole batch"
11814 );
11815 assert!(!verify_inconclusive(&[]));
11816 }
11817
11818 #[tokio::test]
11819 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11820 let calls = std::sync::atomic::AtomicUsize::new(0);
11824 let started = Instant::now();
11825 wait_for_pids_with(
11826 &[123],
11827 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11828 Duration::from_millis(5),
11829 Duration::from_secs(5),
11830 )
11831 .await;
11832 assert!(
11833 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11834 "must keep checking rather than deciding on the first answer"
11835 );
11836 assert!(
11837 started.elapsed() < Duration::from_secs(1),
11838 "must return the moment it is confirmed dead, not wait out the ceiling"
11839 );
11840 }
11841
11842 #[tokio::test]
11843 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11844 let started = Instant::now();
11845 wait_for_pids_with(
11846 &[123],
11847 |_| true, Duration::from_millis(5),
11849 Duration::from_millis(30),
11850 )
11851 .await;
11852 let elapsed = started.elapsed();
11853 assert!(
11854 elapsed >= Duration::from_millis(30),
11855 "must not give up before its own ceiling: {elapsed:?}"
11856 );
11857 assert!(
11858 elapsed < Duration::from_secs(1),
11859 "must not wait past its own ceiling either: {elapsed:?}"
11860 );
11861 }
11862
11863 #[tokio::test]
11864 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11865 let started = Instant::now();
11866 wait_for_pids_with(
11867 &[],
11868 |_| true,
11869 Duration::from_secs(5),
11870 Duration::from_secs(5),
11871 )
11872 .await;
11873 assert!(
11874 started.elapsed() < Duration::from_millis(200),
11875 "an empty pid list has nothing to confirm"
11876 );
11877 }
11878
11879 fn review_round(
11885 clean: bool,
11886 blocking: usize,
11887 answered: usize,
11888 expected: usize,
11889 progressed: bool,
11890 e2e_ok: bool,
11891 ) -> ReviewRound {
11892 ReviewRound {
11893 round: 1,
11894 head: "h".to_owned(),
11895 verified_head: None,
11896 verified_at: None,
11897 reviews: Vec::new(),
11898 e2e: vec![CommandOutcome {
11899 command: "test".to_owned(),
11900 code: Some(if e2e_ok { 0 } else { 1 }),
11901 output_tail: String::new(),
11902 duration_ms: 0,
11903 resource_blocked: false,
11904 }],
11905 verify_retried: false,
11906 e2e_deferred: false,
11907 e2e_defer_reason: None,
11908 fix: None,
11909 blocking,
11910 answered,
11911 expected,
11912 clean,
11913 progressed,
11914 vote_split: false,
11915 reconsideration: Vec::new(),
11916 verdict: None,
11917 }
11918 }
11919
11920 #[test]
11921 fn review_conclusion_is_none_when_nothing_has_run() {
11922 assert_eq!(review_conclusion(&[], 3), None);
11923 }
11924
11925 #[test]
11926 fn review_conclusion_is_none_while_rounds_remain() {
11927 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11928 assert_eq!(review_conclusion(&rounds, 3), None);
11929 }
11930
11931 #[test]
11932 fn review_conclusion_is_gating_once_a_round_is_clean() {
11933 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11934 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11935 }
11936
11937 #[test]
11938 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11939 let rounds = vec![
11940 review_round(false, 1, 2, 2, true, true),
11941 review_round(false, 1, 2, 2, true, true),
11942 ];
11943 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11944 }
11945
11946 #[test]
11947 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11948 let rounds = vec![
11949 review_round(false, 1, 2, 2, true, true),
11950 review_round(false, 1, 2, 2, true, false),
11951 ];
11952 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11953 }
11954
11955 #[test]
11956 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11957 let mut blocked = review_round(false, 1, 2, 2, true, false);
11964 blocked.e2e[0].resource_blocked = true;
11965 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11966 assert_eq!(review_conclusion(&rounds, 2), None);
11967 }
11968
11969 #[test]
11970 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11971 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11973 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11974 }
11975
11976 #[test]
11977 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11978 let rounds = vec![
11979 review_round(false, 1, 2, 2, false, true),
11980 review_round(false, 1, 2, 2, false, true),
11981 ];
11982 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11983 }
11984
11985 fn secs(n: u64) -> Duration {
11986 Duration::from_secs(n)
11987 }
11988
11989 #[tokio::test]
11992 async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11993 ask_test_home();
11994 let tmp = tempfile::tempdir().unwrap();
11995 let repo = tmp.path().join("repo");
11996 std::fs::create_dir_all(&repo).unwrap();
11997 init_repo(&repo);
11998 let g = |args: &[&str]| {
11999 let out = std::process::Command::new("git")
12000 .args(args)
12001 .current_dir(&repo)
12002 .quiet()
12003 .output()
12004 .unwrap();
12005 assert!(
12006 out.status.success(),
12007 "{}",
12008 String::from_utf8_lossy(&out.stderr)
12009 );
12010 };
12011 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
12012 g(&["checkout", "-b", "historical"]);
12013 std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
12014 g(&["add", "."]);
12015 g(&["commit", "-m", "old alternative"]);
12016 let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
12017 g(&["checkout", "-b", "magi/task-selected", &base]);
12018 std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
12019 g(&["add", "."]);
12020 g(&["commit", "-m", "measurement infrastructure"]);
12021 let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
12022 std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
12023 g(&["add", "."]);
12024 g(&["commit", "-m", "dependent implementation"]);
12025 let head = git::rev_parse(&repo, "HEAD").await.unwrap();
12026 g(&["checkout", "main"]);
12027 std::fs::write(repo.join("unrelated"), "current main change").unwrap();
12028 g(&["add", "."]);
12029 g(&["commit", "-m", "unrelated base change"]);
12030 let current = git::rev_parse(&repo, "HEAD").await.unwrap();
12031 let origin = tmp.path().join("origin.git");
12032 g(&[
12033 "clone",
12034 "--bare",
12035 repo.to_str().unwrap(),
12036 origin.to_str().unwrap(),
12037 ]);
12038 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
12039 g(&["fetch", "origin"]);
12040 let selection = crate::pr_recovery::Selection {
12041 url: "https://example.test/project/pull/1".into(),
12042 branch: "magi/task-selected".into(),
12043 head: head.clone(),
12044 instruction: "preserve measured behavior".into(),
12045 approved_at: jiff::Timestamp::now(),
12046 };
12047 let mut template = runner_at(RunStatus::Reviewing);
12048 template.state.repo = repo.clone();
12049 template.state.base_commit = current.clone();
12050 template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
12051 template.state.origin = Some(crate::run::Origin::queue("selected"));
12052 template.state.pr_head = Some(selection.branch.clone());
12053 template.state.pr_recovery = Some(selection.clone());
12054 let mut runner =
12055 Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
12056 .await
12057 .unwrap();
12058 let wt = runner.state.winner().unwrap().worktree.clone();
12059 assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
12060 assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
12061 assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
12062 assert!(runner.state.seeds.is_empty());
12063 assert_eq!(runner.state.instruction, "preserve measured behavior");
12064 runner.sync_to_base().await.unwrap();
12065 assert_eq!(
12066 std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
12067 "prerequisite"
12068 );
12069 assert_eq!(
12070 std::fs::read_to_string(wt.join("implementation")).unwrap(),
12071 "measured implementation"
12072 );
12073 assert_eq!(
12074 std::fs::read_to_string(wt.join("unrelated")).unwrap(),
12075 "current main change"
12076 );
12077 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
12078 assert_eq!(
12079 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
12080 head
12081 );
12082
12083 let mut partial = runner_at(RunStatus::Reviewing);
12085 partial.state.repo = repo.clone();
12086 partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
12087 partial.state.config.worktree.setup = vec![crate::config::SetupStep {
12088 run: Some("exit 1".into()),
12089 ..Default::default()
12090 }];
12091 let selection = runner.state.pr_recovery.clone().unwrap();
12092 partial.state.pr_recovery = Some(selection.clone());
12093 partial.state.pr_head = Some(selection.branch.clone());
12094 partial.state.origin = Some(crate::run::Origin::queue("selected"));
12095 let id = partial.state.id.clone();
12096 let error = match Runner::open_selected(
12097 &repo,
12098 partial.state,
12099 partial.roles,
12100 1,
12101 runner.state.base_commit.clone(),
12102 selection,
12103 )
12104 .await
12105 {
12106 Ok(_) => panic!("setup must fail"),
12107 Err(e) => e,
12108 };
12109 assert_eq!(
12110 error
12111 .downcast_ref::<crate::pr_recovery::StartupFailed>()
12112 .unwrap()
12113 .run,
12114 id
12115 );
12116 let failed = RunState::load(&id).unwrap();
12117 assert_eq!(failed.status, RunStatus::Blocked);
12118 assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
12119 assert!(Runner::resume(&id).is_err());
12120 runner.state.status = RunStatus::Ready;
12121 runner.state.driver_exited = true;
12122 runner.state.save().unwrap();
12123 let mut retry = runner_at(RunStatus::Reviewing);
12124 retry.state.repo = repo.clone();
12125 retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
12126 let mut reselected = runner.state.pr_recovery.clone().unwrap();
12127 reselected.approved_at = jiff::Timestamp::now();
12128 retry.state.origin = Some(crate::run::Origin::queue("selected"));
12129 retry.state.pr_head = Some(reselected.branch.clone());
12130 retry.state.pr_recovery = Some(reselected.clone());
12131 crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
12132 .await
12133 .unwrap();
12134 let restarted = Runner::open_selected(
12135 &repo,
12136 retry.state,
12137 retry.roles,
12138 1,
12139 runner.state.base_commit.clone(),
12140 reselected,
12141 )
12142 .await
12143 .unwrap();
12144 assert_eq!(
12145 git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
12146 .await
12147 .unwrap(),
12148 head
12149 );
12150 assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
12151 assert_eq!(
12152 git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
12153 head
12154 );
12155 }
12156
12157 #[tokio::test]
12158 #[cfg(unix)]
12159 async fn fallback_and_interrupted_restoration_require_coordination() {
12160 use std::os::fd::AsRawFd as _;
12161 ask_test_home();
12162 let tmp = tempfile::tempdir().unwrap();
12163 let mut runner = registered_runner(tmp.path()).await;
12164 runner.state.config.graph.timeout_resource_wait = 1;
12165 let winner = runner.state.candidates[0].clone();
12166 let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12167 let leftover = winner.worktree.join("leftover");
12168 std::fs::write(&leftover, "untrusted edits").unwrap();
12169 let next = tmp.path().join("next-fixer");
12170 let mut failed = spec("first");
12171 failed.command = vec!["sh".into(), "-c".into(), "exit 1".into()];
12172 let mut successor = spec("second");
12173 successor.command = vec![
12174 "sh".into(),
12175 "-c".into(),
12176 format!("touch '{}'; echo done", next.display()),
12177 ];
12178 let attempts = vec![
12179 (failed.clone(), "first".into()),
12180 (successor.clone(), "second".into()),
12181 ];
12182 let lock_path = runner.state.repo.join(".git/updater.lock");
12183 let external = std::fs::File::create(&lock_path).unwrap();
12184 assert_eq!(
12185 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12186 0
12187 );
12188 let artifacts = tmp.path().join("artifacts");
12189 let build = |spec, seat| SeatJob {
12190 spec,
12191 seat,
12192 cwd: winner.worktree.clone(),
12193 prompt: "fixture".into(),
12194 timeout: Duration::from_secs(10),
12195 allow_write: true,
12196 sessions: false,
12197 artifacts: artifacts.clone(),
12198 stem: "fix".into(),
12199 handover: None,
12200 };
12201 let (_, _, out) = runner
12202 .ask_fixer_among(
12203 attempts.clone(),
12204 "graft",
12205 None,
12206 Some((&winner.worktree, &head)),
12207 build,
12208 )
12209 .await;
12210 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
12211 assert!(leftover.exists(), "contention must prevent clean");
12212 assert!(!next.exists());
12213 assert!(runner.state.handovers.is_empty());
12214 runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12216 from_run: "earlier".into(),
12217 generation: 1,
12218 mode: crate::recovery::Mode::VerifyRepair,
12219 reasons: Vec::new(),
12220 reviewed_head: None,
12221 rounds: Vec::new(),
12222 open_findings: Vec::new(),
12223 evidence: None,
12224 criteria_inherited: true,
12225 repair: crate::recovery::Repair::Started {
12226 before: head.clone(),
12227 },
12228 });
12229 assert!(
12230 runner
12231 .recovery_repair(&winner)
12232 .await
12233 .unwrap_err()
12234 .is::<crate::repo_lock::ResourceBlocked>()
12235 );
12236 assert!(matches!(
12237 runner.state.recovery.as_ref().unwrap().repair,
12238 crate::recovery::Repair::Started { .. }
12239 ));
12240 assert!(leftover.exists());
12241 drop(external);
12242 runner.recovery_repair(&winner).await.unwrap();
12243 assert!(!leftover.exists());
12244 assert!(matches!(
12245 runner.state.recovery.as_ref().unwrap().repair,
12246 crate::recovery::Repair::Done { .. }
12247 ));
12248 std::fs::remove_file(&lock_path).unwrap();
12250 std::fs::create_dir(&lock_path).unwrap();
12251 std::fs::write(&leftover, "untrusted edits").unwrap();
12252 let (_, _, out) = runner
12253 .ask_fixer_among(
12254 attempts,
12255 "graft",
12256 None,
12257 Some((&winner.worktree, &head)),
12258 build,
12259 )
12260 .await;
12261 assert!(matches!(out, AgentOutcome::Failed(_)));
12262 assert!(leftover.exists());
12263 assert!(!next.exists());
12264 assert_eq!(
12265 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12266 head
12267 );
12268 }
12269
12270 #[cfg(unix)]
12271 async fn registered_runner(root: &Path) -> Runner {
12272 ask_test_home();
12273 let repo = root.join("repo");
12274 std::fs::create_dir_all(&repo).unwrap();
12275 init_repo(&repo);
12276 std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12277 let mut runner = runner_at(RunStatus::Prep);
12278 runner.state.repo = repo.clone();
12279 runner.state.config.coordination = crate::config::test_coordination();
12280 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12281 runner.state.config.graph.worktree_root = Some(root.join("wt"));
12282 runner.state.config.graph.advise = false;
12283 runner.roles.implementers = vec![spec("implement")];
12284 runner.roles.judges = vec![spec("judge")];
12285 runner.prep().await.unwrap();
12286 runner
12287 }
12288
12289 #[tokio::test]
12290 #[cfg(unix)]
12291 async fn parked_implementer_persists_completed_work() {
12292 for registered in [false, true] {
12293 let tmp = tempfile::tempdir().unwrap();
12294 let mut runner = registered_runner(tmp.path()).await;
12295 if !registered {
12296 std::fs::remove_file(runner.state.repo.join(".git/updater.contract")).unwrap();
12297 }
12298 let marker = tmp.path().join("started");
12299 let release = tmp.path().join("release");
12300 let mut implementer = spec("implement");
12301 implementer.command = vec![
12302 "sh".into(),
12303 "-c".into(),
12304 format!(
12305 "touch '{}'; while [ ! -f '{}' ]; do sleep 0.01; done; echo completed > completed; echo saved-result",
12306 marker.display(),
12307 release.display()
12308 ),
12309 ];
12310 runner.roles.implementers = vec![implementer.clone()];
12311 runner.state.config.agents = vec![implementer];
12312 let pause = runner.pause.clone();
12313 let (result, ()) = tokio::join!(runner.implement(), async {
12314 tokio::time::timeout(Duration::from_secs(10), async {
12315 while !marker.exists() {
12316 tokio::time::sleep(Duration::from_millis(10)).await;
12317 }
12318 })
12319 .await
12320 .unwrap();
12321 pause.park();
12322 std::fs::write(&release, "go").unwrap();
12323 });
12324 result.unwrap();
12325 let candidate = &runner.state.candidates[0];
12326 assert!(candidate.commits > 0, "{candidate:?}");
12327 assert!(candidate.summary.contains("saved-result"));
12328 assert_eq!(candidate.files, 1);
12329 let before = candidate.commits;
12330 runner.implement().await.unwrap();
12331 assert_eq!(runner.state.candidates[0].commits, before);
12332 }
12333 }
12334
12335 #[tokio::test]
12336 #[cfg(unix)]
12337 async fn resource_blocked_recovery_retries_the_unstarted_fixer() {
12338 let tmp = tempfile::tempdir().unwrap();
12339 let mut runner = registered_runner(tmp.path()).await;
12340 let winner = runner.state.candidates[0].clone();
12341 let cache = tmp.path().join("cache");
12342 runner.state.config.verify.e2e = vec![format!("CARGO_TARGET_DIR={} true", cache.display())];
12343 runner.state.config.graph.timeout_resource_wait = 0;
12344 let owner = crate::cache::Owner::here("other", "e2e", "e2e", &cache, "abc");
12345 let crate::cache::AcquireOutcome::Acquired(held) =
12346 crate::cache::try_acquire(&crate::run::home(), &cache, &owner).unwrap()
12347 else {
12348 panic!("cache must be free")
12349 };
12350 runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12351 from_run: "earlier".into(),
12352 generation: 1,
12353 mode: crate::recovery::Mode::VerifyRepair,
12354 reasons: Vec::new(),
12355 reviewed_head: None,
12356 rounds: Vec::new(),
12357 open_findings: Vec::new(),
12358 evidence: None,
12359 criteria_inherited: true,
12360 repair: crate::recovery::Repair::NotStarted,
12361 });
12362 runner.recovery_repair(&winner).await.unwrap();
12363 assert!(matches!(
12364 runner.state.recovery.as_ref().unwrap().repair,
12365 crate::recovery::Repair::WaitingForRestore { .. }
12366 ));
12367 drop(held);
12368 runner.state.resource_block = None;
12369 runner.state.config.graph.timeout_resource_wait = 1;
12370 runner.recovery_repair(&winner).await.unwrap();
12371 assert!(matches!(
12372 runner.state.recovery.as_ref().unwrap().repair,
12373 crate::recovery::Repair::Done { .. }
12374 ));
12375 assert!(runner.state.seats.values().any(|seat| seat.turns > 0));
12376 }
12377
12378 #[tokio::test]
12379 #[cfg(unix)]
12380 async fn repository_blocked_graft_and_recovery_retry_after_deferred_restore() {
12381 use std::os::fd::AsRawFd as _;
12382 for graft in [false, true] {
12383 let tmp = tempfile::tempdir().unwrap();
12384 let mut runner = registered_runner(tmp.path()).await;
12385 runner.state.config.graph.timeout_resource_wait = 1;
12386 let winner = runner.state.candidates[0].clone();
12387 let marker = tmp.path().join("fixer-started");
12388 let mut fixer = spec("fixer");
12389 fixer.command = vec![
12390 "sh".into(),
12391 "-c".into(),
12392 format!("echo started >> '{}'; echo done", marker.display()),
12393 ];
12394 runner.roles.fixer = Some(vec![fixer]);
12395 if graft {
12396 let mut loser = winner.clone();
12397 loser.label = 'B';
12398 loser.index = 1;
12399 runner.state.candidates.push(loser);
12400 runner.state.config.graph.graft_runner_up = true;
12401 runner.state.tally = Some(Tally {
12402 first_choice: BTreeMap::new(),
12403 borda: BTreeMap::new(),
12404 winner: winner.label,
12405 rankings: 1,
12406 unanimous_initial: true,
12407 deliberated: false,
12408 changed_votes: 0,
12409 unanimous_final: true,
12410 tie_break: None,
12411 judges: 1,
12412 present: 1,
12413 quorum: 1,
12414 met_quorum: true,
12415 uncontested: None,
12416 });
12417 runner.state.judgements.push(crate::run::Judgement {
12418 judge: 1,
12419 seat: "judge-1".into(),
12420 agent: "judge".into(),
12421 ranking: vec![winner.label, 'B'],
12422 reasons: BTreeMap::new(),
12423 confidence: None,
12424 strengths: BTreeMap::from([("B".into(), vec!["handles empty input".into()])]),
12425 order: vec![0, 1],
12426 failed: None,
12427 duration_ms: 0,
12428 });
12429 } else {
12430 runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12431 from_run: "earlier".into(),
12432 generation: 1,
12433 mode: crate::recovery::Mode::VerifyRepair,
12434 reasons: Vec::new(),
12435 reviewed_head: None,
12436 rounds: Vec::new(),
12437 open_findings: Vec::new(),
12438 evidence: None,
12439 criteria_inherited: true,
12440 repair: crate::recovery::Repair::NotStarted,
12441 });
12442 }
12443 let reader = crate::repo_lock::SeatLease::for_checkout(
12444 &winner.worktree,
12445 false,
12446 crate::config::test_coordination().as_ref(),
12447 )
12448 .await
12449 .unwrap();
12450 let pause = runner.pause.clone();
12451 let (result, ()) = tokio::join!(
12452 async {
12453 if graft {
12454 runner.graft().await
12455 } else {
12456 runner.recovery_repair(&winner).await
12457 }
12458 },
12459 async {
12460 tokio::time::sleep(Duration::from_millis(100)).await;
12461 pause.park();
12462 }
12463 );
12464 result.unwrap();
12465 assert!(!marker.exists(), "a blocked seat must not spawn");
12466 runner.state = RunState::load(&runner.state.id).unwrap();
12468 if graft {
12469 assert_eq!(
12470 runner.state.graft.as_ref().unwrap().outcome,
12471 GraftOutcome::WaitingForRestore
12472 );
12473 } else {
12474 assert!(matches!(
12475 runner.state.recovery.as_ref().unwrap().repair,
12476 crate::recovery::Repair::WaitingForRestore { .. }
12477 ));
12478 }
12479 drop(reader);
12480 runner.pause = Pause::new();
12481 runner.state.resource_block = None;
12482 let external =
12484 std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12485 assert_eq!(
12486 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12487 0
12488 );
12489 let leftover = winner.worktree.join("leftover");
12490 std::fs::write(&leftover, "earlier fallback edits").unwrap();
12491 let result = if graft {
12492 runner.graft().await
12493 } else {
12494 runner.recovery_repair(&winner).await
12495 };
12496 assert!(
12497 result
12498 .unwrap_err()
12499 .is::<crate::repo_lock::ResourceBlocked>()
12500 );
12501 assert!(leftover.exists());
12502 assert!(!marker.exists());
12503 drop(external);
12504 runner.state.resource_block = None;
12505 let result = if graft {
12506 runner.graft().await
12507 } else {
12508 runner.recovery_repair(&winner).await
12509 };
12510 result.unwrap();
12511 assert!(!leftover.exists(), "restore must precede retry");
12512 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "started\n");
12513 if graft {
12514 assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12515 } else {
12516 assert!(matches!(
12517 runner.state.recovery.as_ref().unwrap().repair,
12518 crate::recovery::Repair::Done { .. }
12519 ));
12520 }
12521 }
12522 }
12523
12524 #[tokio::test]
12525 #[cfg(unix)]
12526 async fn interrupted_graft_restore_waits_and_preserves_its_record() {
12527 use std::os::fd::AsRawFd as _;
12528 let tmp = tempfile::tempdir().unwrap();
12529 let mut runner = registered_runner(tmp.path()).await;
12530 runner.state.config.graph.timeout_resource_wait = 1;
12531 let winner = runner.state.candidates[0].clone();
12532 let pre = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12533 runner.state.tally = Some(Tally {
12534 first_choice: BTreeMap::new(),
12535 borda: BTreeMap::new(),
12536 winner: winner.label,
12537 rankings: 0,
12538 unanimous_initial: false,
12539 deliberated: false,
12540 changed_votes: 0,
12541 unanimous_final: false,
12542 tie_break: None,
12543 judges: 0,
12544 present: 0,
12545 quorum: 0,
12546 met_quorum: true,
12547 uncontested: Some("fixture".into()),
12548 });
12549 runner.state.graft = Some(GraftRecord {
12550 pre: pre.clone(),
12551 post: None,
12552 lines: None,
12553 limit: None,
12554 agent: None,
12555 outcome: GraftOutcome::Running,
12556 requested: Vec::new(),
12557 adoption: None,
12558 adoption_error: None,
12559 });
12560 std::fs::write(winner.worktree.join("README.md"), "unfinished graft\n").unwrap();
12561 git::commit_all(&winner.worktree, "unfinished graft")
12562 .await
12563 .unwrap();
12564 let unfinished = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12565 let leftover = winner.worktree.join("leftover");
12566 std::fs::write(&leftover, "untrusted edits").unwrap();
12567 let path = runner.state.repo.join(".git/updater.lock");
12568 let external = std::fs::File::create(&path).unwrap();
12569 assert_eq!(
12570 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12571 0
12572 );
12573 assert!(
12574 runner
12575 .graft()
12576 .await
12577 .unwrap_err()
12578 .is::<crate::repo_lock::ResourceBlocked>()
12579 );
12580 assert!(matches!(
12581 runner.state.graft.as_ref().unwrap().outcome,
12582 GraftOutcome::Running
12583 ));
12584 assert_eq!(
12585 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12586 unfinished
12587 );
12588 assert!(leftover.exists());
12589 drop(external);
12590 std::fs::remove_file(&path).unwrap();
12591 std::fs::create_dir(&path).unwrap();
12592 assert!(runner.graft().await.is_err());
12593 assert!(matches!(
12594 runner.state.graft.as_ref().unwrap().outcome,
12595 GraftOutcome::Running
12596 ));
12597 assert_eq!(
12598 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12599 unfinished
12600 );
12601 assert!(leftover.exists());
12602 std::fs::remove_dir(&path).unwrap();
12603 runner.graft().await.unwrap();
12604 assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12605 assert_eq!(git::rev_parse(&winner.worktree, "HEAD").await.unwrap(), pre);
12606 assert!(!leftover.exists());
12607 }
12608
12609 #[tokio::test]
12610 #[cfg(unix)]
12611 async fn repository_contention_blocks_and_parks_without_poisoning_preparation() {
12612 use std::os::fd::AsRawFd as _;
12613 let tmp = tempfile::tempdir().unwrap();
12614 let mut runner = registered_runner(tmp.path()).await;
12615 runner.state.prep_complete = Some(false);
12616 runner.state.config.graph.timeout_resource_wait = 1;
12617 runner.state.save().unwrap();
12618 let external = std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12619 assert_eq!(
12620 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12621 0
12622 );
12623 runner.execute().await.unwrap();
12624 let disk = RunState::load(&runner.state.id).unwrap();
12625 assert!(disk.driver_exited);
12626 assert_eq!(disk.driver_pid, None);
12627 assert_eq!(runner.state.status, RunStatus::Blocked);
12628 assert_eq!(
12629 runner.state.resource_block.as_ref().unwrap().kind,
12630 "repository coordination"
12631 );
12632 assert!(runner.state.prep_failure.is_none());
12633 assert!(runner.state.seats.is_empty());
12634 runner.pause.park();
12635 tokio::time::timeout(Duration::from_secs(10), runner.execute())
12638 .await
12639 .unwrap()
12640 .unwrap();
12641 assert!(runner.state.parked);
12642 assert!(runner.state.prep_failure.is_none());
12643 let disk = RunState::load(&runner.state.id).unwrap();
12644 assert!(disk.driver_exited);
12645 assert_eq!(disk.driver_pid, None);
12646 }
12647
12648 #[tokio::test]
12649 #[cfg(unix)]
12650 async fn dropping_execute_during_a_repository_wait_releases_the_driver() {
12651 use std::os::fd::AsRawFd as _;
12652 let tmp = tempfile::tempdir().unwrap();
12653 let mut runner = registered_runner(tmp.path()).await;
12654 runner.state.prep_complete = Some(false);
12655 runner.state.config.graph.timeout_resource_wait = 30;
12656 runner.state.save().unwrap();
12657 let external = std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12658 assert_eq!(
12659 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12660 0
12661 );
12662 assert!(
12663 tokio::time::timeout(Duration::from_millis(100), runner.execute())
12664 .await
12665 .is_err()
12666 );
12667 let disk = RunState::load(&runner.state.id).unwrap();
12668 assert!(disk.driver_exited);
12669 assert_eq!(disk.driver_pid, None);
12670 assert!(disk.status.resumable());
12671 assert!(runner.state.driver_exited);
12672 assert_eq!(runner.state.driver_pid, None);
12673 runner.state.save().unwrap();
12674 assert_eq!(RunState::load(&runner.state.id).unwrap().driver_pid, None);
12675 }
12676
12677 #[tokio::test]
12678 #[cfg(unix)]
12679 async fn gate_takes_cache_before_repository_so_an_editing_wave_can_finish() {
12680 let tmp = tempfile::tempdir().unwrap();
12681 let mut runner = registered_runner(tmp.path()).await;
12682 let winner = runner.state.candidates[0].clone();
12683 let cache = tmp.path().join("cache");
12684 let started = tmp.path().join("pre-gate-started");
12685 runner.state.config.verify.pre_gate = vec![format!("touch '{}'", started.display())];
12686 runner.state.config.verify.gate =
12687 vec![format!("CARGO_TARGET_DIR='{}' true", cache.display())];
12688 let home = crate::run::home();
12689 let owner =
12690 crate::cache::Owner::here("editing-wave", "implement", "*", &winner.worktree, "HEAD");
12691 let guard = match crate::cache::try_acquire(&home, &cache, &owner).unwrap() {
12692 crate::cache::AcquireOutcome::Acquired(g) => g,
12693 crate::cache::AcquireOutcome::Busy(busy) => panic!("cache must be free: {busy:?}"),
12694 };
12695 let mut gate = Box::pin(runner.run_gate(&winner));
12696 assert!(
12697 tokio::time::timeout(Duration::from_millis(150), &mut gate)
12698 .await
12699 .is_err()
12700 );
12701 assert!(!started.exists(), "pre-gate must wait for the cache too");
12702 let wait = crate::repo_lock::WaitPolicy::new(Duration::from_millis(200), || false);
12706 let editor = crate::repo_lock::SeatLease::checkout_with(
12707 &winner.worktree,
12708 true,
12709 crate::config::test_coordination().as_ref(),
12710 &wait,
12711 )
12712 .await
12713 .unwrap();
12714 drop(editor);
12715 guard.release();
12716 let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12717 .await
12718 .unwrap()
12719 .unwrap();
12720 drop(gate);
12721 assert!(outcomes.iter().all(CommandOutcome::ok));
12722 assert!(started.exists());
12723 }
12724
12725 #[tokio::test]
12726 #[cfg(unix)]
12727 async fn post_seat_rescue_and_gate_wait_for_shared_lock() {
12728 use std::os::fd::AsRawFd as _;
12729
12730 ask_test_home();
12731 let tmp = tempfile::tempdir().unwrap();
12732 let repo = tmp.path().join("repo");
12733 std::fs::create_dir_all(&repo).unwrap();
12734 init_repo(&repo);
12735 let common = repo.join(".git");
12736 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12737 let mut runner = runner_at(RunStatus::Prep);
12738 runner.state.repo = repo.clone();
12739 runner.state.config.coordination = crate::config::test_coordination();
12740 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12741 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12742 runner.state.config.graph.advise = false;
12743 runner.roles.implementers = vec![spec("implement")];
12744 runner.roles.judges = vec![spec("judge")];
12745 runner.prep().await.unwrap();
12746 let winner = runner.state.candidates[0].clone();
12747 std::fs::write(winner.worktree.join("leftover"), "seat work\n").unwrap();
12750 let completed = tmp.path().join("seat-completed");
12751 runner.roles.implementers[0].command = vec![
12752 "sh".into(),
12753 "-c".into(),
12754 format!("touch '{}'; echo completed", completed.display()),
12755 ];
12756 let outsider = std::fs::File::create(common.join("updater.lock")).unwrap();
12757 assert_eq!(
12758 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12759 0
12760 );
12761 let before = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12762 let mut implement = Box::pin(runner.implement());
12763 tokio::select! {
12764 result = &mut implement => panic!("rescue must wait: {result:?}"),
12765 result = tokio::time::timeout(Duration::from_secs(10), async {
12766 while !completed.exists() {
12767 tokio::time::sleep(Duration::from_millis(20)).await;
12768 }
12769 }) => result.unwrap(),
12770 }
12771 assert!(
12772 tokio::time::timeout(Duration::from_millis(200), &mut implement)
12773 .await
12774 .is_err()
12775 );
12776 assert_eq!(
12777 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12778 before
12779 );
12780 assert_eq!(
12781 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12782 0
12783 );
12784 tokio::time::timeout(Duration::from_secs(10), &mut implement)
12785 .await
12786 .unwrap()
12787 .unwrap();
12788 drop(implement);
12789 assert_ne!(
12790 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12791 before
12792 );
12793 assert!(git::is_clean(&winner.worktree).await.unwrap());
12794
12795 let started = tmp.path().join("pre-gate-started");
12796 let proceed = tmp.path().join("pre-gate-proceed");
12797 let checked = tmp.path().join("gate-ran");
12798 runner.state.config.verify.pre_gate = vec![format!(
12799 "echo mechanical >> README.md; touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12800 started.display(),
12801 proceed.display()
12802 )];
12803 runner.state.config.verify.gate = vec![format!("touch '{}'", checked.display())];
12804 assert_eq!(
12805 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12806 0
12807 );
12808 let mut gate = Box::pin(runner.run_gate(&winner));
12809 assert!(
12810 tokio::time::timeout(Duration::from_millis(200), &mut gate)
12811 .await
12812 .is_err()
12813 );
12814 assert!(!started.exists());
12815 assert!(!checked.exists());
12816 assert!(git::is_clean(&winner.worktree).await.unwrap());
12817 assert_eq!(
12818 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12819 0
12820 );
12821 tokio::select! {
12822 result = &mut gate => panic!("pre-gate must wait for signal: {result:?}"),
12823 result = tokio::time::timeout(Duration::from_secs(10), async {
12824 while !started.exists() {
12825 tokio::time::sleep(Duration::from_millis(20)).await;
12826 }
12827 }) => result.unwrap(),
12828 }
12829 assert_eq!(
12830 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12831 -1,
12832 "mechanical edits must hold the real lock"
12833 );
12834 std::fs::write(proceed, "ready").unwrap();
12835 let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12836 .await
12837 .unwrap()
12838 .unwrap();
12839 drop(gate);
12840 assert!(outcomes.iter().all(CommandOutcome::ok));
12841 assert!(checked.exists());
12842 assert!(runner.state.pre_gate_commit.is_some());
12843 assert!(git::is_clean(&winner.worktree).await.unwrap());
12844 assert_eq!(
12845 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12846 0,
12847 "gate completion must release coordination"
12848 );
12849 drop(outsider);
12850 std::fs::remove_file(common.join("updater.lock")).unwrap();
12851 std::fs::create_dir(common.join("updater.lock")).unwrap();
12852 std::fs::remove_file(&started).unwrap();
12853 std::fs::remove_file(&checked).unwrap();
12854 let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12855 let error = runner.run_gate(&winner).await.unwrap_err();
12856 assert!(
12857 error
12858 .to_string()
12859 .contains("acquire mandatory repository lock")
12860 );
12861 assert!(
12862 !started.exists(),
12863 "failed acquisition must prevent pre-gate"
12864 );
12865 assert!(!checked.exists(), "failed acquisition must prevent gate");
12866 assert!(git::is_clean(&winner.worktree).await.unwrap());
12867 assert_eq!(
12868 git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12869 head
12870 );
12871 }
12872
12873 #[tokio::test]
12874 #[cfg(unix)]
12875 async fn preparation_waits_for_shared_lock_before_checkout_and_holds_it_through_setup() {
12876 use std::os::fd::AsRawFd as _;
12877 use std::time::Duration;
12878
12879 ask_test_home();
12880 let tmp = tempfile::tempdir().unwrap();
12881 let repo = tmp.path().join("repo");
12882 std::fs::create_dir_all(&repo).unwrap();
12883 init_repo(&repo);
12884 let common = repo.join(".git");
12885 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12886 let lock = std::fs::File::create(common.join("updater.lock")).unwrap();
12887 assert_eq!(
12888 unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12889 0
12890 );
12891 let mut runner = runner_at(RunStatus::Prep);
12892 runner.state.repo = repo.clone();
12893 runner.state.config.coordination = crate::config::test_coordination();
12894 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12895 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12896 runner.state.config.graph.advise = true;
12897 runner.state.config.graph.advisors = 1;
12898 runner.roles.implementers = vec![spec("implement")];
12899 runner.roles.judges = vec![spec("judge")];
12900 let root = runner.state.worktree_root();
12901 let started = tmp.path().join("setup-started");
12902 let proceed = tmp.path().join("setup-proceed");
12903 runner.state.config.worktree.setup = vec![crate::config::SetupStep {
12904 run: Some(format!(
12905 "touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12906 started.display(),
12907 proceed.display()
12908 )),
12909 ..Default::default()
12910 }];
12911 let branches = git::git(&repo, &["branch", "--list"]).await.unwrap();
12912 let config = git::git(&repo, &["config", "--local", "--list"])
12913 .await
12914 .unwrap();
12915 let mut prep = Box::pin(runner.prep());
12916 assert!(
12917 tokio::time::timeout(Duration::from_millis(200), &mut prep)
12918 .await
12919 .is_err()
12920 );
12921 assert!(
12922 !root.exists(),
12923 "contention must prevent even checkout creation"
12924 );
12925 assert!(!started.exists(), "contention must prevent setup");
12926 assert_eq!(
12927 git::git(&repo, &["branch", "--list"]).await.unwrap(),
12928 branches
12929 );
12930 assert_eq!(
12931 git::git(&repo, &["config", "--local", "--list"])
12932 .await
12933 .unwrap(),
12934 config
12935 );
12936 drop(lock);
12937 tokio::select! {
12938 result = &mut prep => panic!("setup must wait for its signal: {result:?}"),
12939 result = tokio::time::timeout(Duration::from_secs(10), async {
12940 while !started.exists() {
12941 tokio::time::sleep(Duration::from_millis(20)).await;
12942 }
12943 }) => result.unwrap(),
12944 }
12945 let outsider = std::fs::File::open(common.join("updater.lock")).unwrap();
12946 assert_eq!(
12947 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12948 -1
12949 );
12950 assert!(
12951 tokio::time::timeout(
12952 Duration::from_millis(100),
12953 crate::repo_lock::SeatLease::for_checkout(
12954 &repo,
12955 true,
12956 crate::config::test_coordination().as_ref()
12957 )
12958 )
12959 .await
12960 .is_err(),
12961 "preparation must also exclude local editors"
12962 );
12963 std::fs::write(proceed, "ready").unwrap();
12964 tokio::time::timeout(Duration::from_secs(10), &mut prep)
12965 .await
12966 .unwrap()
12967 .unwrap();
12968 drop(prep);
12969 assert_eq!(runner.state.prep_complete, Some(true));
12970 assert!(root.join("judge-1").exists());
12971 assert!(root.join("advisor-1").exists());
12972 assert_eq!(
12973 unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12974 0
12975 );
12976 }
12977
12978 #[tokio::test]
12979 #[cfg(unix)]
12980 async fn preparation_lock_failure_prevents_repository_changes() {
12981 ask_test_home();
12982 let tmp = tempfile::tempdir().unwrap();
12983 let repo = tmp.path().join("repo");
12984 std::fs::create_dir_all(&repo).unwrap();
12985 init_repo(&repo);
12986 std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12987 std::fs::create_dir(repo.join(".git/updater.lock")).unwrap();
12990 let mut runner = runner_at(RunStatus::Prep);
12991 runner.state.repo = repo.clone();
12992 runner.state.config.coordination = crate::config::test_coordination();
12993 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12994 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12995 runner.roles.implementers = vec![spec("implement")];
12996 let error = runner.prep().await.unwrap_err();
12997 assert!(
12998 error
12999 .to_string()
13000 .contains("acquire mandatory repository lock")
13001 );
13002 assert!(!runner.state.worktree_root().exists());
13003 assert!(runner.state.candidates.is_empty());
13004 assert!(!repo.join(".git/config.worktree").exists());
13005 assert_eq!(
13006 git::git(&repo, &["branch", "--list"]).await.unwrap().trim(),
13007 "* main"
13008 );
13009 }
13010
13011 #[tokio::test]
13012 async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
13013 ask_test_home();
13014 let tmp = tempfile::tempdir().unwrap();
13015 let repo = tmp.path().join("repo");
13016 std::fs::create_dir_all(&repo).unwrap();
13017 init_repo(&repo);
13018 let base = git::rev_parse(&repo, "HEAD").await.unwrap();
13019 git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
13020 std::fs::write(repo.join("referenced"), "required commit").unwrap();
13021 git::git(&repo, &["add", "."]).await.unwrap();
13022 git::git(&repo, &["commit", "-m", "required seed"])
13023 .await
13024 .unwrap();
13025 let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
13026 git::git(&repo, &["checkout", "main"]).await.unwrap();
13027 let mut runner = runner_at(RunStatus::Prep);
13028 runner.state.repo = repo.clone();
13029 runner.state.base_commit = base.clone();
13030 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
13031 runner.state.config.graph.advise = false;
13032 runner.state.config.blind.commit_msg_hook = false;
13033 runner.state.seeds = vec![refs::Seed {
13034 token: seed.clone(),
13035 sha: seed.clone(),
13036 kind: refs::SeedKind::Unmerged,
13037 branch: false,
13038 detail: String::new(),
13039 }];
13040 runner.roles.implementers = vec![spec("one"), spec("two")];
13041 runner.roles.judges = vec![spec("judge")];
13042 let label = blind::assign_labels(2, runner.state.seed)[0];
13043 let branch = runner.state.branch_for(label);
13044 let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
13045 git::worktree_add_branch(&repo, &worktree, &branch, &base)
13046 .await
13047 .unwrap();
13048 runner.state.candidates.push(Candidate {
13049 index: 0,
13050 label,
13051 agent: "one".into(),
13052 branch,
13053 worktree,
13054 summary: String::new(),
13055 stat: String::new(),
13056 files: 0,
13057 commits: 0,
13058 empty: false,
13059 failed: None,
13060 verified_noop: None,
13061 duration_ms: 0,
13062 folded: false,
13063 });
13064 runner.state.save().unwrap();
13065 runner.state = RunState::load(&runner.state.id).unwrap();
13067 assert_eq!(runner.state.prep_complete, Some(false));
13068 runner.prep().await.unwrap();
13069 assert_eq!(runner.state.prep_complete, Some(true));
13070 assert_eq!(runner.state.candidates.len(), 2);
13071 for candidate in &runner.state.candidates {
13072 assert_eq!(
13073 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
13074 git::tree_of(&repo, &seed).await.unwrap()
13075 );
13076 assert_eq!(
13077 std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
13078 "required commit"
13079 );
13080 }
13081 assert!(runner.state.worktree_root().join("judge-1").exists());
13082 let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
13083 runner.prep().await.unwrap();
13084 assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
13085
13086 runner.state.prep_complete = Some(false);
13087 runner.state.config.worktree.setup = vec![crate::config::SetupStep {
13088 run: Some(format!(
13089 "test -f '{}'",
13090 tmp.path().join("dependency-ready").display()
13091 )),
13092 ..Default::default()
13093 }];
13094 assert!(runner.prep().await.is_err());
13095 assert!(runner.state.prep_failure.is_some());
13096 std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
13097 runner.state = RunState::load(&runner.state.id).unwrap();
13098 assert!(
13099 runner.prep().await.is_err(),
13100 "correcting the cause alone must not auto-retry"
13101 );
13102 let failed_at = runner.state.events.last().unwrap().at;
13103 assert!(
13104 runner.retry_preparation(failed_at).is_err(),
13105 "old approvals cannot unlock the hold"
13106 );
13107 let mut task = crate::queue::Task::new(
13108 "retry".into(),
13109 "retry setup".into(),
13110 repo.clone(),
13111 crate::queue::Source::Human,
13112 );
13113 task.start(runner.state.id.clone());
13114 task.hold_machine(Some("setup failed".into()));
13115 task.release_operator("cli", None);
13116 runner
13117 .retry_preparation(task.resumption.as_ref().unwrap().at)
13118 .unwrap();
13119 runner.prep().await.unwrap();
13120 assert!(runner.state.prep_failure.is_none());
13121 assert_eq!(runner.state.prep_complete, Some(true));
13122 assert_eq!(runner.state.candidates.len(), 2);
13123 assert!(runner.state.worktree_root().join("judge-1").exists());
13124 for candidate in &runner.state.candidates {
13125 assert_eq!(
13126 git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
13127 git::tree_of(&repo, &seed).await.unwrap()
13128 );
13129 }
13130 }
13131
13132 async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
13133 let mut heads = Vec::new();
13134 for candidate in candidates {
13135 heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
13136 }
13137 heads
13138 }
13139
13140 async fn preservation_resume_fixture() -> (tempfile::TempDir, Runner) {
13141 ask_test_home();
13142 let tmp = tempfile::tempdir().unwrap();
13143 let repo = tmp.path().join("repo");
13144 std::fs::create_dir(&repo).unwrap();
13145 init_repo(&repo);
13146 let mut runner = runner_at(RunStatus::Reviewing);
13147 runner.state.repo = repo.clone();
13148 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
13149 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
13150 for (index, label) in ['A', 'B'].into_iter().enumerate() {
13151 let branch = format!("candidate-{label}");
13152 let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
13153 git::worktree_add_branch(&repo, &worktree, &branch, "main")
13154 .await
13155 .unwrap();
13156 if label == 'B' {
13157 std::fs::write(worktree.join("change"), "winner").unwrap();
13158 git::rescue_commit(&worktree, "candidate change")
13159 .await
13160 .unwrap();
13161 }
13162 runner.state.candidates.push(Candidate {
13163 index,
13164 label,
13165 agent: "alpha".into(),
13166 branch,
13167 worktree,
13168 summary: "completed reply".into(),
13169 stat: String::new(),
13170 files: usize::from(label == 'B'),
13171 commits: usize::from(label == 'B'),
13172 empty: label == 'A',
13173 failed: None,
13174 verified_noop: None,
13175 duration_ms: 1,
13176 folded: false,
13177 });
13178 }
13179 runner.state.judge_skipped = true;
13180 runner.state.tally = Some(Tally {
13181 first_choice: BTreeMap::new(),
13182 borda: BTreeMap::new(),
13183 winner: 'B',
13184 rankings: 0,
13185 unanimous_initial: false,
13186 deliberated: false,
13187 changed_votes: 0,
13188 unanimous_final: false,
13189 tie_break: None,
13190 judges: 0,
13191 present: 0,
13192 quorum: 0,
13193 met_quorum: true,
13194 uncontested: Some("only B produced a change".into()),
13195 });
13196 runner.state.save().unwrap();
13197 (tmp, runner)
13198 }
13199
13200 #[tokio::test]
13201 async fn candidate_preservation_does_not_reflag_confirmed_or_folded_empty_candidates() {
13202 let (_tmp, mut runner) = preservation_resume_fixture().await;
13203 runner.implement().await.unwrap();
13205 assert!(runner.state.candidate_preservation.is_empty());
13206 assert!(
13207 !runner
13208 .state
13209 .dir()
13210 .join("artifacts/candidate-A-recovery")
13211 .exists()
13212 );
13213 runner.fold_losers().await.unwrap();
13214 assert!(!runner.state.candidates[0].worktree.exists());
13215 for schema in [14, crate::run::SCHEMA] {
13216 runner.state.schema = schema;
13217 runner.state = crate::run::migrate_schema(runner.state.clone()).unwrap();
13218 runner.state.save().unwrap();
13219 runner.state = RunState::load(&runner.state.id).unwrap();
13220 runner.implement().await.unwrap();
13221 assert!(runner.state.candidate_preservation.is_empty());
13222 assert!(runner.state.candidates[0].folded);
13223 assert_eq!(runner.state.tally.as_ref().unwrap().winner, 'B');
13224 }
13225 }
13226
13227 #[tokio::test]
13228 async fn candidate_preservation_legacy_recovery_blocks_stale_verdict_on_every_resume() {
13229 let (_tmp, mut runner) = preservation_resume_fixture().await;
13230 let wt = runner.state.candidates[0].worktree.clone();
13231 std::fs::write(wt.join("recovered"), "owned edits").unwrap();
13232 runner.state.schema = 14;
13233 runner.state = crate::run::migrate_schema(runner.state.clone()).unwrap();
13234 assert!(runner.state.candidate_preservation.contains_key(&'A'));
13235 let tally = serde_json::to_value(&runner.state.tally).unwrap();
13236 for _ in 0..2 {
13237 assert!(runner.implement().await.is_err());
13238 assert_eq!(runner.state.status, RunStatus::Blocked);
13239 assert!(runner.state.candidate_preservation[&'A'].contains("supported recovery"));
13240 assert!(
13241 runner.state.candidates[0].empty,
13242 "old verdict must remain unusable"
13243 );
13244 assert_eq!(serde_json::to_value(&runner.state.tally).unwrap(), tally);
13245 assert!(runner.state.judge_skipped);
13246 assert!(runner.state.judgements.is_empty() && runner.state.votes.is_empty());
13247 assert!(
13248 crate::run::read_artifact(&runner.state, "cand-A.patch")
13249 .unwrap()
13250 .contains("owned edits")
13251 );
13252 assert!(wt.exists());
13253 assert_eq!(
13254 git::commits_ahead(&wt, &runner.state.base_commit, "HEAD")
13255 .await
13256 .unwrap(),
13257 1
13258 );
13259 runner.state = RunState::load(&runner.state.id).unwrap();
13260 }
13261 }
13262
13263 #[tokio::test]
13264 async fn candidate_preservation_legacy_failed_seat_keeps_edits_before_judging() {
13265 let (_tmp, mut runner) = preservation_resume_fixture().await;
13266 let wt = runner.state.candidates[0].worktree.clone();
13267 runner.state.candidates[0].failed = Some("CLI failed before commit".into());
13268 std::fs::write(wt.join("recovered"), "failed seat edits").unwrap();
13269 let index_lock = PathBuf::from(
13270 git::git(
13271 &wt,
13272 &[
13273 "rev-parse",
13274 "--path-format=absolute",
13275 "--git-path",
13276 "index.lock",
13277 ],
13278 )
13279 .await
13280 .unwrap(),
13281 );
13282 std::fs::write(&index_lock, "fixture-owned lock").unwrap();
13283 runner.state.schema = 14;
13284 runner.state = crate::run::migrate_schema(runner.state.clone()).unwrap();
13285 assert!(runner.state.candidate_preservation.contains_key(&'A'));
13286 assert!(runner.implement().await.is_err());
13287 assert!(runner.after_implement().is_err());
13288 assert!(runner.fold_losers().await.is_err());
13289 assert_eq!(
13290 std::fs::read_to_string(wt.join("recovered")).unwrap(),
13291 "failed seat edits"
13292 );
13293 assert!(runner.state.candidate_preservation.contains_key(&'A'));
13294 runner.state = RunState::load(&runner.state.id).unwrap();
13295 std::fs::remove_file(index_lock).unwrap();
13296 assert!(runner.implement().await.is_err());
13298 assert!(runner.state.candidate_preservation[&'A'].contains("supported recovery"));
13299 assert!(runner.state.candidates[0].empty);
13300 assert_eq!(runner.state.tally.as_ref().unwrap().winner, 'B');
13301 assert!(
13302 crate::run::read_artifact(&runner.state, "cand-A.patch")
13303 .unwrap()
13304 .contains("failed seat edits")
13305 );
13306 assert!(wt.exists());
13307 }
13308
13309 #[tokio::test]
13310 async fn candidate_preservation_loser_cleanup_resumes_after_branch_and_judge_failures() {
13311 let (_tmp, mut runner) = preservation_resume_fixture().await;
13312 let repo = runner.state.repo.clone();
13313 let common = git::git(
13314 &repo,
13315 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
13316 )
13317 .await
13318 .unwrap();
13319 let branch_lock = PathBuf::from(common).join("refs/heads/candidate-A.lock");
13320 std::fs::write(&branch_lock, "fixture-owned lock").unwrap();
13321 assert!(runner.fold_losers().await.is_err());
13322 assert!(!runner.state.candidates[0].worktree.exists());
13323 assert!(!runner.state.candidates[0].folded);
13324 assert!(git::branch_exists(&repo, "candidate-A").await.unwrap());
13325 runner.state = RunState::load(&runner.state.id).unwrap();
13326 std::fs::remove_file(branch_lock).unwrap();
13327 let judge = runner.state.worktree_root().join("judge-1");
13328 git::worktree_add_detached(&repo, &judge, "main")
13329 .await
13330 .unwrap();
13331 std::fs::write(judge.join("uncommitted"), "retain").unwrap();
13332 runner.roles.judges = vec![spec("judge")];
13333 assert!(runner.fold_losers().await.is_err());
13334 runner.state = RunState::load(&runner.state.id).unwrap();
13335 assert!(
13336 runner.state.candidates[0].folded,
13337 "candidate progress saved before judge failure"
13338 );
13339 assert!(!git::branch_exists(&repo, "candidate-A").await.unwrap());
13340 assert!(judge.join("uncommitted").exists());
13341 std::fs::remove_file(judge.join("uncommitted")).unwrap();
13342 runner.fold_losers().await.unwrap();
13343 assert!(!judge.exists());
13344 runner.fold_losers().await.unwrap();
13345 }
13346
13347 #[tokio::test]
13348 async fn candidate_preservation_selected_implementer_resumes_through_execute_graph() {
13349 let (_tmp, mut runner) = preservation_resume_fixture().await;
13350 runner.state.candidates.retain(|c| c.label == 'B');
13351 runner.state.tally = None;
13352 runner.state.judge_skipped = false;
13353 runner.state.prep_complete = Some(true);
13354 runner.state.config.graph.advise = false;
13355 runner.state.config.graph.selected_implementer = Some("alpha".into());
13356 let wt = runner.state.candidates[0].worktree.clone();
13357 std::fs::write(wt.join("leftover"), "completed implementation").unwrap();
13358 runner
13359 .state
13360 .candidate_preservation
13361 .insert('B', "awaiting persistence".into());
13362 let index_lock = git::git(
13363 &wt,
13364 &[
13365 "rev-parse",
13366 "--path-format=absolute",
13367 "--git-path",
13368 "index.lock",
13369 ],
13370 )
13371 .await
13372 .unwrap();
13373 std::fs::write(&index_lock, "fixture-owned lock").unwrap();
13374 runner.state.save().unwrap();
13375 assert!(runner.execute_graph().await.is_err());
13376 assert_eq!(runner.state.status, RunStatus::Blocked);
13377 assert!(runner.state.candidate_preservation.contains_key(&'B'));
13378 std::fs::remove_file(index_lock).unwrap();
13379 for _ in 0..2 {
13382 runner.state = RunState::load(&runner.state.id).unwrap();
13383 runner.pause.park();
13384 runner.execute_graph().await.unwrap();
13385 assert_eq!(runner.state.status, RunStatus::Implementing);
13386 assert!(runner.state.candidate_preservation.is_empty());
13387 assert_eq!(runner.state.candidates[0].commits, 2);
13388 assert!(runner.state.tally.is_none());
13389 }
13390 runner.implement().await.unwrap();
13391 assert_eq!(runner.state.status, RunStatus::Judging);
13392 runner.state.status = RunStatus::Blocked;
13394 runner.state.candidates[0].failed = Some("implementation failed".into());
13395 runner.state.save().unwrap();
13396 let error = runner.execute_graph().await.unwrap_err();
13397 assert!(
13398 error
13399 .to_string()
13400 .contains("selected implementation is held")
13401 );
13402 }
13403
13404 #[tokio::test]
13405 async fn candidate_preservation_cleanup_unregisters_a_vanished_worktree() {
13406 let (_tmp, mut runner) = preservation_resume_fixture().await;
13407 let repo = runner.state.repo.clone();
13408 let candidate = runner.state.candidates[0].clone();
13409 let registered = git::worktree_holding(&repo, &candidate.branch)
13412 .await
13413 .unwrap()
13414 .unwrap();
13415 assert_eq!(
13416 registered.canonicalize().unwrap(),
13417 candidate.worktree.canonicalize().unwrap()
13418 );
13419 std::fs::remove_dir_all(&candidate.worktree).unwrap();
13420 assert_eq!(
13421 git::worktree_holding(&repo, &candidate.branch)
13422 .await
13423 .unwrap(),
13424 Some(registered)
13425 );
13426 assert!(
13427 !git::branch_delete(&repo, &candidate.branch).await.unwrap(),
13428 "the stale registration initially keeps the branch checked out"
13429 );
13430 runner.fold_losers().await.unwrap();
13431 assert!(runner.state.candidates[0].folded);
13432 assert!(!git::branch_exists(&repo, &candidate.branch).await.unwrap());
13433 assert_eq!(
13434 git::worktree_holding(&repo, &candidate.branch)
13435 .await
13436 .unwrap(),
13437 None
13438 );
13439 runner.state = RunState::load(&runner.state.id).unwrap();
13440 runner.fold_losers().await.unwrap();
13441 }
13442
13443 #[tokio::test]
13444 async fn fold_run_unregisters_a_vanished_worktree_and_can_retry() {
13445 let (_tmp, mut runner) = preservation_resume_fixture().await;
13446 let repo = runner.state.repo.clone();
13447 let candidate = runner.state.candidates[0].clone();
13448 let winner = runner.state.candidates[1].clone();
13449 let registered = git::worktree_holding(&repo, &candidate.branch)
13450 .await
13451 .unwrap()
13452 .unwrap();
13453 assert_eq!(
13454 registered.canonicalize().unwrap(),
13455 candidate.worktree.canonicalize().unwrap()
13456 );
13457 std::fs::remove_dir_all(&candidate.worktree).unwrap();
13458 assert_eq!(
13459 git::worktree_holding(&repo, &candidate.branch)
13460 .await
13461 .unwrap(),
13462 Some(registered)
13463 );
13464 assert!(!git::branch_delete(&repo, &candidate.branch).await.unwrap());
13465
13466 for _ in 0..2 {
13467 fold_run(&mut runner.state, false, &crate::run::home())
13468 .await
13469 .unwrap();
13470 runner.state = RunState::load(&runner.state.id).unwrap();
13471 assert!(runner.state.candidates[0].folded);
13472 assert!(!git::branch_exists(&repo, &candidate.branch).await.unwrap());
13473 assert_eq!(
13474 git::worktree_holding(&repo, &candidate.branch)
13475 .await
13476 .unwrap(),
13477 None
13478 );
13479 assert!(!runner.state.candidates[1].folded);
13480 assert!(winner.worktree.exists());
13481 assert!(git::branch_exists(&repo, &winner.branch).await.unwrap());
13482 }
13483 }
13484
13485 #[tokio::test]
13486 async fn candidate_preservation_retries_after_upgrade_without_rerunning_seats() {
13487 ask_test_home();
13488 let tmp = tempfile::tempdir().unwrap();
13489 let repo = tmp.path().join("repo");
13490 std::fs::create_dir(&repo).unwrap();
13491 init_repo(&repo);
13492 let mut runner = runner_at(RunStatus::Implementing);
13493 runner.state.repo = repo.clone();
13494 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
13495 runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
13496 for (index, label) in ['A', 'B'].into_iter().enumerate() {
13497 let branch = format!("candidate-{label}");
13498 let wt = runner.state.worktree_root().join(format!("cand-{label}"));
13499 git::worktree_add_branch(&repo, &wt, &branch, "main")
13500 .await
13501 .unwrap();
13502 std::fs::write(wt.join("README.md"), "tracked edits\n").unwrap();
13503 git::git(&wt, &["add", "README.md"]).await.unwrap();
13504 std::fs::write(wt.join("README.md"), "later tracked edits\n").unwrap();
13505 std::fs::write(wt.join("new-file"), "untracked edits\n").unwrap();
13506 runner.state.candidates.push(Candidate {
13507 index,
13508 label,
13509 agent: "alpha".to_owned(),
13510 branch,
13511 worktree: wt,
13512 summary: "completed implementation".to_owned(),
13513 stat: String::new(),
13514 files: 0,
13515 commits: 0,
13516 empty: false,
13517 failed: None,
13518 verified_noop: None,
13519 duration_ms: 42,
13520 folded: false,
13521 });
13522 runner
13523 .state
13524 .candidate_preservation
13525 .insert(label, "awaiting persistence".to_owned());
13526 }
13527 let wt = runner.state.candidates[0].worktree.clone();
13528 let index_lock = git::git(
13529 &wt,
13530 &[
13531 "rev-parse",
13532 "--path-format=absolute",
13533 "--git-path",
13534 "index.lock",
13535 ],
13536 )
13537 .await
13538 .unwrap();
13539 std::fs::write(&index_lock, "fixture-owned lock").unwrap();
13540 runner.state.save().unwrap();
13541 assert!(runner.persist_candidates().await.is_err());
13542 assert!(runner.state.candidate_preservation.contains_key(&'A'));
13543 assert!(!runner.state.candidate_preservation.contains_key(&'B'));
13544 assert_eq!(
13545 runner.state.candidates[1].commits, 1,
13546 "other results are still collected"
13547 );
13548 assert!(
13549 !runner.state.candidates[0].empty,
13550 "failed rescue is not no change"
13551 );
13552 assert_eq!(
13553 runner.state.candidates[0].summary,
13554 "completed implementation"
13555 );
13556 assert!(runner.after_implement().is_err());
13557 let home = tmp.path().join("fold-home");
13558 assert!(fold_run(&mut runner.state, true, &home).await.is_err());
13559 assert!(wt.join("new-file").exists());
13560 let recovery = runner.state.dir().join("artifacts/candidate-A-recovery");
13561 assert!(recovery.join("staged.patch").exists());
13562 assert!(recovery.join("unstaged.patch").exists());
13563 assert_eq!(
13564 std::fs::read_to_string(recovery.join("untracked/new-file")).unwrap(),
13565 "untracked edits\n"
13566 );
13567 runner.state.begin_pass();
13568 runner.state.end_pass_parked(ParkCause::Upgrade {
13569 from: "1.0".into(),
13570 to: Some("1.1".into()),
13571 });
13572 runner.state.save().unwrap();
13573 runner.state = RunState::load(&runner.state.id).unwrap();
13574 assert!(matches!(
13575 runner.state.passes.last().unwrap().end,
13576 Some(crate::run::PassEnd::Parked {
13577 cause: ParkCause::Upgrade { .. }
13578 })
13579 ));
13580 std::fs::remove_file(index_lock).unwrap();
13581 runner.implement().await.unwrap();
13583 assert!(runner.state.candidate_preservation.is_empty());
13584 assert!(
13585 runner
13586 .state
13587 .candidates
13588 .iter()
13589 .all(|c| c.commits == 1 && !c.empty)
13590 );
13591 assert!(git::is_clean(&wt).await.unwrap());
13592 assert!(
13593 crate::run::read_artifact(&runner.state, "cand-A.patch")
13594 .unwrap()
13595 .contains("later tracked edits")
13596 );
13597 }
13598
13599 #[tokio::test]
13600 async fn candidate_preservation_git_read_and_cleanup_fail_closed() {
13601 ask_test_home();
13602 let tmp = tempfile::tempdir().unwrap();
13603 let repo = tmp.path().join("repo");
13604 std::fs::create_dir(&repo).unwrap();
13605 init_repo(&repo);
13606 let wt = tmp.path().join("candidate");
13607 git::worktree_add_branch(&repo, &wt, "candidate", "main")
13608 .await
13609 .unwrap();
13610 std::fs::write(wt.join("uncommitted"), "keep").unwrap();
13611 assert!(remove_preserved_worktree(&repo, &wt).await.is_err());
13612 assert!(wt.join("uncommitted").exists());
13613 assert!(git::branch_exists(&repo, "candidate").await.unwrap());
13614 std::fs::remove_file(wt.join("uncommitted")).unwrap();
13615 let mut runner = runner_at(RunStatus::Implementing);
13616 runner.state.repo = repo.clone();
13617 runner.state.base_commit = "missing-base".to_owned();
13618 runner.state.candidates.push(Candidate {
13619 index: 0,
13620 label: 'A',
13621 agent: "alpha".into(),
13622 branch: "candidate".into(),
13623 worktree: wt.clone(),
13624 summary: "completed".into(),
13625 stat: String::new(),
13626 files: 0,
13627 commits: 0,
13628 empty: false,
13629 failed: None,
13630 verified_noop: None,
13631 duration_ms: 1,
13632 folded: false,
13633 });
13634 std::fs::write(wt.join("uncommitted"), "keep").unwrap();
13635 assert!(
13636 fold_run(&mut runner.state, true, &tmp.path().join("home"))
13637 .await
13638 .is_err()
13639 );
13640 assert!(!runner.state.candidates[0].folded);
13641 assert!(git::branch_exists(&repo, "candidate").await.unwrap());
13642 std::fs::remove_file(wt.join("uncommitted")).unwrap();
13643 runner
13644 .state
13645 .candidate_preservation
13646 .insert('A', "pending".into());
13647 assert!(runner.persist_candidates().await.is_err());
13648 assert!(!runner.state.candidates[0].empty);
13649 assert!(!runner.state.candidates[0].folded);
13650 assert!(wt.exists());
13651 runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
13652 let patch_path = runner.state.dir().join("artifacts/cand-A.patch");
13653 std::fs::create_dir_all(&patch_path).unwrap();
13654 assert!(
13655 runner.persist_candidates().await.is_err(),
13656 "artifact failure stays pending"
13657 );
13658 assert!(runner.state.candidate_preservation.contains_key(&'A'));
13659 assert!(!runner.state.candidates[0].empty);
13660 std::fs::remove_dir(patch_path).unwrap();
13661 runner.persist_candidates().await.unwrap();
13662 assert!(
13663 runner.state.candidates[0].empty,
13664 "only successful inspection confirms no change"
13665 );
13666 }
13667
13668 fn init_repo(dir: &Path) {
13669 let run = |args: &[&str]| {
13670 let out = std::process::Command::new("git")
13671 .args(args)
13672 .current_dir(dir)
13673 .quiet()
13674 .output()
13675 .expect("spawn git");
13676 assert!(
13677 out.status.success(),
13678 "git {args:?} failed: {}",
13679 String::from_utf8_lossy(&out.stderr)
13680 );
13681 };
13682 run(&["init", "-b", "main"]);
13683 run(&["config", "user.name", "magi test"]);
13684 run(&["config", "user.email", "magi@example.com"]);
13685 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
13686 run(&["add", "-A"]);
13687 run(&["commit", "-m", "init"]);
13688 }
13689
13690 fn ask_test_home() {
13698 crate::run::pin_test_home();
13699 }
13700
13701 fn runner_at(status: RunStatus) -> Runner {
13704 let mut state = RunState::new(
13705 PathBuf::from("/nonexistent/repo"),
13706 "main".to_owned(),
13707 "deadbeef".to_owned(),
13708 "task".to_owned(),
13709 Config::default(),
13710 );
13711 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13714 state.id = format!(
13715 "20000101-000000-{:04x}",
13716 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
13717 );
13718 state.status = status;
13719 Runner {
13720 state,
13721 roles: ResolvedRoles {
13722 implementers: Vec::new(),
13723 judges: Vec::new(),
13724 reviewers: Vec::new(),
13725 fixer: None,
13726 conductor: conductor(),
13727 implementer_roster: Vec::new(),
13728 judge_roster: Vec::new(),
13729 reviewer_roster: Vec::new(),
13730 },
13731 sem: Arc::new(Semaphore::new(1)),
13732 pause: Pause::new(),
13733 interrupt: Pause::new(),
13734 }
13735 }
13736
13737 #[test]
13738 fn a_failed_driver_lease_record_releases_the_claim_and_retained_state() {
13739 crate::run::pin_test_home();
13740 let mut runner = runner_at(RunStatus::Gating);
13741 runner.state.save().unwrap();
13742 std::fs::create_dir(runner.state.dir().join("driver.json.tmp")).unwrap();
13745 assert!(runner.claim_driver().is_err());
13746 assert!(runner.state.driver_exited);
13747 assert_eq!(runner.state.driver_pid, None);
13748 let disk = RunState::load(&runner.state.id).unwrap();
13749 assert!(disk.driver_exited);
13750 assert_eq!(disk.driver_pid, None);
13751 assert_eq!(disk.status, RunStatus::Gating);
13752 }
13753
13754 #[tokio::test]
13755 async fn an_early_graph_error_releases_its_driver() {
13756 crate::run::pin_test_home();
13757 let mut runner = runner_at(RunStatus::Blocked);
13758 runner.state.config.graph.selected_implementer = Some("selected".into());
13759 runner.state.save().unwrap();
13760 assert!(runner.execute().await.is_err());
13761 let disk = RunState::load(&runner.state.id).unwrap();
13762 assert!(disk.driver_exited);
13763 assert_eq!(disk.driver_pid, None);
13764 assert_eq!(disk.status, RunStatus::Blocked);
13765 }
13766
13767 fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
13768 crate::run::MergeRepair {
13769 question: "repair-question".into(),
13770 head: "old-head".into(),
13771 pr: "https://github.com/o/r/pull/42".into(),
13772 reviews: vec![review_round(false, 1, 1, 1, false, true)],
13773 gate: vec![outcome(Some(0), false)],
13774 gate_head: Some("old-head".into()),
13775 gate_fixes: Vec::new(),
13776 pre_gate: Vec::new(),
13777 phase,
13778 failure: None,
13779 }
13780 }
13781
13782 #[tokio::test]
13783 async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
13784 crate::run::pin_test_home();
13785 let mut runner = runner_at(RunStatus::Landing);
13786 runner.state.merge_repairs.push(merge_repair_request(
13787 crate::run::MergeRepairPhase::Requested,
13788 ));
13789 runner.state.save().unwrap();
13790 let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
13791 assert!(
13792 runner
13793 .execute()
13794 .await
13795 .unwrap_err()
13796 .to_string()
13797 .contains("already running")
13798 );
13799 assert_eq!(
13800 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13801 crate::run::MergeRepairPhase::Requested
13802 );
13803 drop(claim);
13804 let mut disk = runner.state.clone();
13805 disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
13806 disk.save().unwrap();
13807 assert!(
13808 runner
13809 .execute()
13810 .await
13811 .unwrap_err()
13812 .to_string()
13813 .contains("decision changed")
13814 );
13815 assert_eq!(
13816 RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13817 crate::run::MergeRepairPhase::Complete
13818 );
13819 }
13820
13821 #[test]
13822 fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
13823 crate::run::pin_test_home();
13824 let mut runner = runner_at(RunStatus::Landing);
13825 let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
13826 runner.state.reviews = request.reviews.clone();
13827 runner.state.gate = request.gate.clone();
13828 runner.state.gate_ran = true;
13829 runner.state.land_approval = Some(crate::run::LandApproval {
13830 question: "old".into(),
13831 head: "old-head".into(),
13832 });
13833 runner.state.merge_repairs.push(request);
13834 runner.begin_merge_repair_review(0).unwrap();
13835 assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
13836 assert!(!runner.state.gate_status().ok());
13837 assert!(runner.state.land_approval.is_none());
13838 let disk = RunState::load(&runner.state.id).unwrap();
13839 assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
13840 assert!(disk.merge_repairs[0].gate[0].ok());
13841 assert_eq!(
13842 disk.merge_repairs[0].phase,
13843 crate::run::MergeRepairPhase::Reviewing
13844 );
13845 assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
13846 assert!(disk.reviews.is_empty());
13847 }
13848
13849 #[tokio::test]
13850 async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
13851 crate::run::pin_test_home();
13852 for phase in [
13853 crate::run::MergeRepairPhase::Fixing,
13854 crate::run::MergeRepairPhase::Requested,
13855 ] {
13856 let mut runner = runner_at(RunStatus::Landing);
13857 runner.state.config.graph.land_rounds = 0;
13858 runner.state.merge_repairs.push(merge_repair_request(phase));
13859 runner.state.save().unwrap();
13860 assert!(!runner.repair_merge_review().await.unwrap());
13861 assert_eq!(runner.state.status, RunStatus::Blocked);
13862 assert_eq!(
13863 runner.state.merge_repairs[0].phase,
13864 crate::run::MergeRepairPhase::Failed
13865 );
13866 let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
13867 assert!(
13868 reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
13869 "interrupted"
13870 } else {
13871 "budget exhausted"
13872 })
13873 );
13874 assert!(runner.state.merge.is_none());
13875 }
13876 }
13877
13878 #[test]
13882 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
13883 crate::run::pin_test_home();
13884 let mut runner = runner_at(RunStatus::Implementing);
13885 let interrupt = Pause::new();
13886 runner.watch_interrupt(interrupt.clone());
13887
13888 interrupt.park_because("task a1b2 asked to run first");
13889
13890 assert!(runner.park_here().expect("park_here"));
13891 assert!(runner.state.parked);
13892 let last = runner.state.events.last().expect("a park event");
13893 assert_eq!(last.node, "park");
13894 assert!(
13895 last.message.contains("task a1b2 asked to run first"),
13896 "expected the interrupt reason in {:?}",
13897 last.message
13898 );
13899 }
13900
13901 #[test]
13904 fn park_here_records_the_structured_cause_and_the_first_one_wins() {
13905 crate::run::pin_test_home();
13906 let mut runner = runner_at(RunStatus::Implementing);
13907 runner.state.begin_pass();
13908 let shutdown = Pause::new();
13909 runner.on_pause(shutdown.clone());
13910 let upgrade = ParkCause::Upgrade {
13911 from: "0.121.0".to_owned(),
13912 to: Some("0.121.1".to_owned()),
13913 };
13914 shutdown.park_for(upgrade.clone());
13915 shutdown.park_for(ParkCause::Operator);
13916
13917 assert!(runner.park_here().expect("park_here"));
13918 assert_eq!(
13919 runner.state.passes[0].end,
13920 Some(crate::run::PassEnd::Parked { cause: upgrade })
13921 );
13922 let last = runner.state.events.last().expect("a park event");
13923 assert!(last.message.contains("0.121.0"), "{}", last.message);
13924 }
13925
13926 #[test]
13934 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
13935 crate::run::pin_test_home();
13936 let mut runner = runner_at(RunStatus::Implementing);
13937 let shutdown = Pause::new();
13938 runner.on_pause(shutdown.clone());
13939 let interrupt = Pause::new();
13940 runner.watch_interrupt(interrupt.clone());
13941
13942 assert!(!runner.park_here().expect("park_here"));
13944 assert!(!runner.state.parked);
13945
13946 interrupt.park_because("test");
13948 assert!(!shutdown.parked());
13949 assert!(runner.park_here().expect("park_here"));
13950 }
13951
13952 #[tokio::test]
13966 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
13967 crate::run::pin_test_home();
13968 let mut runner = runner_at(RunStatus::Implementing);
13969 let interrupt = Pause::new();
13970 runner.watch_interrupt(interrupt.clone());
13971
13972 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
13973 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
13974
13975 let node = async move {
13979 started_tx.send(()).expect("send started");
13980 finish_rx.await.expect("recv finish");
13981 "node finished"
13982 };
13983
13984 let interrupter = async move {
13985 started_rx.await.expect("recv started");
13986 interrupt.park_because("higher-priority task waiting");
13988 tokio::task::yield_now().await;
13992 finish_tx.send(()).expect("send finish");
13993 };
13994
13995 let (node_result, ()) = tokio::join!(node, interrupter);
13996 assert_eq!(
13997 node_result, "node finished",
13998 "the in-flight call ran to completion"
13999 );
14000
14001 assert!(runner.park_here().expect("park_here"));
14004 assert!(runner.state.parked);
14005 }
14006
14007 #[test]
14013 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
14014 crate::run::pin_test_home();
14015 let mut runner = runner_at(RunStatus::Judging);
14016 runner.state.config.agents = vec![conductor()];
14020 runner.state.candidates = vec![Candidate {
14021 index: 0,
14022 label: 'A',
14023 agent: "alpha".to_owned(),
14024 branch: "magi/x/A".to_owned(),
14025 worktree: PathBuf::from("/nonexistent/worktree"),
14026 summary: "did the thing".to_owned(),
14027 stat: "1 file changed".to_owned(),
14028 files: 1,
14029 commits: 1,
14030 empty: false,
14031 failed: None,
14032 verified_noop: None,
14033 duration_ms: 1234,
14034 folded: false,
14035 }];
14036 let run_id = runner.state.id.clone();
14037
14038 let interrupt = Pause::new();
14039 runner.watch_interrupt(interrupt.clone());
14040 interrupt.park_because("task c3d4 asked to run first");
14041 assert!(runner.park_here().expect("park_here"));
14042
14043 let resumed = Runner::resume(&run_id).expect("resume");
14044 assert_eq!(resumed.state.candidates.len(), 1);
14045 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
14046 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
14047 assert_eq!(resumed.state.status, runner.state.status);
14048 assert!(
14049 resumed.state.parked,
14050 "still parked until `execute` actually walks the graph again"
14051 );
14052 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
14053 }
14054
14055 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
14057 let mut q = ask::Question::new(
14058 run.to_owned(),
14059 "implement".to_owned(),
14060 "impl-A".to_owned(),
14061 "Which storage backend should the cache use?".to_owned(),
14062 String::new(),
14063 vec!["SQLite".to_owned(), "Redis".to_owned()],
14064 );
14065 store.put(&mut q).unwrap();
14066 q
14067 }
14068
14069 #[test]
14070 fn a_failed_runs_open_question_is_abandoned() {
14071 ask_test_home();
14072 let store = ask::Questions::open();
14073 let mut runner = runner_at(RunStatus::Failed);
14074 let run = runner.state.id.clone();
14075 let q = ask_open_question(&store, &run);
14076
14077 runner.settle_questions();
14078
14079 let back = store.get(&q.id).unwrap();
14080 assert!(
14081 !back.status.open(),
14082 "the seat that asked died with the run; nobody is left to read an answer"
14083 );
14084 assert!(
14085 back.detail.contains(&run) && back.detail.contains("failed"),
14086 "the reason names what the run became, not just that it is gone: {}",
14087 back.detail
14088 );
14089 }
14090
14091 #[test]
14092 fn a_merged_runs_open_question_is_abandoned_too() {
14093 ask_test_home();
14094 let store = ask::Questions::open();
14095 for status in [RunStatus::Merged, RunStatus::Ready] {
14098 let mut runner = runner_at(status);
14099 let run = runner.state.id.clone();
14100 let q = ask_open_question(&store, &run);
14101
14102 runner.settle_questions();
14103
14104 let back = store.get(&q.id).unwrap();
14105 assert!(
14106 !back.status.open(),
14107 "{status:?} run's question must not outlive the run"
14108 );
14109 }
14110 }
14111
14112 #[test]
14113 fn a_still_resumable_runs_open_question_is_left_alone() {
14114 ask_test_home();
14115 let store = ask::Questions::open();
14116 for status in [RunStatus::Blocked, RunStatus::Stalled] {
14122 let mut runner = runner_at(status);
14123 let run = runner.state.id.clone();
14124 let q = ask_open_question(&store, &run);
14125
14126 runner.settle_questions();
14127
14128 let back = store.get(&q.id).unwrap();
14129 assert!(
14130 back.status.open(),
14131 "{status:?} is still alive; the question must still be waiting"
14132 );
14133 }
14134 }
14135
14136 #[test]
14137 fn settle_questions_never_touches_an_already_answered_question() {
14138 ask_test_home();
14139 let store = ask::Questions::open();
14140 let mut runner = runner_at(RunStatus::Failed);
14141 let run = runner.state.id.clone();
14142 let mut q = ask_open_question(&store, &run);
14143 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
14144 .unwrap();
14145 store.put(&mut q).unwrap();
14146
14147 runner.settle_questions();
14152 runner.settle_questions();
14153
14154 let back = store.get(&q.id).unwrap();
14155 assert_eq!(
14156 back.status,
14157 ask::QuestionStatus::Answered,
14158 "a real answer is a decision on record, never overwritten by a sweep"
14159 );
14160 }
14161
14162 #[tokio::test]
14173 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
14174 crate::run::pin_test_home();
14175 let tmp = tempfile::tempdir().expect("tempdir");
14176 let repo = tmp.path().join("repo");
14177 std::fs::create_dir_all(&repo).unwrap();
14178 init_repo(&repo);
14179
14180 let mut config = Config::default();
14181 config.graph.worktree_root = Some(tmp.path().join("wt"));
14182
14183 let mut state = RunState::new(
14184 repo.clone(),
14185 "main".to_owned(),
14186 "deadbeef".to_owned(),
14187 "task".to_owned(),
14188 config,
14189 );
14190 let root = state.worktree_root();
14191 let wt_a = root.join("cand-A");
14192 let wt_b = root.join("cand-B");
14193 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
14194 .await
14195 .expect("worktree A");
14196 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
14197 .await
14198 .expect("worktree B");
14199
14200 state.candidates = vec![
14201 Candidate {
14202 index: 0,
14203 label: 'A',
14204 agent: "alpha".to_owned(),
14205 branch: "magi/x/A".to_owned(),
14206 worktree: wt_a.clone(),
14207 summary: String::new(),
14208 stat: String::new(),
14209 files: 0,
14210 commits: 0,
14211 empty: false,
14212 failed: None,
14213 verified_noop: None,
14214 duration_ms: 0,
14215 folded: false,
14216 },
14217 Candidate {
14218 index: 1,
14219 label: 'B',
14220 agent: "beta".to_owned(),
14221 branch: "magi/x/B".to_owned(),
14222 worktree: wt_b.clone(),
14223 summary: String::new(),
14224 stat: String::new(),
14225 files: 0,
14226 commits: 0,
14227 empty: false,
14228 failed: None,
14229 verified_noop: None,
14230 duration_ms: 0,
14231 folded: false,
14232 },
14233 ];
14234 state.tally = Some(Tally {
14235 first_choice: BTreeMap::from([('A', 1)]),
14236 borda: BTreeMap::new(),
14237 winner: 'A',
14238 rankings: 1,
14239 unanimous_initial: true,
14240 deliberated: false,
14241 changed_votes: 0,
14242 unanimous_final: true,
14243 tie_break: None,
14244 judges: 1,
14245 present: 1,
14246 quorum: 1,
14247 met_quorum: true,
14248 uncontested: None,
14249 });
14250 state.status = RunStatus::Ready;
14251
14252 fold_run(&mut state, false, &crate::run::home())
14253 .await
14254 .expect("fold_run");
14255
14256 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
14257 assert!(
14258 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
14259 "the unmerged winner's branch survives"
14260 );
14261 assert!(
14262 !state.candidates[0].folded,
14263 "the winner is not marked folded"
14264 );
14265
14266 assert!(!wt_b.exists(), "the loser's worktree is removed");
14267 assert!(
14268 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
14269 "the loser's branch is removed"
14270 );
14271 assert!(state.candidates[1].folded, "the loser is marked folded");
14272 }
14273
14274 #[tokio::test]
14275 #[cfg(unix)]
14276 async fn fold_run_waits_for_shared_lock_before_removing_worktrees_branches_or_config() {
14277 use std::os::fd::AsRawFd as _;
14278 use std::time::Duration;
14279
14280 let tmp = tempfile::tempdir().unwrap();
14281 let repo = tmp.path().join("repo");
14282 std::fs::create_dir_all(&repo).unwrap();
14283 init_repo(&repo);
14284 let common = repo.join(".git");
14285 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
14286 let home = tmp.path().join("home");
14287 let mut config = Config::default();
14288 config.coordination = crate::config::test_coordination();
14289 config.graph.worktree_root = Some(tmp.path().join("wt"));
14290 config.graph.timeout_resource_wait = 1;
14291 let mut state = RunState::new(
14292 repo.clone(),
14293 "main".into(),
14294 "deadbeef".into(),
14295 "task".into(),
14296 config,
14297 );
14298 let root = state.worktree_root();
14299 let candidate = root.join("cand-A");
14300 let reviewer = root.join("review-1");
14301 let branch = state.branch_for('A');
14302 git::worktree_add_branch(&repo, &candidate, &branch, "main")
14303 .await
14304 .unwrap();
14305 git::worktree_add_detached(&repo, &reviewer, "main")
14306 .await
14307 .unwrap();
14308 git::acquire_worktree_config(&repo).await.unwrap();
14309 state.enabled_worktree_config = true;
14310 state.status = RunStatus::Ready;
14311 state.candidates.push(Candidate {
14312 index: 0,
14313 label: 'A',
14314 agent: "implement".into(),
14315 branch: branch.clone(),
14316 worktree: candidate.clone(),
14317 summary: String::new(),
14318 stat: String::new(),
14319 files: 0,
14320 commits: 0,
14321 empty: false,
14322 failed: None,
14323 verified_noop: None,
14324 duration_ms: 0,
14325 folded: false,
14326 });
14327 state.save_under(&home).unwrap();
14328 let config_before = std::fs::read(common.join("config")).unwrap();
14329 let worktrees_before = git::git(&repo, &["worktree", "list", "--porcelain"])
14330 .await
14331 .unwrap();
14332 let external = std::fs::File::create(common.join("updater.lock")).unwrap();
14335 assert_eq!(
14336 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
14337 0
14338 );
14339 let mut fold = Box::pin(fold_run(&mut state, true, &home));
14340 assert!(
14341 tokio::time::timeout(Duration::from_millis(200), &mut fold)
14342 .await
14343 .is_err()
14344 );
14345 assert!(candidate.exists());
14346 assert!(reviewer.exists());
14347 assert!(git::branch_exists(&repo, &branch).await.unwrap());
14348 assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
14349 assert_eq!(
14350 git::git(&repo, &["worktree", "list", "--porcelain"])
14351 .await
14352 .unwrap(),
14353 worktrees_before
14354 );
14355 let error = tokio::time::timeout(Duration::from_secs(3), &mut fold)
14356 .await
14357 .unwrap()
14358 .unwrap_err();
14359 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
14360 drop(fold);
14361 assert!(candidate.exists() && reviewer.exists());
14362 assert!(!state.candidates[0].folded && state.enabled_worktree_config);
14363 assert_eq!(
14364 state.resource_block.as_ref().unwrap().kind,
14365 "repository coordination"
14366 );
14367 assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
14368 drop(external);
14369 let mut fold = Box::pin(fold_run(&mut state, true, &home));
14370 let removed = tokio::time::timeout(Duration::from_secs(10), &mut fold)
14371 .await
14372 .unwrap()
14373 .unwrap();
14374 drop(fold);
14375 assert!(removed.contains(&branch));
14376 assert!(!root.exists());
14377 assert!(!git::branch_exists(&repo, &branch).await.unwrap());
14378 assert!(state.candidates[0].folded);
14379 assert!(!state.enabled_worktree_config);
14380 assert_ne!(std::fs::read(common.join("config")).unwrap(), config_before);
14381 let enabled = git::git_raw(
14382 &repo,
14383 &["config", "--local", "--get", "extensions.worktreeConfig"],
14384 )
14385 .await
14386 .unwrap();
14387 assert!(!enabled.ok(), "the last config reference was released");
14388 }
14389
14390 #[tokio::test]
14393 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
14394 let tmp = tempfile::tempdir().expect("tempdir");
14395 let repo = tmp.path().join("repo");
14396 std::fs::create_dir_all(&repo).unwrap();
14397 init_repo(&repo);
14398 let home = tmp.path().join("home");
14399
14400 let mut config = Config::default();
14401 config.graph.worktree_root = Some(tmp.path().join("wt"));
14402 let mut state = RunState::new(
14403 repo.clone(),
14404 "main".to_owned(),
14405 "deadbeef".to_owned(),
14406 "task".to_owned(),
14407 config,
14408 );
14409 git::git(&repo, &["branch", "magi/x/A", "main"])
14411 .await
14412 .expect("branch");
14413 state.candidates = vec![Candidate {
14414 index: 0,
14415 label: 'A',
14416 agent: "alpha".to_owned(),
14417 branch: "magi/x/A".to_owned(),
14418 worktree: state.worktree_root().join("cand-A"),
14419 summary: String::new(),
14420 stat: String::new(),
14421 files: 0,
14422 commits: 0,
14423 empty: false,
14424 failed: None,
14425 verified_noop: None,
14426 duration_ms: 0,
14427 folded: true,
14428 }];
14429 state.released_to = Some("20260901-000000-new1".to_owned());
14430 state.released_branches = vec!["magi/x/A".to_owned()];
14431
14432 fold_run(&mut state, true, &home).await.expect("fold_run");
14433
14434 assert!(
14435 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
14436 "the handed-over branch survives a fold"
14437 );
14438 }
14439
14440 #[tokio::test]
14444 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
14445 let tmp = tempfile::tempdir().expect("tempdir");
14446 let repo = tmp.path().join("repo");
14447 std::fs::create_dir_all(&repo).unwrap();
14448 init_repo(&repo);
14449 let run = |args: &[&str]| {
14450 let out = std::process::Command::new("git")
14451 .quiet()
14452 .args(args)
14453 .current_dir(&repo)
14454 .output()
14455 .expect("spawn git");
14456 assert!(out.status.success(), "git {args:?}");
14457 };
14458 run(&["branch", "magi/x/A"]);
14459 run(&["checkout", "-q", "-b", "magi/x/B"]);
14460 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
14461 run(&["add", "-A"]);
14462 run(&["commit", "-q", "-m", "work"]);
14463 run(&["checkout", "-q", "main"]);
14464
14465 let probe = RunState::new(
14469 repo.clone(),
14470 "main".to_owned(),
14471 "deadbeef".to_owned(),
14472 "task".to_owned(),
14473 Config::default(),
14474 );
14475 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
14476 let bare = tmp.path().join("origin.git");
14477 let out = std::process::Command::new("git")
14478 .quiet()
14479 .args(["init", "-q", "--bare"])
14480 .arg(&bare)
14481 .output()
14482 .expect("spawn git");
14483 assert!(out.status.success(), "git init --bare");
14484 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
14485 run(&["push", "-q", "origin", "main"]);
14486
14487 let mut state = RunState::new(
14488 repo.clone(),
14489 "main".to_owned(),
14490 "deadbeef".to_owned(),
14491 "task".to_owned(),
14492 Config::default(),
14493 );
14494 state.seeds = vec![refs::Seed {
14495 token: "magi/27b2/A".to_owned(),
14496 kind: refs::SeedKind::Unresolved,
14497 sha: String::new(),
14498 branch: true,
14499 detail: "no branch or commit named magi/27b2/A".to_owned(),
14500 }];
14501
14502 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
14503 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
14504 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
14505 let detail = empty_candidate_detail(&state, "main");
14506 assert!(detail.starts_with("empty candidate"), "{detail}");
14507 assert!(detail.contains("magi/27b2/A"), "{detail}");
14508 }
14509
14510 #[tokio::test]
14519 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
14520 let tmp = tempfile::tempdir().expect("tempdir");
14521 let repo = tmp.path().join("repo");
14522 std::fs::create_dir_all(&repo).unwrap();
14523 init_repo(&repo);
14524
14525 let mut config = Config::default();
14526 config.merge.mode = MergeMode::Local;
14527
14528 let mut state = RunState::new(
14529 repo.clone(),
14530 "main".to_owned(),
14531 "deadbeef".to_owned(),
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 {
14568 round: 1,
14569 head: "deadbeef".to_owned(),
14570 verified_head: None,
14571 verified_at: None,
14572 reviews: Vec::new(),
14573 e2e: Vec::new(),
14574 fix: None,
14575 blocking: 0,
14576 answered: 0,
14577 expected: 0,
14578 clean: true,
14579 verify_retried: false,
14580 e2e_deferred: false,
14581 e2e_defer_reason: None,
14582 progressed: false,
14583 vote_split: false,
14584 reconsideration: Vec::new(),
14585 verdict: None,
14586 }];
14587 state.gate = vec![CommandOutcome {
14588 command: "test".to_owned(),
14589 code: Some(0),
14590 output_tail: String::new(),
14591 duration_ms: 0,
14592 resource_blocked: false,
14593 }];
14594 state.gate_ran = true;
14595 state.status = RunStatus::Ready;
14600 state.merge = Some(MergeOutcome {
14601 mode: MergeMode::Local,
14602 ok: false,
14603 detail: "already concluded".to_owned(),
14604 empty: false,
14605 });
14606
14607 let mut runner = Runner {
14608 state,
14609 roles: ResolvedRoles {
14610 implementers: Vec::new(),
14611 judges: Vec::new(),
14612 reviewers: Vec::new(),
14613 fixer: None,
14614 conductor: conductor(),
14615 implementer_roster: Vec::new(),
14616 judge_roster: Vec::new(),
14617 reviewer_roster: Vec::new(),
14618 },
14619 sem: Arc::new(Semaphore::new(1)),
14620 pause: Pause::new(),
14621 interrupt: Pause::new(),
14622 };
14623
14624 runner.merge().await.expect("merge");
14625
14626 assert_eq!(
14627 runner.state.status,
14628 RunStatus::Ready,
14629 "a concluded run's status must not change on reentry"
14630 );
14631 assert_eq!(
14632 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
14633 Some("already concluded"),
14634 "merge must not run again once the node already recorded an outcome"
14635 );
14636 }
14637
14638 #[tokio::test]
14647 async fn merge_refuses_a_gate_that_has_not_actually_run() {
14648 let tmp = tempfile::tempdir().expect("tempdir");
14649 let repo = tmp.path().join("repo");
14650 std::fs::create_dir_all(&repo).unwrap();
14651 init_repo(&repo);
14652
14653 let mut config = Config::default();
14654 config.merge.mode = MergeMode::Local;
14655
14656 let mut state = RunState::new(
14657 repo.clone(),
14658 "main".to_owned(),
14659 "deadbeef".to_owned(),
14660 "task".to_owned(),
14661 config,
14662 );
14663 state.candidates = vec![Candidate {
14664 index: 0,
14665 label: 'A',
14666 agent: "alpha".to_owned(),
14667 branch: "does-not-exist".to_owned(),
14668 worktree: repo.clone(),
14669 summary: String::new(),
14670 stat: String::new(),
14671 files: 0,
14672 commits: 0,
14673 empty: false,
14674 failed: None,
14675 verified_noop: None,
14676 duration_ms: 0,
14677 folded: false,
14678 }];
14679 state.tally = Some(Tally {
14680 first_choice: BTreeMap::from([('A', 1)]),
14681 borda: BTreeMap::new(),
14682 winner: 'A',
14683 rankings: 1,
14684 unanimous_initial: true,
14685 deliberated: false,
14686 changed_votes: 0,
14687 unanimous_final: true,
14688 tie_break: None,
14689 judges: 0,
14690 present: 0,
14691 quorum: 0,
14692 met_quorum: true,
14693 uncontested: Some("only candidate A produced a change".to_owned()),
14694 });
14695 state.reviews = vec![ReviewRound {
14696 round: 1,
14697 head: "deadbeef".to_owned(),
14698 verified_head: None,
14699 verified_at: None,
14700 reviews: Vec::new(),
14701 e2e: Vec::new(),
14702 fix: None,
14703 blocking: 0,
14704 answered: 0,
14705 expected: 0,
14706 clean: true,
14707 verify_retried: false,
14708 e2e_deferred: false,
14709 e2e_defer_reason: None,
14710 progressed: false,
14711 vote_split: false,
14712 reconsideration: Vec::new(),
14713 verdict: None,
14714 }];
14715 state.gate = Vec::new();
14717 state.gate_ran = false;
14718 state.status = RunStatus::Gating;
14719
14720 let mut runner = Runner {
14721 state,
14722 roles: ResolvedRoles {
14723 implementers: Vec::new(),
14724 judges: Vec::new(),
14725 reviewers: Vec::new(),
14726 fixer: None,
14727 conductor: conductor(),
14728 implementer_roster: Vec::new(),
14729 judge_roster: Vec::new(),
14730 reviewer_roster: Vec::new(),
14731 },
14732 sem: Arc::new(Semaphore::new(1)),
14733 pause: Pause::new(),
14734 interrupt: Pause::new(),
14735 };
14736
14737 runner.merge().await.expect("merge");
14738
14739 assert!(
14740 runner.state.merge.is_none(),
14741 "an empty gate must never be read as a passing one: {:?}",
14742 runner.state.merge
14743 );
14744 }
14745
14746 #[tokio::test]
14753 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
14754 let tmp = tempfile::tempdir().expect("tempdir");
14755 let repo = tmp.path().join("repo");
14756 std::fs::create_dir_all(&repo).unwrap();
14757 init_repo(&repo);
14758
14759 let config = Config::default();
14761
14762 let mut state = RunState::new(
14763 repo.clone(),
14764 "main".to_owned(),
14765 "deadbeef".to_owned(),
14766 "task".to_owned(),
14767 config,
14768 );
14769 state.candidates = vec![Candidate {
14770 index: 0,
14771 label: 'A',
14772 agent: "alpha".to_owned(),
14773 branch: "does-not-exist".to_owned(),
14774 worktree: repo.clone(),
14775 summary: String::new(),
14776 stat: String::new(),
14777 files: 0,
14778 commits: 0,
14779 empty: false,
14780 failed: None,
14781 verified_noop: None,
14782 duration_ms: 0,
14783 folded: false,
14784 }];
14785 state.tally = Some(Tally {
14786 first_choice: BTreeMap::from([('A', 1)]),
14787 borda: BTreeMap::new(),
14788 winner: 'A',
14789 rankings: 1,
14790 unanimous_initial: true,
14791 deliberated: false,
14792 changed_votes: 0,
14793 unanimous_final: true,
14794 tie_break: None,
14795 judges: 0,
14796 present: 0,
14797 quorum: 0,
14798 met_quorum: true,
14799 uncontested: Some("only candidate A produced a change".to_owned()),
14800 });
14801 state.reviews = vec![ReviewRound {
14802 round: 1,
14803 head: "deadbeef".to_owned(),
14804 verified_head: None,
14805 verified_at: None,
14806 reviews: Vec::new(),
14807 e2e: Vec::new(),
14808 fix: None,
14809 blocking: 0,
14810 answered: 0,
14811 expected: 0,
14812 clean: true,
14813 verify_retried: false,
14814 e2e_deferred: false,
14815 e2e_defer_reason: None,
14816 progressed: false,
14817 vote_split: false,
14818 reconsideration: Vec::new(),
14819 verdict: None,
14820 }];
14821
14822 let mut runner = Runner {
14823 state,
14824 roles: ResolvedRoles {
14825 implementers: Vec::new(),
14826 judges: Vec::new(),
14827 reviewers: Vec::new(),
14828 fixer: None,
14829 conductor: conductor(),
14830 implementer_roster: Vec::new(),
14831 judge_roster: Vec::new(),
14832 reviewer_roster: Vec::new(),
14833 },
14834 sem: Arc::new(Semaphore::new(1)),
14835 pause: Pause::new(),
14836 interrupt: Pause::new(),
14837 };
14838
14839 runner.gate().await.expect("gate");
14840 assert!(
14841 runner.state.gate_ran,
14842 "zero configured commands is still a real attempt, not an unrun gate"
14843 );
14844 assert!(runner.state.gate.is_empty());
14845 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
14846 assert_ne!(
14847 runner.state.status,
14848 RunStatus::Blocked,
14849 "a gate with nothing to check must not read as failed"
14850 );
14851
14852 runner.merge().await.expect("merge");
14853 assert_eq!(
14854 runner.state.status,
14855 RunStatus::Ready,
14856 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
14857 );
14858 }
14859
14860 #[tokio::test]
14871 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
14872 crate::run::pin_test_home();
14873 let home = crate::run::home();
14874
14875 let tmp = tempfile::tempdir().expect("tempdir");
14876 let repo = tmp.path().join("repo");
14877 std::fs::create_dir_all(&repo).unwrap();
14878 init_repo(&repo);
14879 let cache_dir = tmp.path().join("target");
14882
14883 let mut config = Config::default();
14884 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
14885 config.graph.timeout_verify = Some(2);
14888 config.graph.timeout_resource_wait = 2;
14889
14890 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14891 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14892 .expect("no io error acquiring directly")
14893 {
14894 crate::cache::AcquireOutcome::Acquired(g) => g,
14895 crate::cache::AcquireOutcome::Busy(b) => {
14896 panic!("expected the direct acquire to win the lease first: {b:?}")
14897 }
14898 };
14899
14900 let mut state = RunState::new(
14901 repo.clone(),
14902 "main".to_owned(),
14903 "deadbeef".to_owned(),
14904 "task".to_owned(),
14905 config,
14906 );
14907 state.candidates = vec![Candidate {
14908 index: 0,
14909 label: 'A',
14910 agent: "alpha".to_owned(),
14911 branch: "does-not-exist".to_owned(),
14912 worktree: repo.clone(),
14913 summary: String::new(),
14914 stat: String::new(),
14915 files: 0,
14916 commits: 0,
14917 empty: false,
14918 failed: None,
14919 verified_noop: None,
14920 duration_ms: 0,
14921 folded: false,
14922 }];
14923 state.tally = Some(Tally {
14924 first_choice: BTreeMap::from([('A', 1)]),
14925 borda: BTreeMap::new(),
14926 winner: 'A',
14927 rankings: 1,
14928 unanimous_initial: true,
14929 deliberated: false,
14930 changed_votes: 0,
14931 unanimous_final: true,
14932 tie_break: None,
14933 judges: 0,
14934 present: 0,
14935 quorum: 0,
14936 met_quorum: true,
14937 uncontested: Some("only candidate A produced a change".to_owned()),
14938 });
14939 state.reviews = vec![ReviewRound {
14940 round: 1,
14941 head: "deadbeef".to_owned(),
14942 verified_head: None,
14943 verified_at: None,
14944 reviews: Vec::new(),
14945 e2e: Vec::new(),
14946 fix: None,
14947 blocking: 0,
14948 answered: 0,
14949 expected: 0,
14950 clean: true,
14951 verify_retried: false,
14952 e2e_deferred: false,
14953 e2e_defer_reason: None,
14954 progressed: false,
14955 vote_split: false,
14956 reconsideration: Vec::new(),
14957 verdict: None,
14958 }];
14959
14960 let mut runner = Runner {
14961 state,
14962 roles: ResolvedRoles {
14963 implementers: Vec::new(),
14964 judges: Vec::new(),
14965 reviewers: Vec::new(),
14966 fixer: None,
14967 conductor: conductor(),
14968 implementer_roster: Vec::new(),
14969 judge_roster: Vec::new(),
14970 reviewer_roster: Vec::new(),
14971 },
14972 sem: Arc::new(Semaphore::new(1)),
14973 pause: Pause::new(),
14974 interrupt: Pause::new(),
14975 };
14976
14977 let started = std::time::Instant::now();
14978 runner.gate().await.expect("gate");
14979 assert!(
14980 started.elapsed() < Duration::from_secs(1),
14981 "a gate with nothing to run must never wait on a lease it never needed"
14982 );
14983 assert!(
14984 runner.state.gate_ran,
14985 "zero commands is still a real, immediate attempt"
14986 );
14987 assert!(runner.state.gate.is_empty());
14988 assert_ne!(
14989 runner.state.status,
14990 RunStatus::Blocked,
14991 "must not read as resource-blocked on a lease it never asked for"
14992 );
14993 }
14994
14995 #[tokio::test]
15005 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
15006 crate::run::pin_test_home();
15007
15008 let tmp = tempfile::tempdir().expect("tempdir");
15009 let repo = tmp.path().join("repo");
15010 std::fs::create_dir_all(&repo).unwrap();
15011 init_repo(&repo);
15012
15013 let mut config = Config::default();
15014 config.verify.gate = vec![
15015 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
15016 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
15017 .to_owned(),
15018 ];
15019
15020 let mut state = RunState::new(
15021 repo.clone(),
15022 "main".to_owned(),
15023 "deadbeef".to_owned(),
15024 "task".to_owned(),
15025 config,
15026 );
15027 let run_id = state.id.clone();
15028 state.candidates = vec![Candidate {
15029 index: 0,
15030 label: 'A',
15031 agent: "alpha".to_owned(),
15032 branch: "does-not-exist".to_owned(),
15033 worktree: repo.clone(),
15034 summary: String::new(),
15035 stat: String::new(),
15036 files: 0,
15037 commits: 0,
15038 empty: false,
15039 failed: None,
15040 verified_noop: None,
15041 duration_ms: 0,
15042 folded: false,
15043 }];
15044 state.tally = Some(Tally {
15045 first_choice: BTreeMap::from([('A', 1)]),
15046 borda: BTreeMap::new(),
15047 winner: 'A',
15048 rankings: 1,
15049 unanimous_initial: true,
15050 deliberated: false,
15051 changed_votes: 0,
15052 unanimous_final: true,
15053 tie_break: None,
15054 judges: 0,
15055 present: 0,
15056 quorum: 0,
15057 met_quorum: true,
15058 uncontested: Some("only candidate A produced a change".to_owned()),
15059 });
15060 state.reviews = vec![ReviewRound {
15061 round: 1,
15062 head: "deadbeef".to_owned(),
15063 verified_head: None,
15064 verified_at: None,
15065 reviews: Vec::new(),
15066 e2e: Vec::new(),
15067 fix: None,
15068 blocking: 0,
15069 answered: 0,
15070 expected: 0,
15071 clean: true,
15072 verify_retried: false,
15073 e2e_deferred: false,
15074 e2e_defer_reason: None,
15075 progressed: false,
15076 vote_split: false,
15077 reconsideration: Vec::new(),
15078 verdict: None,
15079 }];
15080
15081 let mut runner = Runner {
15082 state,
15083 roles: ResolvedRoles {
15084 implementers: Vec::new(),
15085 judges: Vec::new(),
15086 reviewers: Vec::new(),
15087 fixer: None,
15088 conductor: conductor(),
15089 implementer_roster: Vec::new(),
15090 judge_roster: Vec::new(),
15091 reviewer_roster: Vec::new(),
15092 },
15093 sem: Arc::new(Semaphore::new(1)),
15094 pause: Pause::new(),
15095 interrupt: Pause::new(),
15096 };
15097
15098 let started_marker = repo.join("started.marker");
15099 let release_marker = repo.join("release.marker");
15100 let poller = tokio::spawn(async move {
15101 for _ in 0..100 {
15106 if started_marker.exists()
15107 && let Ok(s) = crate::run::RunState::load(&run_id)
15108 && let Some(a) = s.active.get("gate")
15109 {
15110 std::fs::write(&release_marker, b"go").expect("release marker");
15111 return Some(a.clone());
15112 }
15113 tokio::time::sleep(Duration::from_millis(50)).await;
15114 }
15115 None
15116 });
15117
15118 runner.gate().await.expect("gate");
15119 let captured = poller.await.expect("poller task");
15120 let captured = captured.expect(
15121 "the poller never saw a `gate` task entry in run.json while the command was \
15122 still blocked on its own release marker",
15123 );
15124
15125 assert_eq!(captured.task.as_deref(), Some("gate"));
15126 assert_eq!(captured.node, "gate");
15127 assert_eq!(captured.index, Some(1));
15128 assert_eq!(captured.total, Some(1));
15129 assert!(
15130 captured
15131 .command
15132 .as_deref()
15133 .is_some_and(|c| c.contains("started.marker")),
15134 "{captured:?}"
15135 );
15136
15137 assert!(
15138 runner.state.active.is_empty(),
15139 "the entry must be cleared once the command actually finished: {:?}",
15140 runner.state.active
15141 );
15142 assert!(runner.state.gate_ran);
15143 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
15144 }
15145
15146 #[tokio::test]
15149 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
15150 use crate::verdict::{Finding, ReviewVote, Severity};
15151 crate::run::pin_test_home();
15152 let tmp = tempfile::tempdir().expect("tempdir");
15153 let repo = tmp.path().join("repo");
15154 std::fs::create_dir_all(&repo).unwrap();
15155 init_repo(&repo);
15156
15157 for (severity, vote, expect) in [
15158 (Severity::Major, ReviewVote::Reject, true),
15159 (Severity::Minor, ReviewVote::Reject, false),
15160 (Severity::Major, ReviewVote::Approve, false),
15161 ] {
15162 let mut round = review_round(false, 1, 1, 1, false, true);
15163 round.reviews = vec![ReviewRecord {
15164 reviewer: 1,
15165 agent: "alpha".to_owned(),
15166 summary: String::new(),
15167 findings: vec![Finding {
15168 id: "R1-1-1".to_owned(),
15169 severity,
15170 file: None,
15171 line: None,
15172 title: "t".to_owned(),
15173 detail: String::new(),
15174 }],
15175 vote: Some(vote),
15176 failed: None,
15177 duration_ms: 0,
15178 attempts: 0,
15179 }];
15180 let mut state = RunState::new(
15181 repo.clone(),
15182 "main".to_owned(),
15183 "deadbeef".to_owned(),
15184 "task".to_owned(),
15185 Config::default(),
15186 );
15187 state.reviews = vec![round];
15188 let mut runner = Runner {
15189 state,
15190 roles: ResolvedRoles {
15191 implementers: Vec::new(),
15192 judges: Vec::new(),
15193 reviewers: Vec::new(),
15194 fixer: None,
15195 conductor: conductor(),
15196 implementer_roster: Vec::new(),
15197 judge_roster: Vec::new(),
15198 reviewer_roster: Vec::new(),
15199 },
15200 sem: Arc::new(Semaphore::new(1)),
15201 pause: Pause::new(),
15202 interrupt: Pause::new(),
15203 };
15204 let shell = runner.state.config.shell();
15205 runner
15206 .stop_reviewing("round budget spent", &shell, &repo)
15207 .await
15208 .expect("stop_reviewing");
15209 assert_eq!(runner.state.status, RunStatus::Gating);
15210 assert_eq!(
15211 runner.state.contested_handoff.is_some(),
15212 expect,
15213 "{severity:?} + {vote:?}"
15214 );
15215 }
15216 }
15217
15218 #[tokio::test]
15231 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
15232 crate::run::pin_test_home();
15233 let home = crate::run::home();
15234
15235 let tmp = tempfile::tempdir().expect("tempdir");
15236 let repo = tmp.path().join("repo");
15237 std::fs::create_dir_all(&repo).unwrap();
15238 init_repo(&repo);
15239 let head = crate::git::rev_parse(&repo, "HEAD")
15240 .await
15241 .expect("rev-parse");
15242 let cache_dir = tmp.path().join("target");
15245
15246 let mut config = Config::default();
15247 config.verify.e2e = vec![format!(
15248 "CARGO_TARGET_DIR='{}' test -f README.md",
15249 cache_dir.display()
15250 )];
15251 config.graph.review_rounds = 1;
15252 config.graph.timeout_verify = Some(2);
15255 config.graph.timeout_resource_wait = 2;
15256
15257 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
15258 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
15259 .expect("no io error acquiring directly")
15260 {
15261 crate::cache::AcquireOutcome::Acquired(g) => g,
15262 crate::cache::AcquireOutcome::Busy(b) => {
15263 panic!("expected the direct acquire to win the lease first: {b:?}")
15264 }
15265 };
15266
15267 let mut state = RunState::new(
15268 repo.clone(),
15269 "main".to_owned(),
15270 head.clone(),
15271 "task".to_owned(),
15272 config,
15273 );
15274 state.candidates = vec![Candidate {
15275 index: 0,
15276 label: 'A',
15277 agent: "alpha".to_owned(),
15278 branch: "does-not-exist".to_owned(),
15279 worktree: repo.clone(),
15280 summary: String::new(),
15281 stat: String::new(),
15282 files: 0,
15283 commits: 0,
15284 empty: false,
15285 failed: None,
15286 verified_noop: None,
15287 duration_ms: 0,
15288 folded: false,
15289 }];
15290 state.tally = Some(Tally {
15291 first_choice: BTreeMap::from([('A', 1)]),
15292 borda: BTreeMap::new(),
15293 winner: 'A',
15294 rankings: 1,
15295 unanimous_initial: true,
15296 deliberated: false,
15297 changed_votes: 0,
15298 unanimous_final: true,
15299 tie_break: None,
15300 judges: 0,
15301 present: 0,
15302 quorum: 0,
15303 met_quorum: true,
15304 uncontested: Some("only candidate A produced a change".to_owned()),
15305 });
15306 state.reviews = vec![ReviewRound {
15310 round: 1,
15311 head: head.clone(),
15312 verified_head: None,
15313 verified_at: None,
15314 reviews: Vec::new(),
15315 e2e: Vec::new(),
15316 fix: None,
15317 blocking: 1,
15318 answered: 1,
15319 expected: 1,
15320 clean: false,
15321 verify_retried: false,
15322 e2e_deferred: true,
15323 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
15324 progressed: false,
15325 vote_split: false,
15326 reconsideration: Vec::new(),
15327 verdict: None,
15328 }];
15329
15330 let mut runner = Runner {
15331 state,
15332 roles: ResolvedRoles {
15333 implementers: Vec::new(),
15334 judges: Vec::new(),
15335 reviewers: Vec::new(),
15336 fixer: None,
15337 conductor: conductor(),
15338 implementer_roster: Vec::new(),
15339 judge_roster: Vec::new(),
15340 reviewer_roster: Vec::new(),
15341 },
15342 sem: Arc::new(Semaphore::new(1)),
15343 pause: Pause::new(),
15344 interrupt: Pause::new(),
15345 };
15346
15347 let shell = runner.state.config.shell();
15348 runner
15349 .stop_reviewing("round budget spent", &shell, &repo)
15350 .await
15351 .expect("stop_reviewing");
15352
15353 let last = runner.state.reviews.last().expect("round record");
15354 assert_eq!(
15355 last.e2e_status(),
15356 E2eStatus::ResourceBlocked,
15357 "the shared cache is still held; the attempt must read as blocked, not deferred or \
15358 failed: {last:?}"
15359 );
15360 assert_eq!(
15361 last.verified_head.as_deref(),
15362 Some(head.as_str()),
15363 "which commit this attempt targeted is known even though nothing finished checking \
15364 it"
15365 );
15366 let first_attempt_at = last
15367 .verified_at
15368 .expect("when this attempt ran is known too");
15369 assert_ne!(
15370 runner.state.status,
15371 RunStatus::Blocked,
15372 "contention is evidence about the machine, not the patch — it must not settle the \
15373 run as blocked: {:?}",
15374 runner.state.status
15375 );
15376 assert!(
15377 !runner
15378 .state
15379 .events
15380 .iter()
15381 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
15382 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
15383 runner.state.events
15384 );
15385
15386 runner
15391 .stop_reviewing("round budget spent", &shell, &repo)
15392 .await
15393 .expect("stop_reviewing retry");
15394 assert_eq!(
15395 runner.state.reviews.len(),
15396 1,
15397 "no new round was started: {:?}",
15398 runner.state.reviews
15399 );
15400 let last = runner.state.reviews.last().expect("round record");
15401 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
15402 assert!(
15403 last.verified_at.expect("still known") > first_attempt_at,
15404 "a second reentry must be a fresh attempt, not a stale copy of the first"
15405 );
15406 assert_ne!(runner.state.status, RunStatus::Blocked);
15407
15408 held.release();
15409 }
15410
15411 #[tokio::test]
15423 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
15424 crate::run::pin_test_home();
15425 let home = crate::run::home();
15426
15427 let tmp = tempfile::tempdir().expect("tempdir");
15428 let repo = tmp.path().join("repo");
15429 std::fs::create_dir_all(&repo).unwrap();
15430 init_repo(&repo);
15431 let head = crate::git::rev_parse(&repo, "HEAD")
15432 .await
15433 .expect("rev-parse");
15434 let cache_dir = tmp.path().join("target");
15435
15436 let mut config = Config::default();
15437 config.verify.e2e = vec![format!(
15438 "CARGO_TARGET_DIR='{}' test -f README.md",
15439 cache_dir.display()
15440 )];
15441 config.graph.review_rounds = 1;
15442 config.graph.timeout_verify = Some(2);
15443 config.graph.timeout_resource_wait = 2;
15444
15445 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
15446 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
15447 .expect("no io error acquiring directly")
15448 {
15449 crate::cache::AcquireOutcome::Acquired(g) => g,
15450 crate::cache::AcquireOutcome::Busy(b) => {
15451 panic!("expected the direct acquire to win the lease first: {b:?}")
15452 }
15453 };
15454
15455 let mut state = RunState::new(
15456 repo.clone(),
15457 "main".to_owned(),
15458 head.clone(),
15459 "task".to_owned(),
15460 config,
15461 );
15462 state.candidates = vec![Candidate {
15463 index: 0,
15464 label: 'A',
15465 agent: "alpha".to_owned(),
15466 branch: "does-not-exist".to_owned(),
15467 worktree: repo.clone(),
15468 summary: String::new(),
15469 stat: String::new(),
15470 files: 0,
15471 commits: 0,
15472 empty: false,
15473 failed: None,
15474 verified_noop: None,
15475 duration_ms: 0,
15476 folded: false,
15477 }];
15478 state.tally = Some(Tally {
15479 first_choice: BTreeMap::from([('A', 1)]),
15480 borda: BTreeMap::new(),
15481 winner: 'A',
15482 rankings: 1,
15483 unanimous_initial: true,
15484 deliberated: false,
15485 changed_votes: 0,
15486 unanimous_final: true,
15487 tie_break: None,
15488 judges: 0,
15489 present: 0,
15490 quorum: 0,
15491 met_quorum: true,
15492 uncontested: Some("only candidate A produced a change".to_owned()),
15493 });
15494 state.reviews = vec![ReviewRound {
15498 round: 1,
15499 head: head.clone(),
15500 verified_head: Some(head.clone()),
15501 verified_at: Some(jiff::Timestamp::now()),
15502 reviews: Vec::new(),
15503 e2e: vec![CommandOutcome {
15504 command: format!(
15505 "CARGO_TARGET_DIR='{}' test -f README.md",
15506 cache_dir.display()
15507 ),
15508 code: None,
15509 output_tail: "waiting for the shared build cache".to_owned(),
15510 duration_ms: 0,
15511 resource_blocked: true,
15512 }],
15513 fix: None,
15514 blocking: 1,
15515 answered: 1,
15516 expected: 1,
15517 clean: false,
15518 verify_retried: false,
15519 e2e_deferred: false,
15520 e2e_defer_reason: None,
15521 progressed: false,
15522 vote_split: false,
15523 reconsideration: Vec::new(),
15524 verdict: None,
15525 }];
15526
15527 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
15528 let mut runner = Runner {
15529 state,
15530 roles: ResolvedRoles {
15531 implementers: Vec::new(),
15532 judges: Vec::new(),
15533 reviewers: Vec::new(),
15534 fixer: None,
15535 conductor: conductor(),
15536 implementer_roster: Vec::new(),
15537 judge_roster: Vec::new(),
15538 reviewer_roster: Vec::new(),
15539 },
15540 sem: Arc::new(Semaphore::new(1)),
15541 pause: Pause::new(),
15542 interrupt: Pause::new(),
15543 };
15544
15545 runner.review_loop().await.expect("review_loop");
15550
15551 assert_eq!(
15552 runner.state.reviews.len(),
15553 1,
15554 "no new round was started on top of the unresolved one: {:?}",
15555 runner.state.reviews
15556 );
15557 let last = &runner.state.reviews[0];
15558 assert_eq!(
15559 last.e2e_status(),
15560 E2eStatus::ResourceBlocked,
15561 "still contended: {last:?}"
15562 );
15563 assert!(
15564 last.verified_at.expect("still known") > first_attempt_at,
15565 "review_loop must have actually retried the check, not left it exactly as found"
15566 );
15567 assert_ne!(
15568 runner.state.status,
15569 RunStatus::Blocked,
15570 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
15571 runner.state.status
15572 );
15573
15574 held.release();
15575 }
15576
15577 #[tokio::test]
15578 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
15579 crate::run::pin_test_home();
15580 let tmp = tempfile::tempdir().expect("tempdir");
15581 let repo = tmp.path().join("repo");
15582 std::fs::create_dir_all(&repo).unwrap();
15583 init_repo(&repo);
15584
15585 let mut config = Config::default();
15586 config.merge.mode = MergeMode::Pr;
15587 config.graph.land = true;
15588 config.graph.land_approval = false;
15589
15590 let mut state = RunState::new(
15591 repo.clone(),
15592 "main".to_owned(),
15593 "deadbeef".to_owned(),
15594 "task".to_owned(),
15595 config,
15596 );
15597 state.candidates = vec![Candidate {
15598 index: 0,
15599 label: 'A',
15600 agent: "alpha".to_owned(),
15601 branch: "does-not-exist".to_owned(),
15602 worktree: repo.clone(),
15603 summary: String::new(),
15604 stat: String::new(),
15605 files: 0,
15606 commits: 0,
15607 empty: false,
15608 failed: None,
15609 verified_noop: None,
15610 duration_ms: 0,
15611 folded: false,
15612 }];
15613 state.tally = Some(Tally {
15614 first_choice: BTreeMap::from([('A', 1)]),
15615 borda: BTreeMap::new(),
15616 winner: 'A',
15617 rankings: 1,
15618 unanimous_initial: true,
15619 deliberated: false,
15620 changed_votes: 0,
15621 unanimous_final: true,
15622 tie_break: None,
15623 judges: 0,
15624 present: 0,
15625 quorum: 0,
15626 met_quorum: true,
15627 uncontested: Some("only candidate A produced a change".to_owned()),
15628 });
15629 state.reviews = vec![ReviewRound {
15630 round: 1,
15631 head: "deadbeef".to_owned(),
15632 verified_head: None,
15633 verified_at: None,
15634 reviews: Vec::new(),
15635 e2e: Vec::new(),
15636 fix: None,
15637 blocking: 0,
15638 answered: 0,
15639 expected: 0,
15640 clean: true,
15641 verify_retried: false,
15642 e2e_deferred: false,
15643 e2e_defer_reason: None,
15644 progressed: false,
15645 vote_split: false,
15646 reconsideration: Vec::new(),
15647 verdict: None,
15648 }];
15649 state.gate = vec![CommandOutcome {
15650 command: "test".to_owned(),
15651 code: Some(0),
15652 output_tail: String::new(),
15653 duration_ms: 0,
15654 resource_blocked: false,
15655 }];
15656 state.gate_ran = true;
15657 state.status = RunStatus::Landing;
15661 state.merge = Some(MergeOutcome {
15662 mode: MergeMode::Pr,
15663 ok: true,
15664 detail: "https://example.invalid/x/y/pull/1".to_owned(),
15665 empty: false,
15666 });
15667
15668 ask_test_home();
15672 state.save().expect("save landing run");
15675 let store = ask::Questions::open();
15676 let q = ask_open_question(&store, &state.id);
15677
15678 let mut runner = Runner {
15679 state,
15680 roles: ResolvedRoles {
15681 implementers: Vec::new(),
15682 judges: Vec::new(),
15683 reviewers: Vec::new(),
15684 fixer: None,
15685 conductor: conductor(),
15686 implementer_roster: Vec::new(),
15687 judge_roster: Vec::new(),
15688 reviewer_roster: Vec::new(),
15689 },
15690 sem: Arc::new(Semaphore::new(1)),
15691 pause: Pause::new(),
15692 interrupt: Pause::new(),
15693 };
15694
15695 runner.execute().await.expect("execute");
15700
15701 assert_eq!(
15702 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
15703 Some("https://example.invalid/x/y/pull/1"),
15704 "reentry must not push again or open a second pull request over the \
15705 one `land` is already watching"
15706 );
15707 assert_ne!(
15708 runner.state.status,
15709 RunStatus::Landing,
15710 "land could not actually reach the fake pull request, so it must \
15711 have given up rather than left the run silently parked forever"
15712 );
15713 assert_eq!(runner.state.status, RunStatus::Blocked);
15717 assert!(
15718 store.get(&q.id).unwrap().status.open(),
15719 "Blocked is still alive; settle_questions must have been a no-op here"
15720 );
15721 }
15722
15723 fn state_with_round(round: ReviewRound) -> RunState {
15724 let mut s = RunState::new(
15725 PathBuf::from("/repo"),
15726 "main".to_owned(),
15727 "abc1234".to_owned(),
15728 "add retries".to_owned(),
15729 Config::default(),
15730 );
15731 s.reviews = vec![round];
15732 s
15733 }
15734
15735 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
15736 crate::verdict::Finding {
15737 id: id.to_owned(),
15738 severity,
15739 file: None,
15740 line: None,
15741 title: title.to_owned(),
15742 detail: String::new(),
15743 }
15744 }
15745
15746 #[test]
15747 fn pr_body_names_open_findings_and_declined_ones() {
15748 let round = ReviewRound {
15749 round: 2,
15750 head: "deadbee".to_owned(),
15751 verified_head: None,
15752 verified_at: None,
15753 reviews: vec![ReviewRecord {
15754 attempts: 0,
15755 reviewer: 1,
15756 agent: "alpha".to_owned(),
15757 summary: String::new(),
15758 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
15759 vote: None,
15760 failed: None,
15761 duration_ms: 0,
15762 }],
15763 e2e: vec![CommandOutcome {
15764 command: "cargo test".to_owned(),
15765 code: Some(0),
15766 output_tail: String::new(),
15767 duration_ms: 0,
15768 resource_blocked: false,
15769 }],
15770 verify_retried: false,
15771 e2e_deferred: false,
15772 e2e_defer_reason: None,
15773 fix: Some(FixRecord {
15774 agent: "alpha".to_owned(),
15775 addressed: Vec::new(),
15776 rejected: vec![crate::verdict::Rejection {
15777 id: "R1-1-1".to_owned(),
15778 why: "not reachable from any caller".to_owned(),
15779 }],
15780 notes: String::new(),
15781 committed: true,
15782 failed: None,
15783 duration_ms: 0,
15784 continuation: None,
15785 }),
15786 blocking: 0,
15787 answered: 1,
15788 expected: 1,
15789 clean: false,
15790 progressed: true,
15791 vote_split: false,
15792 reconsideration: Vec::new(),
15793 verdict: None,
15794 };
15795 let state = state_with_round(round);
15796 let body = pr_message(&state, 'A').body;
15797
15798 assert!(body.contains("add retries"), "the task must still be there");
15799 assert!(body.contains("R2-1-1"), "{body}");
15800 assert!(!body.contains("unused import"), "{body}");
15801 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
15802 assert!(
15803 !body.contains("not reachable from any caller"),
15804 "the reason it was declined: {body}"
15805 );
15806 }
15807
15808 #[test]
15809 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
15810 let round = ReviewRound {
15811 round: 1,
15812 head: "deadbee".to_owned(),
15813 verified_head: None,
15814 verified_at: None,
15815 reviews: vec![ReviewRecord {
15816 attempts: 0,
15817 reviewer: 1,
15818 agent: "alpha".to_owned(),
15819 summary: String::new(),
15820 findings: Vec::new(),
15821 vote: None,
15822 failed: None,
15823 duration_ms: 0,
15824 }],
15825 e2e: Vec::new(),
15826 verify_retried: false,
15827 e2e_deferred: false,
15828 e2e_defer_reason: None,
15829 fix: None,
15830 blocking: 0,
15831 answered: 1,
15832 expected: 1,
15833 clean: true,
15834 progressed: false,
15835 vote_split: false,
15836 reconsideration: Vec::new(),
15837 verdict: None,
15838 };
15839 let state = state_with_round(round);
15840 let body = pr_message(&state, 'A').body;
15841 assert!(!body.contains("Open review findings"), "{body}");
15842 assert!(!body.contains("Declined"), "{body}");
15843 }
15844
15845 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
15846 let mut state = RunState::new(
15847 PathBuf::from("/repo"),
15848 "main".to_owned(),
15849 "abc1234".to_owned(),
15850 instruction.to_owned(),
15851 Config::default(),
15852 );
15853 state.candidates.push(Candidate {
15854 index: 0,
15855 label: 'A',
15856 agent: "alpha".to_owned(),
15857 branch: "magi/x/A".to_owned(),
15858 worktree: PathBuf::from("/wt"),
15859 summary: summary.to_owned(),
15860 stat: String::new(),
15861 files: 1,
15862 commits: 1,
15863 empty: false,
15864 failed: None,
15865 verified_noop: None,
15866 folded: false,
15867 duration_ms: 0,
15868 });
15869 state
15870 }
15871
15872 fn review_state(subjects: &[&str]) -> RunState {
15873 let mut state = state_with_summary(
15874 "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",
15875 "",
15876 );
15877 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15878 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
15879 state
15880 }
15881
15882 #[tokio::test]
15885 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
15886 ask_test_home();
15887 let tmp = tempfile::tempdir().unwrap();
15888 let repo = tmp.path().join("repo");
15889 std::fs::create_dir_all(&repo).unwrap();
15890 init_repo(&repo);
15891 let origin = tmp.path().join("origin.git");
15892 let g = |dir: &Path, args: &[&str]| {
15893 let out = std::process::Command::new("git")
15894 .args(args)
15895 .current_dir(dir)
15896 .quiet()
15897 .output()
15898 .expect("spawn git");
15899 assert!(
15900 out.status.success(),
15901 "git {args:?}: {}",
15902 String::from_utf8_lossy(&out.stderr)
15903 );
15904 };
15905 g(
15906 tmp.path(),
15907 &[
15908 "clone",
15909 "--bare",
15910 "-q",
15911 repo.to_str().unwrap(),
15912 origin.to_str().unwrap(),
15913 ],
15914 );
15915 g(
15916 &repo,
15917 &["remote", "add", "origin", origin.to_str().unwrap()],
15918 );
15919 let c1 = git::rev_parse(&repo, "main").await.unwrap();
15920
15921 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
15923 g(&repo, &["add", "-A"]);
15924 g(
15925 &repo,
15926 &["commit", "-q", "-m", "chore(deps): update a crate"],
15927 );
15928 g(&repo, &["push", "-q", "origin", "main"]);
15929 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
15930 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
15931 g(&repo, &["add", "-A"]);
15932 g(
15933 &repo,
15934 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
15935 );
15936 g(&repo, &["checkout", "-q", "main"]);
15937
15938 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
15939 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
15940 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
15942 assert_eq!(fallback, c1);
15943
15944 let mut state = review_state(&[
15945 "chore(deps): update a crate",
15946 "fix(daemon): apply a chosen action",
15947 ]);
15948 state.repo = repo.clone();
15949 state.base_branch = "main".to_owned();
15950 state.base_commit = c1;
15951 refresh_reviewed_commits(&mut state, "feat/own").await;
15952 assert_eq!(
15953 state.reviewed_commits,
15954 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
15955 );
15956 assert_eq!(
15957 review_title(&state).as_deref(),
15958 Some("fix(daemon): apply a chosen action")
15959 );
15960 assert_eq!(
15961 leaked_subjects(&state, "feat/own").await,
15962 Some(vec!["chore(deps): update a crate".to_owned()])
15963 );
15964 state.config.merge.remote = "nowhere".to_owned();
15967 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
15968 }
15969
15970 struct SummaryFixture {
15974 _tmp: tempfile::TempDir,
15975 repo: PathBuf,
15976 reply: PathBuf,
15977 runner: Runner,
15978 }
15979
15980 fn summary_fixture() -> SummaryFixture {
15981 ask_test_home();
15982 let tmp = tempfile::tempdir().unwrap();
15983 let repo = tmp.path().join("repo");
15984 std::fs::create_dir_all(&repo).unwrap();
15985 init_repo(&repo);
15986 let origin = tmp.path().join("origin.git");
15987 let g = |args: &[&str]| {
15988 let out = std::process::Command::new("git")
15989 .args(args)
15990 .current_dir(&repo)
15991 .quiet()
15992 .output()
15993 .expect("spawn git");
15994 assert!(
15995 out.status.success(),
15996 "git {args:?}: {}",
15997 String::from_utf8_lossy(&out.stderr)
15998 );
15999 };
16000 g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
16001 g(&["remote", "add", "origin", origin.to_str().unwrap()]);
16002 g(&["push", "-q", "origin", "main"]);
16003 g(&["checkout", "-q", "-b", "feat/own"]);
16004 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
16005 g(&["add", "-A"]);
16006 g(&["commit", "-q", "-m", "feat(own): add the own file"]);
16007 let reply = tmp.path().join("reply.txt");
16008 std::fs::write(
16009 &reply,
16010 "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
16011 )
16012 .unwrap();
16013 let mut runner = runner_at(RunStatus::Gating);
16014 let mut state = review_state(&["feat(own): add the own file"]);
16015 state.repo = repo.clone();
16016 state.base_branch = "main".to_owned();
16017 state.candidates[0].branch = "feat/own".to_owned();
16018 state.candidates[0].worktree = repo.clone();
16019 let mut author = spec("alpha");
16020 author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
16021 author
16022 .env
16023 .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
16024 state.config.agents = vec![author];
16025 runner.state = state;
16026 SummaryFixture {
16027 _tmp: tmp,
16028 repo,
16029 reply,
16030 runner,
16031 }
16032 }
16033
16034 async fn posted(f: &mut SummaryFixture) -> PrMessage {
16035 let winner = f.runner.state.candidates[0].clone();
16036 f.runner
16037 .guarded_pr_message(&winner, None, true)
16038 .await
16039 .unwrap()
16040 .expect("an English description never parks")
16041 }
16042
16043 fn clean_round_with_minor_findings() -> ReviewRound {
16044 let mut round = passing_round(2, None);
16045 round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
16046 round.reviews = vec![ReviewRecord {
16047 attempts: 0,
16048 reviewer: 1,
16049 agent: "alpha".into(),
16050 summary: "内部レビュー".into(),
16051 findings: vec![
16052 finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
16053 finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
16054 ],
16055 vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
16056 failed: None,
16057 duration_ms: 0,
16058 }];
16059 round
16060 }
16061
16062 #[tokio::test]
16063 async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
16064 for review_only in [false, true] {
16065 let mut f = summary_fixture();
16066 if !review_only {
16067 f.runner.state.candidates[0].agent = "alpha".into();
16068 }
16069 f.runner.state.reviews = vec![clean_round_with_minor_findings()];
16070 assert!(f.runner.state.reviews[0].clean);
16071 assert!(f.runner.state.reviews[0].fix.is_none());
16072 assert!(f.runner.state.open_findings().is_empty());
16073 assert_eq!(
16074 crate::pr_outcome::required_finding_ids(&f.runner.state),
16075 ["R2-1-1", "R2-1-2"]
16076 );
16077 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16078 let evidence = crate::pr_outcome::summary_evidence(
16079 &f.runner.state,
16080 crate::pr_outcome::FinalHead::At(&head),
16081 );
16082 assert!(
16083 evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
16084 );
16085 let omitted = posted(&mut f).await;
16087 assert!(omitted.body.contains("No description"));
16088 assert!(omitted.body.contains("`R2-1-1` [Minor]"));
16089 assert!(omitted.body.contains("`R2-1-2` [Minor]"));
16090 assert!(f.runner.state.change_summary.is_none());
16091 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();
16092 let described = posted(&mut f).await;
16093 assert!(described.body.contains("improve documentation for callers"));
16094 assert!(described.body.contains("remaining edge case"));
16095 assert!(described.body.contains("## Open review findings"));
16096 assert!(!described.body.contains("未修正"));
16097 }
16098 }
16099
16100 async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
16101 let mut f = summary_fixture();
16102 if !review_only {
16103 f.runner.state.candidates[0].agent = "alpha".into();
16104 }
16105 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16106 let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
16107 fixed
16108 .fix
16109 .as_mut()
16110 .unwrap()
16111 .rejected
16112 .push(crate::verdict::Rejection {
16113 id: "R1-1-1".into(),
16114 why: "呼び出されないコードのため辞退".into(),
16115 });
16116 let mut clean = clean_round_with_minor_findings();
16117 clean.verified_head = Some(head.clone());
16118 f.runner.state.reviews = vec![fixed, clean];
16119 ok_gate(&mut f.runner.state, Some(&head));
16120 std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
16121 f.runner.state.config.agents.push(scripted("beta", reply));
16122 f.runner.state.config.roles.language_judge =
16123 Some(crate::config::AgentChoice::One("beta".into()));
16124 f
16125 }
16126
16127 #[tokio::test]
16128 async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
16129 for review_only in [false, true] {
16130 let mut f = rewrite_fixture_with_findings(review_only,
16131 "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;
16132 let m = posted(&mut f).await;
16133 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16134 let expected =
16135 public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
16136 assert!(m.body.contains(&expected));
16137 assert!(m.body.contains("## Review rounds"));
16138 assert!(
16139 m.body
16140 .contains("Gate (`verify.gate`): passed on the final commit")
16141 );
16142 assert!(
16143 m.body
16144 .contains("Tests (`verify.e2e`): passed on the final commit")
16145 );
16146 let c = f.runner.state.change_summary.as_ref().unwrap();
16147 assert!(
16148 m.body
16149 .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
16150 );
16151 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
16152 assert!(crate::pr_outcome::covers_required_findings(
16153 &f.runner.state,
16154 &m.body
16155 ));
16156 assert!(m.body.contains("documentation remains incomplete"));
16157 assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
16158 assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
16159 assert!(f.runner.state.github_text.is_none());
16160 }
16161 }
16162
16163 #[tokio::test]
16164 async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
16165 for review_only in [false, true] {
16166 for reply in [
16167 "TITLE: fix: public description\nA short English description.",
16168 "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
16169 ] {
16170 let mut f = rewrite_fixture_with_findings(review_only, reply).await;
16171 let winner = f.runner.state.candidates[0].clone();
16172 let posted = f
16173 .runner
16174 .guarded_pr_message(&winner, None, true)
16175 .await
16176 .unwrap();
16177 assert!(posted.is_none());
16178 assert!(f.runner.state.parked);
16179 let withheld = f.runner.state.github_text.as_ref().unwrap();
16180 assert!(
16181 withheld.rewrite.is_none(),
16182 "an incomplete rewrite must not be selectable"
16183 );
16184 let q = ask::Questions::open()
16185 .get(withheld.question.as_ref().unwrap())
16186 .unwrap();
16187 assert!(
16188 !q.choices
16189 .contains(&crate::github_text::USE_REWRITE.to_owned())
16190 );
16191 assert!(
16192 q.detail.contains("R1-1-1")
16193 && q.detail.contains("R2-1-1")
16194 && q.detail.contains("R2-1-2")
16195 );
16196 }
16197 }
16198 }
16199
16200 #[tokio::test]
16201 async fn japanese_reports_stay_local_in_both_public_run_modes() {
16202 for review_only in [false, true] {
16203 let mut f = summary_fixture();
16204 if !review_only {
16205 f.runner.state.candidates[0].agent = "alpha".into();
16206 }
16207 f.runner.state.instruction = "内部タスクの説明".into();
16208 f.runner.state.candidates[0].summary = "内部の候補報告".into();
16209 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16210 f.runner.state.reviews = vec![
16211 round_with_notes(1, Some("コミットできません。テストは実行できません。")),
16212 round_with_notes(2, Some("二回目の内部修正報告です。")),
16213 passing_round(3, Some(&head)),
16214 ];
16215 f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
16216 ok_gate(&mut f.runner.state, Some(&head));
16217 let captured = f.repo.join("public-prompt.txt");
16218 let author = &mut f.runner.state.config.agents[0];
16219 author.command = vec![
16220 "sh".into(),
16221 "-c".into(),
16222 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
16223 ];
16224 author.env.insert(
16225 "PROMPT_FILE".into(),
16226 captured.to_string_lossy().into_owned(),
16227 );
16228 let m = posted(&mut f).await;
16229 assert!(m.body.contains("Adds `own.txt`"));
16230 assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
16231 assert!(m.body.contains("R1-1-1"));
16232 assert!(
16233 m.body
16234 .contains("Gate (`verify.gate`): passed on the final commit")
16235 );
16236 assert!(
16237 m.body
16238 .contains("Tests (`verify.e2e`): passed on the final commit")
16239 );
16240 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
16241 assert!(!m.body.contains("内部") && !m.body.contains("できません"));
16242 let prompt = std::fs::read_to_string(captured).unwrap();
16243 assert!(prompt.contains(&format!(
16244 "git diff {} {head}",
16245 f.runner.state.change_summary.as_ref().unwrap().base
16246 )));
16247 assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
16248 assert_eq!(
16249 f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
16250 "二回目の内部修正報告です。"
16251 );
16252 }
16253 }
16254
16255 #[tokio::test]
16256 async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
16257 for review_only in [false, true] {
16258 let mut f = summary_fixture();
16259 if !review_only {
16260 f.runner.state.candidates[0].agent = "alpha".into();
16261 }
16262 f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
16263 f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
16264 std::fs::write(&f.reply, "no usable description").unwrap();
16265 let failed = posted(&mut f).await;
16266 assert!(
16267 failed
16268 .body
16269 .contains("No description of what this change does could be generated")
16270 );
16271 assert!(failed.body.is_ascii());
16272 assert!(f.runner.state.github_text.is_none());
16273 assert!(f.runner.state.change_summary.is_none());
16274 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16275 let evidence = crate::pr_outcome::summary_evidence(
16276 &f.runner.state,
16277 crate::pr_outcome::FinalHead::At(&head),
16278 );
16279 let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
16280 assert!(
16281 crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
16282 .exists()
16283 );
16284 f.runner.state =
16285 serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
16286 std::fs::write(
16287 &f.reply,
16288 "TITLE: feat(own): add the own file\nAdds the committed own file.",
16289 )
16290 .unwrap();
16291 let retried = posted(&mut f).await;
16292 assert!(retried.body.contains("Adds the committed own file."));
16293 assert_eq!(
16294 f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
16295 "日本語の修正報告"
16296 );
16297 }
16298 }
16299
16300 #[tokio::test]
16301 async fn same_head_verification_and_findings_invalidate_public_prose() {
16302 let mut f = summary_fixture();
16303 posted(&mut f).await;
16304 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16305 let original = f
16306 .runner
16307 .state
16308 .change_summary
16309 .as_ref()
16310 .unwrap()
16311 .evidence
16312 .clone();
16313 ok_gate(&mut f.runner.state, Some(&head));
16314 std::fs::write(
16315 &f.reply,
16316 "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
16317 )
16318 .unwrap();
16319 let verified = posted(&mut f).await;
16320 assert!(verified.body.contains("now verified"));
16321 assert_ne!(
16322 f.runner.state.change_summary.as_ref().unwrap().evidence,
16323 original
16324 );
16325 let mut round = round_with_notes(1, Some("内部報告"));
16326 round.reviews.push(ReviewRecord {
16327 attempts: 0,
16328 reviewer: 1,
16329 agent: "alpha".into(),
16330 summary: "内部レビュー".into(),
16331 findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
16332 vote: None,
16333 failed: None,
16334 duration_ms: 0,
16335 });
16336 round
16337 .fix
16338 .as_mut()
16339 .unwrap()
16340 .rejected
16341 .push(crate::verdict::Rejection {
16342 id: "R1-1-1".into(),
16343 why: "呼び出し元から到達できない".into(),
16344 });
16345 f.runner.state.reviews = vec![round];
16346 let omitted = posted(&mut f).await;
16348 assert!(omitted.body.contains("No description"));
16349 assert!(omitted.body.contains("R1-1-1"));
16350 assert!(!omitted.body.contains("到達"));
16351 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();
16352 let described = posted(&mut f).await;
16353 assert!(described.body.contains("unreachable from callers"));
16354 assert!(described.body.contains("R1-1-1"));
16355 assert!(!described.body.contains("到達"));
16356 let evidence = f
16357 .runner
16358 .state
16359 .change_summary
16360 .as_ref()
16361 .unwrap()
16362 .evidence
16363 .clone();
16364 f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
16365 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
16366 assert!(posted(&mut f).await.body.contains("rationale updated"));
16367 assert_ne!(
16368 f.runner.state.change_summary.as_ref().unwrap().evidence,
16369 evidence
16370 );
16371 f.runner
16373 .state
16374 .change_summary
16375 .as_mut()
16376 .unwrap()
16377 .evidence
16378 .clear();
16379 std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
16380 assert!(posted(&mut f).await.body.contains("current specification"));
16381 }
16382
16383 #[tokio::test]
16384 async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
16385 let mut f = summary_fixture();
16386 let m = posted(&mut f).await;
16387 assert_eq!(m.title, "feat(own): add the own file");
16388 assert!(
16389 m.body
16390 .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
16391 "{}",
16392 m.body
16393 );
16394 assert!(!m.body.contains("## Review fixes"), "{}", m.body);
16395 assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
16396 assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
16397 let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16398 let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
16399 let kept = f.runner.state.change_summary.as_ref().unwrap();
16400 assert_eq!(
16401 (kept.head.as_str(), kept.base.as_str()),
16402 (head.as_str(), base.as_str())
16403 );
16404 assert!(!f.runner.state.seats["change-summary"].key.is_empty());
16405 }
16406
16407 #[tokio::test]
16408 async fn review_only_summary_receives_the_configured_github_language() {
16409 let mut f = summary_fixture();
16410 f.runner.state.config.graph.github_language = "ja".into();
16411 let captured = f.repo.join("summary-prompt.txt");
16412 let author = &mut f.runner.state.config.agents[0];
16413 author.command = vec![
16414 "sh".into(),
16415 "-c".into(),
16416 "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
16417 ];
16418 author.env.insert(
16419 "PROMPT_FILE".into(),
16420 captured.to_string_lossy().into_owned(),
16421 );
16422 std::fs::write(
16423 &f.reply,
16424 "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
16425 )
16426 .unwrap();
16427 let m = posted(&mut f).await;
16428 let prompt = std::fs::read_to_string(captured).unwrap();
16429 assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
16430 assert!(!prompt.contains("in English"));
16431 assert_eq!(m.title, "feat(own): ファイルを追加");
16432 assert!(m.body.contains("own.txt を追加します。"));
16433 }
16434
16435 #[tokio::test]
16436 async fn review_fixes_come_after_the_whole_change_summary() {
16437 let mut f = summary_fixture();
16438 f.runner.state.reviews = vec![
16439 round_with_notes(1, Some("handled the PATH case")),
16440 round_with_notes(2, None),
16441 ];
16442 let m = posted(&mut f).await;
16443 let summary = m.body.find("## Summary").expect("summary");
16444 let fixes = m.body.find("## Review rounds").expect("review rounds");
16445 assert!(summary < fixes, "{}", m.body);
16446 assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
16447 assert!(m.body.contains("## Final verification"), "{}", m.body);
16448 }
16449
16450 #[tokio::test]
16451 async fn a_changed_final_diff_invalidates_the_earlier_summary() {
16452 let mut f = summary_fixture();
16453 let first = posted(&mut f).await;
16454 let first_head = f.runner.state.change_summary.clone().unwrap().head;
16455 std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
16457 let again = posted(&mut f).await;
16458 assert_eq!((again.title, again.body), (first.title, first.body));
16459
16460 let g = |args: &[&str]| {
16461 let out = std::process::Command::new("git")
16462 .args(args)
16463 .current_dir(&f.repo)
16464 .quiet()
16465 .output()
16466 .expect("spawn git");
16467 assert!(out.status.success(), "git {args:?}");
16468 };
16469 std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
16470 g(&["add", "-A"]);
16471 g(&["commit", "-q", "-m", "fix(own): handle more"]);
16472 let second = posted(&mut f).await;
16473 let kept = f.runner.state.change_summary.clone().unwrap();
16474 assert_ne!(kept.head, first_head);
16475 assert!(second.body.contains("Not read yet."), "{}", second.body);
16476 assert!(!second.body.contains("so the first summary is read"));
16477 assert_eq!(second.title, "feat(own): other");
16478 }
16479
16480 #[tokio::test]
16481 async fn an_unavailable_summary_is_stated_and_never_invented() {
16482 let mut f = summary_fixture();
16483 std::fs::write(&f.reply, "no title line here").unwrap();
16484 let m = posted(&mut f).await;
16485 assert!(
16486 m.body
16487 .contains("No description of what this change does could be generated"),
16488 "{}",
16489 m.body
16490 );
16491 assert!(m.body.contains("internal reports remain in local records"));
16492 assert!(f.runner.state.change_summary.is_none());
16493 assert!(!m.body.contains("## Commits under review"), "{}", m.body);
16494 let winner = f.runner.state.candidates[0].clone();
16496 let preview = f
16497 .runner
16498 .guarded_pr_message(&winner, None, false)
16499 .await
16500 .unwrap()
16501 .unwrap();
16502 assert!(!preview.body.contains("## Summary"), "{}", preview.body);
16503 }
16504
16505 #[test]
16506 fn pr_message_review_single_commit_uses_its_subject() {
16507 let state = review_state(&["feat(nats): per-role user"]);
16508 let m = pr_message(&state, 'A');
16509 assert_eq!(m.title, "feat(nats): per-role user");
16510 assert!(!m.body.contains("Review the work already"), "{}", m.body);
16511 assert!(m.body.contains("## Commits under review"), "{}", m.body);
16512 }
16513
16514 #[test]
16515 fn pr_message_review_multi_commit_takes_the_oldest() {
16516 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
16517 let m = pr_message(&state, 'A');
16518 assert_eq!(m.title, "feat: the change");
16519 for s in ["feat: the change", "fix: typo", "fix: again"] {
16520 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
16521 }
16522 }
16523
16524 #[test]
16525 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
16526 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
16527 let mut state = review_state(&[first, "fix: later fixup"]);
16528 state.candidates[0].branch = "機能/ログイン".to_owned();
16529 let m = pr_message(&state, 'A');
16530 assert!(
16531 m.title.starts_with("chore: land candidate A of run"),
16532 "{}",
16533 m.title
16534 );
16535 }
16536 }
16537
16538 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
16539 BranchFacts {
16540 commits: commits
16541 .iter()
16542 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
16543 .collect(),
16544 stat: stat.to_owned(),
16545 }
16546 }
16547
16548 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
16549 let mut r = review_round(true, 0, 1, 1, true, true);
16550 r.round = round;
16551 r.fix = notes.map(|n| FixRecord {
16552 agent: "fixer".to_owned(),
16553 addressed: Vec::new(),
16554 rejected: Vec::new(),
16555 notes: n.to_owned(),
16556 committed: true,
16557 failed: None,
16558 duration_ms: 0,
16559 continuation: None,
16560 });
16561 r
16562 }
16563
16564 #[test]
16565 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
16566 let state = review_state(&["ignored"]);
16567 let f = facts(
16568 &[
16569 (
16570 "fix(login): resolve PATH on macOS",
16571 "Login shells skip rc files.",
16572 ),
16573 ("fix: address review", ""),
16574 ],
16575 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
16576 );
16577 let m = pr_message_with(&state, 'A', Some(&f));
16578 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
16579 assert!(
16580 m.body
16581 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
16582 "{}",
16583 m.body
16584 );
16585 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
16586 assert!(m.body.contains("## Diff stat"), "{}", m.body);
16587 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
16588 for banned in [
16589 "Review the work already",
16590 "no task statement",
16591 "Original task",
16592 ] {
16593 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
16594 }
16595 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
16596 }
16597
16598 #[test]
16599 fn pr_message_review_truncates_a_huge_first_commit_body() {
16600 let state = review_state(&["ignored"]);
16601 let f = facts(
16602 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
16603 "s",
16604 );
16605 let m = pr_message_with(&state, 'A', Some(&f));
16606 assert!(m.body.len() < 30_000, "{}", m.body.len());
16607 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
16608 assert!(
16609 m.body.contains("- ... 1 more commit(s)"),
16610 "{}",
16611 m.body.len()
16612 );
16613 assert!(m.body.ends_with("magi:candidate-a\n"));
16614 }
16615
16616 #[test]
16617 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
16618 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
16619 assert_eq!(m.title, "feat: x");
16620 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
16621 assert!(!m.body.contains("Diff stat"), "{}", m.body);
16622 assert!(!m.body.contains("no task statement"), "{}", m.body);
16623 }
16624
16625 #[test]
16626 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
16627 let mut state = review_state(&["日本語の件名"]);
16628 state.candidates[0].branch = "fix/macos-login-path".to_owned();
16629 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
16630 state.candidates[0].branch = "機能/ログイン".to_owned();
16631 assert!(
16632 pr_message(&state, 'A')
16633 .title
16634 .starts_with("chore: land candidate A")
16635 );
16636 }
16637
16638 #[test]
16639 fn pr_message_review_fixes_survive_a_clean_final_round() {
16640 let mut state = review_state(&["feat: x"]);
16641 state.reviews = vec![
16642 round_with_notes(1, Some("handled the PATH case")),
16643 round_with_notes(2, None),
16644 ];
16645 let body = pr_message(&state, 'A').body;
16646 assert!(
16647 body.contains("## Review rounds") && !body.contains("handled the PATH case"),
16648 "{body}"
16649 );
16650 assert!(body.contains("### Round 1"), "{body}");
16651
16652 state.reviews = vec![
16653 round_with_notes(1, Some("first fix")),
16654 round_with_notes(2, Some("")),
16655 round_with_notes(3, Some("second fix")),
16656 round_with_notes(4, None),
16657 ];
16658 let body = pr_message(&state, 'A').body;
16659 assert!(
16660 body.contains("### Round 1") && !body.contains("first fix"),
16661 "{body}"
16662 );
16663 assert!(
16664 body.contains("### Round 3") && !body.contains("second fix"),
16665 "{body}"
16666 );
16667 assert!(body.contains("### Round 2"), "{body}");
16668 }
16669
16670 const FINAL: &str = "abcdef1234567890";
16671
16672 fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
16673 let mut r = round_with_notes(round, None);
16674 r.verified_head = verified.map(str::to_owned);
16675 r
16676 }
16677
16678 fn ok_gate(state: &mut RunState, head: Option<&str>) {
16679 state.gate = vec![CommandOutcome {
16680 command: "gate".to_owned(),
16681 code: Some(0),
16682 output_tail: String::new(),
16683 duration_ms: 0,
16684 resource_blocked: false,
16685 }];
16686 state.gate_ran = true;
16687 state.gate_head = head.map(str::to_owned);
16688 }
16689
16690 fn body_at(state: &RunState) -> String {
16691 pr_message_raw(
16692 state,
16693 'A',
16694 None,
16695 crate::pr_outcome::FinalHead::At(FINAL),
16696 ChangeNote::NotAsked,
16697 )
16698 .body
16699 }
16700
16701 const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
16702
16703 #[test]
16704 fn rescued_commit_and_later_verification_override_the_fixers_limits() {
16705 let mut state = review_state(&["feat: x"]);
16706 state.reviews = vec![
16707 round_with_notes(1, Some(SANDBOXED)),
16708 passing_round(2, Some(FINAL)),
16709 ];
16710 ok_gate(&mut state, Some(FINAL));
16711 let body = body_at(&state);
16712 assert!(!body.contains("Handled the PATH case."), "{body}");
16713 assert!(!body.contains("could not commit"), "{body}");
16714 assert!(!body.contains("could not run"), "{body}");
16715 assert!(
16716 body.contains("The fix is committed on the branch"),
16717 "{body}"
16718 );
16719 assert!(!body.contains("sandbox"), "{body}");
16720 assert!(
16721 body.contains("Tests (`verify.e2e`): passed on the final commit"),
16722 "{body}"
16723 );
16724 assert!(
16725 body.contains("Gate (`verify.gate`): passed on the final commit"),
16726 "{body}"
16727 );
16728 assert!(
16730 state.reviews[0]
16731 .fix
16732 .as_ref()
16733 .unwrap()
16734 .notes
16735 .contains("could not commit")
16736 );
16737 }
16738
16739 #[test]
16740 fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
16741 let mut state = review_state(&["feat: x"]);
16742 let mut r1 = round_with_notes(1, Some(SANDBOXED));
16743 r1.fix.as_mut().unwrap().committed = false;
16744 let mut r2 = passing_round(2, Some(FINAL));
16745 r2.e2e[0].code = Some(1);
16746 state.reviews = vec![r1, r2];
16747 let body = body_at(&state);
16748 assert!(!body.contains("could not commit"), "{body}");
16749 assert!(!body.contains("could not run"), "{body}");
16750 assert!(body.contains("This round produced no commit"), "{body}");
16751 assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
16752 assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
16753 assert!(!body.contains("passed"), "{body}");
16754 }
16755
16756 #[test]
16757 fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
16758 let mut state = review_state(&["feat: x"]);
16759 state.reviews = vec![
16760 round_with_notes(1, Some(SANDBOXED)),
16761 passing_round(2, Some("0123456789abcdef")),
16762 ];
16763 ok_gate(&mut state, Some("0123456789abcdef"));
16764 let body = body_at(&state);
16765 assert!(!body.contains("HTTP tests could not run"), "{body}");
16766 assert!(!body.contains("could not commit"), "{body}");
16768 assert!(
16769 body.contains("passed on an earlier commit (`0123456`)"),
16770 "{body}"
16771 );
16772 assert!(!body.contains("passed on the final commit"), "{body}");
16773
16774 ok_gate(&mut state, None);
16776 let body = body_at(&state);
16777 assert!(
16778 body.contains("passed on a commit that was not recorded"),
16779 "{body}"
16780 );
16781 }
16782
16783 #[test]
16784 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
16785 let state = state_with_summary(
16786 "add retries to the client",
16787 "TITLE: feat: retries\n\nDid it.",
16788 );
16789 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
16790 assert_eq!(m.title, "feat: retries");
16791 assert!(
16792 m.body.contains("<summary>Original task</summary>"),
16793 "{}",
16794 m.body
16795 );
16796 assert!(!m.body.contains("Commits under review"), "{}", m.body);
16797 assert!(
16798 m.body
16799 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
16800 "{}",
16801 m.body
16802 );
16803 }
16804
16805 #[test]
16806 fn pr_message_review_bounds_a_long_english_subject() {
16807 let long = format!("feat: {}", "word ".repeat(100));
16808 let m = pr_message(&review_state(&[&long]), 'A');
16809 assert!(m.title.starts_with("feat: word"), "{}", m.title);
16810 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16811 }
16812
16813 #[test]
16814 fn pr_message_implementation_run_is_unchanged_by_review_support() {
16815 let state = state_with_summary("add retries\n\ndetails", "- did some things");
16816 let m = pr_message(&state, 'A');
16817 assert_eq!(m.title, "add retries");
16818 assert!(m.body.contains("<summary>Original task</summary>"));
16819 assert!(!m.body.contains("Commits under review"));
16820 assert_eq!(landing_subject_source(&state), state.instruction);
16821 }
16822
16823 #[test]
16824 fn review_run_squash_subject_is_the_change_not_the_prompt() {
16825 let state = review_state(&["feat: the change", "fix: typo"]);
16826 let source = landing_subject_source(&state);
16827 assert_eq!(land::merge_subject("", &source), "feat: the change");
16828 assert_eq!(
16829 land::merge_subject("magi: candidate A (uncommitted work)", &source),
16830 "feat: the change"
16831 );
16832 assert_eq!(
16834 land::merge_subject("feat: renamed by hand", &source),
16835 "feat: renamed by hand"
16836 );
16837 let blank = review_state(&["日本語"]);
16838 assert!(
16839 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
16840 );
16841 }
16842
16843 #[test]
16844 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
16845 let state = review_state(&["feat: the change"]);
16846 let source = landing_subject_source(&state);
16847 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
16848 assert_eq!(
16849 land::merge_subject(landing_title(&state, old), &source),
16850 "feat: the change"
16851 );
16852 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
16853 let task = state_with_summary("add retries", "");
16854 assert_eq!(landing_title(&task, old), old);
16855 }
16856
16857 #[test]
16858 fn pr_message_describes_the_change_not_the_task() {
16859 let state = state_with_summary(
16860 "今回やってほしいこと: results projector を直す",
16861 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
16862 );
16863 let m = pr_message(&state, 'A');
16864 assert_eq!(m.title, "fix(web): batch the runs list reads");
16865 assert!(
16866 m.body.starts_with("## Summary\n\n- reads run.json once"),
16867 "{}",
16868 m.body
16869 );
16870 assert!(!m.body.contains("TITLE:"), "{}", m.body);
16871 let task_at = m.body.find("今回やってほしいこと").unwrap();
16872 let details_at = m.body.find("<details>").unwrap();
16873 assert!(
16874 details_at < task_at,
16875 "the task lives inside <details>: {}",
16876 m.body
16877 );
16878 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
16879 assert!(m.body.contains("magi:candidate-a"));
16880 }
16881
16882 #[test]
16883 fn pr_message_falls_back_to_the_task_without_a_title_line() {
16884 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
16885 let m = pr_message(&state, 'A');
16886 assert_eq!(m.title, "add retries");
16887 assert!(
16888 m.body.contains("## Summary\n\n- did some things"),
16889 "{}",
16890 m.body
16891 );
16892
16893 let none = RunState::new(
16894 PathBuf::from("/repo"),
16895 "main".to_owned(),
16896 "abc1234".to_owned(),
16897 "add retries".to_owned(),
16898 Config::default(),
16899 );
16900 let m = pr_message(&none, 'A');
16901 assert_eq!(m.title, "add retries");
16902 assert!(!m.body.contains("## Summary"), "{}", m.body);
16903 }
16904
16905 #[test]
16906 fn pr_message_refuses_the_candidate_commit_subject() {
16907 for bad in [
16908 "TITLE: magi: candidate A (uncommitted work)",
16909 "TITLE: chore: stuff (uncommitted work)",
16910 "TITLE: ",
16911 ] {
16912 let state = state_with_summary("add retries", bad);
16913 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
16914 }
16915 }
16916
16917 #[test]
16918 fn pr_message_bounds_a_very_long_task_and_title() {
16919 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
16920 let state = state_with_summary(&long, "- nothing");
16921 let m = pr_message(&state, 'A');
16922 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16923 assert!(!m.title.contains('\n'));
16924
16925 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
16926 let m = pr_message(&state, 'A');
16927 assert!(m.title.starts_with("feat: "));
16928 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16929 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
16930 }
16931
16932 fn long_title_of(instruction: &str) -> String {
16933 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
16934 }
16935
16936 #[test]
16937 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
16938 let first = "Make the landing path keep a readable title for long tasks";
16939 let line = format!(
16940 "{first}. {}",
16941 "And then keep going with more words ".repeat(20)
16942 );
16943 let t = long_title_of(&line);
16944 assert_eq!(t, first);
16945 assert!(!t.starts_with("chore: land"));
16946 }
16947
16948 #[test]
16949 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
16950 let line = "word ".repeat(200);
16951 let t = long_title_of(&line);
16952 assert!(t.ends_with("word..."), "{t}");
16953 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
16954 }
16955
16956 #[test]
16957 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
16958 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
16959 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
16960 }
16961 }
16962
16963 #[test]
16964 fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
16965 let mut state = state_with_summary("再試行を追加する", "- nothing");
16966 assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
16967 state.config.graph.github_language = "ja".into();
16968 assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
16969 }
16970
16971 #[test]
16972 fn pr_message_title_limit_is_exact() {
16973 let at = "a".repeat(PR_TITLE_MAX);
16974 assert_eq!(long_title_of(&at), at);
16975 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
16976 assert!(over.ends_with("..."), "{over}");
16977 assert_eq!(over.chars().count(), PR_TITLE_MAX);
16978 }
16979
16980 #[test]
16981 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
16982 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
16983 let t = long_title_of(&line);
16984 assert!(t.ends_with("..."), "{t}");
16985 assert!(t.is_ascii(), "{t}");
16986 }
16987
16988 #[test]
16989 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
16990 let line = format!(
16991 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
16992 "plus more filler words ".repeat(20)
16993 );
16994 let t = long_title_of(&line);
16995 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
16996 }
16997
16998 #[test]
16999 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
17000 let line = format!("\n\n# {}", "title words ".repeat(40));
17001 let state = state_with_summary(&line, "- nothing");
17002 let m = pr_message(&state, 'A');
17003 assert!(m.title.starts_with("title words"), "{}", m.title);
17004 assert_eq!(
17005 land::merge_subject(&m.title, &landing_subject_source(&state)),
17006 m.title
17007 );
17008 assert_eq!(
17010 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
17011 "feat: renamed by hand"
17012 );
17013 }
17014
17015 #[tokio::test]
17016 async fn github_text_rewrite_is_bounded_and_falls_back() {
17017 crate::run::pin_test_home();
17018 for (reply, accepted) in [
17019 (
17020 "TITLE: fix: retries\nAdd retries for failed requests.",
17021 true,
17022 ),
17023 (
17024 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
17025 false,
17026 ),
17027 (
17028 "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
17029 true,
17030 ),
17031 (
17032 "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
17033 true,
17034 ),
17035 (
17036 "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
17037 true,
17038 ),
17039 (
17040 "TITLE: fix: retries\n日本語の説明です。 token=secret",
17041 false,
17042 ),
17043 ("TITLE: fix: retries\n", false),
17044 ] {
17045 let dir = tempfile::tempdir().unwrap();
17046 let mut runner = runner_at(RunStatus::Gating);
17047 runner.state = state_with_summary(
17048 "add retries",
17049 "TITLE: fix: retries\n日本語の説明を書きます。",
17050 );
17051 runner.state.repo = dir.path().to_owned();
17052 runner.state.candidates[0].worktree = dir.path().to_owned();
17053 let mut author = spec("alpha");
17054 author.command = vec![
17055 "sh".into(),
17056 "-c".into(),
17057 "printf '%s' \"$REWRITE_REPLY\"".into(),
17058 ];
17059 author.env.insert("REWRITE_REPLY".into(), reply.into());
17060 runner.state.config.agents = vec![author];
17061 seed_public_summary(&mut runner);
17062 let winner = runner.state.candidates[0].clone();
17063 let first = runner
17064 .guarded_pr_message(&winner, None, true)
17065 .await
17066 .unwrap();
17067 let message = if accepted {
17068 first.expect("a passing rewrite never asks")
17069 } else {
17070 assert!(first.is_none());
17072 assert!(runner.state.parked);
17073 let store = ask::Questions::open();
17074 let asked: Vec<_> = store
17075 .list()
17076 .into_iter()
17077 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
17078 .collect();
17079 assert_eq!(asked.len(), 1);
17080 assert!(!asked[0].detail.contains("secret"));
17081 let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
17082 assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
17083 assert_eq!(
17084 asked[0]
17085 .choices
17086 .contains(&crate::github_text::USE_REWRITE.to_owned()),
17087 rewrite.is_some()
17088 );
17089 store
17092 .update(&asked[0].id, |q| {
17093 q.abandon("test");
17094 Ok(())
17095 })
17096 .unwrap();
17097 let again = runner
17098 .guarded_pr_message(&winner, None, true)
17099 .await
17100 .unwrap()
17101 .expect("an abandoned question falls back");
17102 let n = store
17103 .list()
17104 .into_iter()
17105 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
17106 .count();
17107 assert_eq!(n, 1, "asked once per rejected text");
17108 again
17109 };
17110 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
17111 assert!(!message.body.contains("secret"));
17112 assert!(!message.body.contains("prefix"));
17113 assert!(!message.title.contains("secret"));
17114 assert_eq!(
17115 message.body.contains("Add retries for failed requests."),
17116 accepted
17117 );
17118 assert_eq!(
17119 runner.state.seats["github-text-rewrite"].turns, 1,
17120 "only one rewrite invocation"
17121 );
17122 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
17123 if accepted {
17124 assert!(runner.state.github_text.is_none());
17125 assert!(!runner.state.parked);
17126 assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
17127 assert!(message.body.contains("## Final verification"));
17128 assert!(message.body.contains("Evidence: final diff"));
17129 assert!(
17130 message
17131 .body
17132 .contains(&format!("magi:run/{}", runner.state.id))
17133 );
17134 }
17135 if !accepted {
17136 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
17137 assert!(
17138 message
17139 .body
17140 .contains(&format!("magi:run/{}", runner.state.id))
17141 );
17142 }
17143 }
17144 }
17145
17146 fn scripted(id: &str, reply: &str) -> AgentSpec {
17147 let mut a = spec(id);
17148 a.command = vec![
17149 "sh".into(),
17150 "-c".into(),
17151 "printf '%s' \"$REWRITE_REPLY\"".into(),
17152 ];
17153 a.env.insert("REWRITE_REPLY".into(), reply.into());
17154 a
17155 }
17156
17157 const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
17158 const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
17159
17160 fn seed_public_summary(runner: &mut Runner) {
17162 let repo = &runner.state.repo;
17163 let g = |args: &[&str]| {
17164 let out = std::process::Command::new("git")
17165 .args(args)
17166 .current_dir(repo)
17167 .quiet()
17168 .output()
17169 .unwrap();
17170 assert!(out.status.success(), "git {args:?}");
17171 String::from_utf8_lossy(&out.stdout).trim().to_owned()
17172 };
17173 g(&["init", "-q", "-b", "main"]);
17174 g(&[
17175 "-c",
17176 "user.name=Test",
17177 "-c",
17178 "user.email=test@example.invalid",
17179 "commit",
17180 "-q",
17181 "--allow-empty",
17182 "-m",
17183 "feat: fixture",
17184 ]);
17185 let head = g(&["rev-parse", "HEAD"]);
17186 g(&["update-ref", "refs/remotes/origin/main", &head]);
17187 let evidence = crate::pr_outcome::summary_evidence(
17188 &runner.state,
17189 crate::pr_outcome::FinalHead::At(&head),
17190 );
17191 let generated = &runner.state.candidates[0].summary;
17192 runner.state.change_summary = Some(crate::run::ChangeSummary {
17193 base: head.clone(),
17194 head,
17195 evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
17196 title: summary_title(generated).unwrap(),
17197 body: summary_without_title(generated),
17198 });
17199 }
17200
17201 fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
17202 let mut runner = runner_at(RunStatus::Gating);
17203 runner.state = state_with_summary("add retries", JA_SUMMARY);
17204 if review_only {
17205 runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
17206 }
17207 runner.state.repo = dir.to_owned();
17208 runner.state.candidates[0].worktree = dir.to_owned();
17209 runner.state.config.agents = agents;
17210 seed_public_summary(&mut runner);
17211 runner
17212 }
17213
17214 fn gate_questions(runner: &Runner) -> usize {
17215 ask::Questions::open()
17216 .list()
17217 .into_iter()
17218 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
17219 .count()
17220 }
17221
17222 #[tokio::test]
17223 async fn review_only_description_is_rewritten_by_the_language_judge_role() {
17224 crate::run::pin_test_home();
17225 let dir = tempfile::tempdir().unwrap();
17226 let mut runner = rewrite_runner(
17227 dir.path(),
17228 true,
17229 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
17230 );
17231 runner.state.config.roles.language_judge =
17232 Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
17233 let winner = runner.state.candidates[0].clone();
17234 let msg = runner
17235 .guarded_pr_message(&winner, None, true)
17236 .await
17237 .unwrap()
17238 .expect("an English rewrite posts without asking");
17239 assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
17240 assert!(msg.body.contains("<details>kept</details>"));
17241 assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
17242 assert_eq!(gate_questions(&runner), 0);
17243 assert!(runner.state.github_text.is_none());
17244 assert!(!runner.state.seats.contains_key("impl-A"));
17245 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
17246 }
17247
17248 #[tokio::test]
17249 async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
17250 crate::run::pin_test_home();
17251 let dir = tempfile::tempdir().unwrap();
17252 let mut runner = rewrite_runner(
17253 dir.path(),
17254 false,
17255 vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
17256 );
17257 runner.state.config.roles.language_judge =
17258 Some(crate::config::AgentChoice::One("beta".into()));
17259 runner.state.seats.insert(
17260 "impl-A".into(),
17261 SeatState::new("impl-A", "alpha", runner.state.seed),
17262 );
17263 let winner = runner.state.candidates[0].clone();
17264 let msg = runner
17265 .guarded_pr_message(&winner, None, true)
17266 .await
17267 .unwrap()
17268 .expect("rewritten");
17269 assert!(msg.body.contains("<details>kept</details>"));
17270 assert_eq!(runner.state.seats["impl-A"].turns, 0);
17271 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
17272 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
17273 }
17274
17275 #[tokio::test]
17276 async fn rewrite_reuses_a_resumable_implementer_session() {
17277 crate::run::pin_test_home();
17278 let dir = tempfile::tempdir().unwrap();
17279 let mut runner = rewrite_runner(
17280 dir.path(),
17281 false,
17282 vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
17283 );
17284 runner.state.config.roles.language_judge =
17285 Some(crate::config::AgentChoice::One("beta".into()));
17286 let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
17287 seat.turns = 1;
17288 runner.state.seats.insert("impl-A".into(), seat);
17289 let winner = runner.state.candidates[0].clone();
17290 runner
17291 .guarded_pr_message(&winner, None, true)
17292 .await
17293 .unwrap()
17294 .expect("rewritten");
17295 assert_eq!(runner.state.seats["impl-A"].turns, 2);
17296 assert!(!runner.state.seats.contains_key("github-text-rewrite"));
17297 }
17298
17299 #[tokio::test]
17300 async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
17301 crate::run::pin_test_home();
17302 let dir = tempfile::tempdir().unwrap();
17303 let mut broken = spec("bad");
17304 broken.command = vec!["false".into()];
17305 let mut runner = rewrite_runner(
17306 dir.path(),
17307 true,
17308 vec![
17309 broken,
17310 scripted("noformat", "Add retries."),
17311 scripted("good", EN_REWRITE),
17312 ],
17313 );
17314 runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
17315 "bad".into(),
17316 "noformat".into(),
17317 "good".into(),
17318 ]));
17319 let winner = runner.state.candidates[0].clone();
17320 let msg = runner
17321 .guarded_pr_message(&winner, None, true)
17322 .await
17323 .unwrap()
17324 .expect("the third agent rewrites");
17325 assert!(msg.body.contains("`src/net.rs`"));
17326 assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
17327 assert!(runner.state.handovers.is_empty(), "no handover is recorded");
17328 }
17329
17330 #[tokio::test]
17331 async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
17332 crate::run::pin_test_home();
17333 for (agents, role, saved) in [
17335 (
17336 vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
17337 Some("beta"),
17338 true,
17339 ),
17340 (vec![scripted("beta", "Add retries.")], Some("beta"), false),
17341 (Vec::new(), None, false),
17342 ] {
17343 let dir = tempfile::tempdir().unwrap();
17344 let mut runner = rewrite_runner(dir.path(), true, agents);
17345 runner.state.config.roles.language_judge =
17346 role.map(|r| crate::config::AgentChoice::One(r.into()));
17347 let winner = runner.state.candidates[0].clone();
17348 let first = runner
17349 .guarded_pr_message(&winner, None, true)
17350 .await
17351 .unwrap();
17352 assert!(first.is_none(), "withheld text parks the run");
17353 assert!(runner.state.parked);
17354 assert_eq!(gate_questions(&runner), 1);
17355 let g = runner.state.github_text.as_ref().unwrap();
17356 assert_eq!(g.rewrite.is_some(), saved);
17357 assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
17358 }
17359 }
17360
17361 #[tokio::test]
17362 async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
17363 crate::run::pin_test_home();
17364 let dir = tempfile::tempdir().unwrap();
17365 let mut runner = runner_at(RunStatus::Gating);
17366 runner.state =
17367 state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
17368 runner.state.repo = dir.path().to_owned();
17369 runner.state.candidates[0].worktree = dir.path().to_owned();
17370 let mut author = spec("alpha");
17371 author.command = vec![
17372 "sh".into(),
17373 "-c".into(),
17374 "printf '%s' \"$REWRITE_REPLY\"".into(),
17375 ];
17376 author.env.insert(
17377 "REWRITE_REPLY".into(),
17378 format!(
17379 "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
17380 "English sentence. ".repeat(600)
17381 ),
17382 );
17383 runner.state.config.agents = vec![author];
17384 seed_public_summary(&mut runner);
17385 let winner = runner.state.candidates[0].clone();
17386 assert!(
17387 runner
17388 .guarded_pr_message(&winner, None, true)
17389 .await
17390 .unwrap()
17391 .is_none()
17392 );
17393 let record = runner.state.github_text.clone().unwrap();
17394 let rewrite = record.rewrite.clone().unwrap();
17395 assert_eq!(rewrite.failed, ["body-language"]);
17396 assert!(
17397 rewrite
17398 .body
17399 .ends_with(&format!("magi:run/{}", runner.state.id))
17400 );
17401 let store = ask::Questions::open();
17402 let q = store.get(record.question.as_ref().unwrap()).unwrap();
17403 assert!(
17404 q.choices
17405 .contains(&crate::github_text::USE_REWRITE.to_owned())
17406 );
17407 for label in [
17408 "ORIGINAL",
17409 "FALLBACK",
17410 "REWRITE",
17411 "Rules still failed: body-language",
17412 "truncated",
17413 ] {
17414 assert!(q.detail.contains(label), "{label}");
17415 }
17416 let artifact = std::fs::read_to_string(crate::run::artifact_path(
17417 &runner.state,
17418 &crate::github_text::artifact_name(&record.fingerprint),
17419 ))
17420 .unwrap();
17421 assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
17422 runner.state =
17424 serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
17425 store
17426 .update(&q.id, |q| {
17427 q.say("まだ日本語")?;
17428 q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
17429 })
17430 .unwrap();
17431 assert!(
17432 runner
17433 .guarded_pr_message(&winner, None, true)
17434 .await
17435 .unwrap()
17436 .is_none()
17437 );
17438 let retry = runner.state.github_text.as_ref().unwrap();
17439 assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
17440 let q = store.get(retry.question.as_ref().unwrap()).unwrap();
17441 assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
17442 assert!(
17443 q.posting_comparison
17444 .as_ref()
17445 .unwrap()
17446 .matches_detail(&q.detail)
17447 );
17448 store
17449 .update(&q.id, |q| {
17450 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
17451 })
17452 .unwrap();
17453 let message = runner
17454 .guarded_pr_message(&winner, None, true)
17455 .await
17456 .unwrap()
17457 .unwrap();
17458 assert_eq!(message.title, rewrite.title);
17459 assert_eq!(message.body, rewrite.body);
17460 assert_eq!(
17461 runner.state.github_text.as_ref().unwrap().choice,
17462 Some(GithubTextChoice::Rewrite)
17463 );
17464 let again = runner
17465 .guarded_pr_message(&winner, None, true)
17466 .await
17467 .unwrap()
17468 .unwrap();
17469 assert_eq!(again.title, message.title);
17470 assert_eq!(again.body, message.body);
17471 assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
17472 assert!(
17473 !runner
17474 .state
17475 .events
17476 .iter()
17477 .any(|e| e.message.contains(&rewrite.title)
17478 || e.message.contains("English sentence."))
17479 );
17480 }
17481
17482 #[tokio::test]
17483 async fn github_text_rewrite_choice_without_saved_text_falls_back() {
17484 crate::run::pin_test_home();
17485 let dir = tempfile::tempdir().unwrap();
17486 let mut runner = runner_at(RunStatus::Gating);
17487 runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
17488 runner.state.repo = dir.path().to_owned();
17489 runner.state.candidates[0].worktree = dir.path().to_owned();
17490 seed_public_summary(&mut runner);
17491 let winner = runner.state.candidates[0].clone();
17492 assert!(
17493 runner
17494 .guarded_pr_message(&winner, None, true)
17495 .await
17496 .unwrap()
17497 .is_none()
17498 );
17499 let record = runner.state.github_text.as_ref().unwrap();
17500 assert!(record.rewrite.is_none());
17501 let mut old = serde_json::to_value(record).unwrap();
17503 old.as_object_mut().unwrap().remove("rewrite");
17504 let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
17505 assert!(old.rewrite.is_none());
17506 let id = record.question.clone().unwrap();
17507 ask::Questions::open()
17508 .update(&id, |q| {
17509 q.choices.push(crate::github_text::USE_REWRITE.into());
17510 q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
17511 })
17512 .unwrap();
17513 let message = runner
17514 .guarded_pr_message(&winner, None, true)
17515 .await
17516 .unwrap()
17517 .unwrap();
17518 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
17519 assert_eq!(
17520 runner.state.github_text.as_ref().unwrap().choice,
17521 Some(GithubTextChoice::Fallback)
17522 );
17523 }
17524
17525 #[tokio::test]
17526 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
17527 crate::run::pin_test_home();
17528 let dir = tempfile::tempdir().unwrap();
17529 let mut runner = runner_at(RunStatus::Gating);
17530 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
17531 runner.state.repo = dir.path().to_owned();
17532 runner.state.candidates[0].worktree = dir.path().to_owned();
17533 seed_public_summary(&mut runner);
17534 let winner = runner.state.candidates[0].clone();
17535 assert!(
17536 runner
17537 .guarded_pr_message(&winner, None, true)
17538 .await
17539 .unwrap()
17540 .is_none()
17541 );
17542 let store = ask::Questions::open();
17543 let find = |run: &str| {
17544 store
17545 .list()
17546 .into_iter()
17547 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
17548 .unwrap()
17549 };
17550 let run = runner.state.id.clone();
17551 let q = find(&run);
17552 let fp = runner
17553 .state
17554 .github_text
17555 .as_ref()
17556 .unwrap()
17557 .fingerprint
17558 .clone();
17559 let name = crate::github_text::artifact_name(&fp);
17560 let path = crate::run::artifact_path(&runner.state, &name);
17561 assert!(q.detail.contains(&path.display().to_string()));
17562 assert!(q.detail.contains("title-language: title"));
17563 let saved = std::fs::read_to_string(&path).expect("artifact written");
17564 assert!(saved.contains("日本語のタイトル"));
17565 assert!(q.cwd.is_none());
17566 assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
17567 let say = |id: &str, text: &str, choice: &str| {
17569 store
17570 .update(id, |q| {
17571 q.thread.push(ask::Turn {
17572 who: ask::Who::Operator,
17573 body: text.to_owned(),
17574 at: jiff::Timestamp::now(),
17575 note: None,
17576 });
17577 q.answer(ask::Answer::Choice(choice.to_owned()))
17578 })
17579 .unwrap();
17580 };
17581 say(&q.id, "まだ日本語", "use my text");
17582 assert!(
17583 runner
17584 .guarded_pr_message(&winner, None, true)
17585 .await
17586 .unwrap()
17587 .is_none()
17588 );
17589 let second = find(&run);
17590 assert_ne!(second.id, q.id);
17591 assert!(second.detail.contains("did not pass"));
17592 assert!(second.detail.contains("日本語のタイトル"));
17595 assert!(!second.detail.contains("まだ日本語"));
17596 assert!(second.detail.contains(&path.display().to_string()));
17597 say(&second.id, "fix: retry failed requests", "use my text");
17598 let message = runner
17599 .guarded_pr_message(&winner, None, true)
17600 .await
17601 .unwrap()
17602 .expect("a vetted replacement is posted");
17603 assert_eq!(message.title, "fix: retry failed requests");
17604 assert_eq!(
17605 runner
17606 .state
17607 .github_text
17608 .as_ref()
17609 .unwrap()
17610 .chosen_title
17611 .as_deref(),
17612 Some("fix: retry failed requests")
17613 );
17614 assert!(runner.state.github_text.as_ref().unwrap().resolved);
17615 let again = runner
17617 .guarded_pr_message(&winner, None, true)
17618 .await
17619 .unwrap()
17620 .unwrap();
17621 assert_eq!(again.title, message.title);
17622 assert_eq!(
17623 store
17624 .list()
17625 .into_iter()
17626 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
17627 .count(),
17628 2
17629 );
17630 }
17631
17632 #[test]
17633 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
17634 let mut state = state_with_summary(
17638 "add retries",
17639 "TITLE: fix(web): batch reads\n- reads run.json once",
17640 );
17641 state.config.graph.language = "ja".to_owned();
17642 let m = pr_message(&state, 'A');
17643 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
17644 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
17645
17646 let task = "今回やってほしいこと: results projector を直す";
17649 let mut state = state_with_summary(task, "- no title line");
17650 state.config.graph.language = "ja".to_owned();
17651 let m = pr_message(&state, 'A');
17652 assert_eq!(
17653 m.title,
17654 format!("chore: land candidate A of run {}", state.id)
17655 );
17656 assert!(
17657 m.body.contains(&format!(
17658 "<summary>Original task</summary>\n\n{task}\n\n</details>"
17659 )),
17660 "{}",
17661 m.body
17662 );
17663 }
17664
17665 #[test]
17666 fn pr_message_scrubs_home_paths_and_addresses() {
17667 let state = state_with_summary(
17668 "fix it in /Users/someone/src/x",
17669 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
17670 );
17671 let m = pr_message(&state, 'A');
17672 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
17673 assert!(!m.body.contains(leak), "{}", m.body);
17674 }
17675 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
17676 }
17677
17678 #[test]
17679 fn pr_message_survives_a_task_that_closes_details() {
17680 let state = state_with_summary("a </details> b", "TITLE: fix: x");
17681 let m = pr_message(&state, 'A');
17682 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
17683 }
17684
17685 #[test]
17686 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
17687 let cmd = manual_merge_command(
17688 MergeStyle::Squash,
17689 Path::new("/repo"),
17690 "b",
17691 "fix: \"quoted\" $(x) `y`\n\nbody",
17692 );
17693 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
17694 }
17695
17696 #[test]
17697 fn manual_merge_command_matches_the_configured_style() {
17698 let repo = Path::new("/repo");
17699 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
17700
17701 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
17702 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
17703
17704 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
17705 assert_eq!(
17706 squash,
17707 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
17708 \"Merge magi run 0832 (candidate A)\""
17709 );
17710
17711 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
17712 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
17713 }
17714
17715 #[test]
17716 fn a_nudge_gets_a_quarter_of_the_budget() {
17717 assert_eq!(retry_budget(secs(1200), true), secs(300));
17719 assert_eq!(retry_budget(secs(3600), true), secs(900));
17720 }
17721
17722 #[test]
17723 fn a_resent_prompt_keeps_the_whole_budget() {
17724 assert_eq!(retry_budget(secs(1200), false), secs(1200));
17727 assert_eq!(retry_budget(secs(60), false), secs(60));
17728 }
17729
17730 #[test]
17731 fn the_floor_never_exceeds_the_original_budget() {
17732 assert_eq!(retry_budget(secs(60), true), secs(60));
17736 assert_eq!(retry_budget(secs(480), true), secs(120));
17737 assert_eq!(retry_budget(secs(0), true), secs(0));
17738 }
17739
17740 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
17741 agent::CommandEvidence {
17742 id: "item1".to_owned(),
17743 description: "cargo test".to_owned(),
17744 exit_code,
17745 result_summary: String::new(),
17746 source: "codex".to_owned(),
17747 }
17748 }
17749
17750 #[test]
17751 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
17752 assert!(!has_unconfirmed_command(&[]));
17756 }
17757
17758 #[test]
17759 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
17760 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
17764 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
17765 assert!(!has_unconfirmed_command(&[
17766 evidence(Some(0)),
17767 evidence(Some(101))
17768 ]));
17769 }
17770
17771 #[test]
17772 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
17773 assert!(has_unconfirmed_command(&[
17774 evidence(Some(0)),
17775 evidence(None)
17776 ]));
17777 }
17778
17779 #[test]
17780 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
17781 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17782 assert_eq!(
17783 verified_noop_claim(true, &[], text).as_deref(),
17784 Some("already fixed by b32cfc4, on main.")
17785 );
17786 }
17787
17788 #[test]
17789 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
17790 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17793 assert!(verified_noop_claim(false, &[], text).is_none());
17794 }
17795
17796 #[test]
17797 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
17798 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17799 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
17800 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
17802 }
17803
17804 #[test]
17805 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
17806 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
17807 }
17808
17809 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
17812 runner.state.candidates = shape
17813 .iter()
17814 .enumerate()
17815 .map(|(i, &(empty, verified))| Candidate {
17816 index: i,
17817 label: (b'A' + i as u8) as char,
17818 agent: "sonnet".to_owned(),
17819 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
17820 worktree: PathBuf::from(format!("/wt/{i}")),
17821 summary: String::new(),
17822 stat: String::new(),
17823 files: 0,
17824 commits: 0,
17825 empty,
17826 failed: None,
17827 verified_noop: verified.map(str::to_owned),
17828 duration_ms: 0,
17829 folded: false,
17830 })
17831 .collect();
17832 }
17833
17834 #[test]
17835 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
17836 ask_test_home();
17837 let mut runner = runner_at(RunStatus::Implementing);
17838 set_candidates(
17839 &mut runner,
17840 &[
17841 (true, Some("already on main at b32cfc4")),
17842 (true, Some("same fix, see the existing test")),
17843 ],
17844 );
17845
17846 runner
17847 .after_implement()
17848 .expect("a verified no-op is not an error");
17849
17850 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
17851 }
17852
17853 #[test]
17854 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
17855 ask_test_home();
17856 let mut runner = runner_at(RunStatus::Implementing);
17857 set_candidates(
17861 &mut runner,
17862 &[(true, Some("already on main at b32cfc4")), (true, None)],
17863 );
17864
17865 let err = runner
17866 .after_implement()
17867 .expect_err("an unverified empty candidate must still fail the run");
17868
17869 assert!(
17870 err.to_string().contains("no candidate produced a change"),
17871 "{err}"
17872 );
17873 assert_eq!(runner.state.status, RunStatus::Failed);
17874 }
17875
17876 #[test]
17877 fn after_implement_still_fails_an_ordinary_all_empty_run() {
17878 ask_test_home();
17879 let mut runner = runner_at(RunStatus::Implementing);
17880 set_candidates(&mut runner, &[(true, None), (true, None)]);
17881
17882 let err = runner
17883 .after_implement()
17884 .expect_err("no candidate declared anything; this is an ordinary failure");
17885
17886 assert!(
17887 err.to_string().contains("no candidate produced a change"),
17888 "{err}"
17889 );
17890 assert_eq!(runner.state.status, RunStatus::Failed);
17891 }
17892}
17893
17894#[derive(Debug)]
17897struct GraftRestoreFailed(String);
17898
17899impl std::fmt::Display for GraftRestoreFailed {
17900 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17901 write!(
17902 f,
17903 "could not restore the winner to its pre-graft commit: {}",
17904 self.0
17905 )
17906 }
17907}
17908
17909impl std::error::Error for GraftRestoreFailed {}
17910
17911#[cfg(test)]
17915mod resource_wait_tests {
17916 use super::*;
17917 use std::collections::BTreeMap;
17918
17919 fn sh(id: &str, script: &str) -> AgentSpec {
17920 AgentSpec {
17921 id: id.to_owned(),
17922 kind: crate::config::AgentKind::Command,
17923 model: None,
17924 command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
17925 extra_args: Vec::new(),
17926 env: BTreeMap::new(),
17927 prompt_delivery: None,
17928 }
17929 }
17930
17931 fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
17932 SeatJob {
17933 seat: SeatState::new("impl-A", &spec.id, 1),
17934 spec,
17935 cwd: dir.to_path_buf(),
17936 prompt: "do it".to_owned(),
17937 timeout,
17938 allow_write: true,
17939 sessions: false,
17940 artifacts: dir.join("art"),
17941 stem: "impl-A".to_owned(),
17942 handover: None,
17943 }
17944 }
17945
17946 fn state(resource_wait_secs: u64) -> RunState {
17947 let mut config = Config::default();
17948 config.graph.timeout_resource_wait = resource_wait_secs;
17949 RunState::new(
17950 PathBuf::from("/repo"),
17951 "main".to_owned(),
17952 "abc1234".to_owned(),
17953 "task".to_owned(),
17954 config,
17955 )
17956 }
17957
17958 fn hold(cache: &Path) -> crate::cache::Guard {
17959 let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
17960 match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
17961 crate::cache::AcquireOutcome::Acquired(g) => g,
17962 crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
17963 }
17964 }
17965
17966 async fn run(
17967 jobs: Vec<SeatJob>,
17968 sem: Arc<Semaphore>,
17969 cache: Option<&Path>,
17970 st: &mut RunState,
17971 ) -> Vec<(usize, SeatState, AgentOutcome)> {
17972 let prompts = Prompts::default();
17973 let ctx = WaveCtx {
17974 run: "r",
17975 node: "implement",
17976 prompts: &prompts,
17977 cache,
17978 round: None,
17979 carry_seats: false,
17980 pauses: None,
17981 };
17982 wave(jobs, sem, &ctx, st, 0).await
17983 }
17984
17985 #[tokio::test]
17986 async fn verification_cache_wait_preserves_the_command_budget() {
17987 crate::run::pin_test_home();
17988 let tmp = tempfile::tempdir().unwrap();
17989 let cache = tmp.path().join("target");
17990 let guard = hold(&cache);
17991 let release = tokio::spawn(async move {
17992 tokio::time::sleep(Duration::from_millis(100)).await;
17993 guard.release();
17994 });
17995 let mut st = state(10);
17996 let budget = Duration::from_millis(50);
17997 let wait = repository_wait(&st, None);
17998 let (outcomes, _) = with_cache_lease(
17999 &mut st,
18000 Some(&cache),
18001 "gate",
18002 "gate",
18003 tmp.path(),
18004 "abc1234",
18005 budget,
18006 "test gate",
18007 &wait,
18008 |_, available| async move {
18009 assert_eq!(
18010 available, budget,
18011 "contention must not shorten verification"
18012 );
18013 (Vec::new(), false, Vec::new())
18014 },
18015 )
18016 .await
18017 .unwrap();
18018 release.await.unwrap();
18019 assert!(outcomes.is_empty(), "{outcomes:?}");
18020 assert!(
18021 st.events
18022 .iter()
18023 .any(|event| event.message.contains("waiting for"))
18024 );
18025 assert!(st.resource_wait.is_none());
18026 hold(&cache).release();
18028 }
18029
18030 #[tokio::test]
18031 async fn verification_cache_wait_expires_without_starting_commands() {
18032 crate::run::pin_test_home();
18033 let tmp = tempfile::tempdir().unwrap();
18034 let cache = tmp.path().join("target");
18035 let _held = hold(&cache);
18036 let mut st = state(0);
18037 let wait = repository_wait(&st, None);
18038 let (outcomes, _) = with_cache_lease(
18039 &mut st,
18040 Some(&cache),
18041 "gate",
18042 "gate",
18043 tmp.path(),
18044 "abc1234",
18045 Duration::from_secs(60),
18046 "test gate",
18047 &wait,
18048 |_, _| async { panic!("commands must not start without the cache lease") },
18049 )
18050 .await
18051 .unwrap();
18052 assert_eq!(outcomes.len(), 1);
18053 assert!(outcomes[0].resource_blocked);
18054 assert!(st.resource_wait.is_none());
18055 }
18056
18057 #[tokio::test]
18058 async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
18059 crate::run::pin_test_home();
18060 let tmp = tempfile::tempdir().unwrap();
18061 let cache = tmp.path().join("target-a");
18062 let guard = hold(&cache);
18063 tokio::spawn(async move {
18065 tokio::time::sleep(Duration::from_millis(1500)).await;
18066 guard.release();
18067 });
18068 let mut st = state(60);
18069 let results = run(
18070 vec![job(
18071 sh("alpha", "echo ok"),
18072 tmp.path(),
18073 Duration::from_secs(1),
18074 )],
18075 Arc::new(Semaphore::new(1)),
18076 Some(&cache),
18077 &mut st,
18078 )
18079 .await;
18080 assert!(
18081 matches!(results[0].2, AgentOutcome::Ok(_)),
18082 "the wait must not be charged to the agent"
18083 );
18084 assert!(st.resource_block.is_none());
18085 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
18086 assert!(st.resource_wait.is_none() && st.active.is_empty());
18087 }
18088
18089 #[tokio::test]
18090 #[cfg(unix)]
18091 async fn repository_wait_in_a_wave_is_a_bounded_cancellable_resource_block() {
18092 use std::os::fd::AsRawFd as _;
18093 crate::run::pin_test_home();
18094 let tmp = tempfile::tempdir().unwrap();
18095 crate::git::git(tmp.path(), &["init"]).await.unwrap();
18096 let common = tmp.path().join(".git");
18097 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
18098 let external = std::fs::File::create(common.join("updater.lock")).unwrap();
18099 assert_eq!(
18100 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
18101 0
18102 );
18103 let mut st = state(1);
18104 st.config.coordination = crate::config::test_coordination();
18105 let mut reader = job(
18106 sh("reader", "echo must-not-run"),
18107 tmp.path(),
18108 Duration::from_secs(30),
18109 );
18110 reader.allow_write = false;
18111 let results = run(
18112 vec![reader.clone()],
18113 Arc::new(Semaphore::new(1)),
18114 None,
18115 &mut st,
18116 )
18117 .await;
18118 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
18119 assert_eq!(results[0].1.turns, 0);
18120 assert!(FailClass::of(&results[0].2).is_none());
18121 assert_eq!(
18122 st.resource_block.as_ref().unwrap().kind,
18123 "repository coordination"
18124 );
18125 assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
18126 assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
18127 drop(external);
18128 let editor = crate::repo_lock::SeatLease::for_checkout(
18129 tmp.path(),
18130 true,
18131 crate::config::test_coordination().as_ref(),
18132 )
18133 .await
18134 .unwrap();
18135 let pause = Pause::new();
18136 let interrupt = Pause::new();
18137 let prompts = Prompts::default();
18138 let ctx = WaveCtx {
18139 run: "r",
18140 node: "review",
18141 prompts: &prompts,
18142 cache: None,
18143 round: None,
18144 carry_seats: false,
18145 pauses: Some((&pause, &interrupt)),
18146 };
18147 let (results, ()) = tokio::join!(
18148 wave(vec![reader], Arc::new(Semaphore::new(1)), &ctx, &mut st, 0),
18149 async {
18150 tokio::time::sleep(Duration::from_millis(50)).await;
18151 pause.park();
18152 }
18153 );
18154 assert!(
18155 matches!(&results[0].2, AgentOutcome::ResourceBlocked(why) if why.contains("parking"))
18156 );
18157 assert_eq!(results[0].1.turns, 0);
18158 assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
18159 assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
18160 drop(editor);
18161 }
18162
18163 #[tokio::test]
18164 async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
18165 crate::run::pin_test_home();
18166 let tmp = tempfile::tempdir().unwrap();
18167 let cache = tmp.path().join("target-b");
18168 let _held = hold(&cache);
18169 let mut st = state(1);
18170 let results = run(
18171 vec![job(
18172 sh("alpha", "echo ok"),
18173 tmp.path(),
18174 Duration::from_secs(1),
18175 )],
18176 Arc::new(Semaphore::new(1)),
18177 Some(&cache),
18178 &mut st,
18179 )
18180 .await;
18181 let (_, seat, out) = &results[0];
18182 assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
18183 assert!(wave_blocked(&results));
18184 assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
18185 assert_eq!(seat.turns, 0, "nothing was asked");
18186 assert!(
18187 !tmp.path().join("art").join("impl-A.prompt.md").exists(),
18188 "no agent was spawned"
18189 );
18190 assert!(FailClass::of(out).is_none(), "not a failure class");
18191 let block = st.resource_block.as_ref().expect("recorded for the resume");
18192 assert_eq!(block.kind, "shared build cache");
18193 assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
18194 assert!(st.active.is_empty());
18195 }
18196
18197 #[tokio::test]
18198 async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
18199 crate::run::pin_test_home();
18200 let tmp = tempfile::tempdir().unwrap();
18201 let mut st = state(1);
18202 let results = run(
18203 vec![job(
18204 sh("alpha", "echo ok"),
18205 tmp.path(),
18206 Duration::from_secs(1),
18207 )],
18208 Arc::new(Semaphore::new(0)),
18209 None,
18210 &mut st,
18211 )
18212 .await;
18213 assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
18214 assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
18215 assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
18216 }
18217
18218 #[tokio::test]
18219 async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
18220 crate::run::pin_test_home();
18221 let tmp = tempfile::tempdir().unwrap();
18222 let mut st = state(60);
18223 let results = run(
18224 vec![job(
18225 sh("alpha", "sleep 30"),
18226 tmp.path(),
18227 Duration::from_secs(1),
18228 )],
18229 Arc::new(Semaphore::new(1)),
18230 None,
18231 &mut st,
18232 )
18233 .await;
18234 let out = &results[0].2;
18235 assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
18236 let class = FailClass::of(out).expect("a failure");
18237 assert_eq!(class, FailClass::Timeout);
18238 assert!(should_hand_over(None, &class), "existing handover policy");
18239 assert!(st.resource_block.is_none());
18240 }
18241}