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::land;
41use crate::proc::Quiet as _;
42use crate::prompt::{
43 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
44};
45use crate::queue;
46use crate::refs;
47use crate::run::{
48 BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
49 DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord, Handover,
50 JobRecord, JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome,
51 OperatorFixRequest, Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState,
52 RunStatus, SeatHistory, Tally, VoteRecord, tail, write_artifact,
53};
54use crate::verdict::{
55 self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
56 ReviewVote, Severity,
57};
58use crate::worktree_setup;
59
60const OUTPUT_TAIL: usize = 8_000;
62
63const EVENT_OUTPUT_TAIL: usize = 2_000;
66
67const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
70
71const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
88
89pub(crate) const STAGNANT_LIMIT: usize = 2;
103
104const BASE_SYNC_ROUNDS: usize = 4;
117
118const MAX_FIX_CONTINUATIONS: usize = 2;
134
135#[derive(Clone)]
141struct SeatJob {
142 spec: AgentSpec,
143 seat: SeatState,
144 cwd: PathBuf,
145 prompt: String,
146 timeout: Duration,
147 allow_write: bool,
148 sessions: bool,
149 artifacts: PathBuf,
150 stem: String,
151 handover: Option<String>,
156}
157
158enum AgentOutcome {
170 Ok(AgentOutput),
172 Quota(AgentOutput),
174 Dropped(AgentOutput),
177 Failed(String),
179}
180
181#[derive(Debug, Clone, Default)]
208pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
209
210impl Pause {
211 #[must_use]
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 pub fn park(&self) {
219 self.0.store(true, Ordering::SeqCst);
220 }
221
222 pub fn park_because(&self, reason: impl Into<String>) {
228 let mut reason_guard = self
229 .1
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner);
232 if reason_guard.is_none() {
233 *reason_guard = Some(reason.into());
234 }
235 drop(reason_guard);
236 self.park();
237 }
238
239 #[must_use]
241 pub fn parked(&self) -> bool {
242 self.0.load(Ordering::SeqCst)
243 }
244
245 #[must_use]
247 pub fn reason(&self) -> Option<String> {
248 self.1
249 .lock()
250 .unwrap_or_else(std::sync::PoisonError::into_inner)
251 .clone()
252 }
253}
254
255pub struct Runner {
257 pub state: RunState,
259 roles: ResolvedRoles,
260 sem: Arc<Semaphore>,
261 pause: Pause,
265 interrupt: Pause,
271}
272
273async fn review_base(
278 repo: &Path,
279 remote: &str,
280 base_branch: &str,
281 base_commit: &str,
282 branch: &str,
283) -> String {
284 review_base_checked(repo, remote, base_branch, base_commit, branch)
285 .await
286 .0
287}
288
289async fn review_base_checked(
294 repo: &Path,
295 remote: &str,
296 base_branch: &str,
297 base_commit: &str,
298 branch: &str,
299) -> (String, bool) {
300 let tracking = format!("{remote}/{base_branch}");
301 let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
302 if git::rev_exists(repo, &tracking).await
303 && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
304 && !mb.is_empty()
305 {
306 return (mb, fresh);
307 }
308 let mb = git::merge_base(repo, base_commit, branch)
309 .await
310 .ok()
311 .filter(|mb| !mb.is_empty())
312 .unwrap_or_else(|| base_commit.to_owned());
313 (mb, false)
314}
315
316pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
320 if !is_review_run(state) {
321 return;
322 }
323 let base = review_base(
324 &state.repo,
325 &state.config.merge.remote,
326 &state.base_branch,
327 &state.base_commit,
328 branch,
329 )
330 .await;
331 if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
332 && !subjects.is_empty()
333 && state.reviewed_commits.as_ref() != Some(&subjects)
334 {
335 state.reviewed_commits = Some(subjects);
336 state.save().ok();
337 }
338}
339
340async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
344 let (base, trusted) = review_base_checked(
345 &state.repo,
346 &state.config.merge.remote,
347 &state.base_branch,
348 &state.base_commit,
349 branch,
350 )
351 .await;
352 if !trusted {
353 return None;
354 }
355 git::subjects(&state.repo, &state.base_commit, &base)
356 .await
357 .ok()
358}
359
360fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
364 let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
365 if is_leak(computed) {
366 return false;
367 }
368 let cur = current.trim();
369 cur.is_empty()
370 || cur.starts_with(REVIEW_PROMPT_OPENING)
371 || cur.starts_with("chore: land candidate")
372 || cur.starts_with("magi: candidate")
373 || is_leak(cur)
374}
375
376async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
405 let tracking = format!("{remote}/{branch}");
406 let fetched = git::fetch(repo, remote, branch).await;
407 let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
408 let local_exists = git::branch_exists(repo, branch).await?;
409 if !fresh {
410 if !local_exists {
411 bail!("no branch `{branch}` in {} or on {remote}", repo.display());
412 }
413 tracing::warn!(
414 "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
415 );
416 return Ok(());
417 }
418 let remote_sha = git::rev_parse(repo, &tracking).await?;
419 if !local_exists {
420 git::git(repo, &["branch", branch, &tracking]).await?;
421 return Ok(());
422 }
423 let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
424 if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
425 return Ok(());
426 }
427 if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
428 match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
434 .await?
435 {
436 crate::reconcile::Reconciliation::Pushed => {
437 tracing::warn!(
438 "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
439 short(&local_sha),
440 short(&remote_sha)
441 );
442 return Ok(());
443 }
444 crate::reconcile::Reconciliation::Placeholder => {}
445 crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
446 }
447 }
448 let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
449 if !out.ok() {
450 bail!(
451 "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
452 short(&local_sha),
453 short(&remote_sha),
454 out.stderr
455 );
456 }
457 tracing::warn!(
458 "local `{branch}` was stale: fast-forwarded {} -> {}",
459 short(&local_sha),
460 short(&remote_sha)
461 );
462 Ok(())
463}
464
465async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
466 let tracking = format!("{remote}/{base_branch}");
467 let fetched = git::fetch(repo, remote, base_branch).await;
468 if let Ok(out) = &fetched
469 && out.ok()
470 && git::rev_exists(repo, &tracking).await
471 {
472 return git::rev_parse(repo, &tracking).await;
473 }
474 let why = match &fetched {
475 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
476 Ok(_) => format!("{remote} has no {base_branch}"),
477 Err(e) => e.to_string(),
478 };
479 bail!(
482 "cannot read {tracking} ({why}); refusing to branch off the local \
483 `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
484 base / remote in magi.toml"
485 )
486}
487
488struct FixClaim {
505 path: PathBuf,
506}
507
508impl FixClaim {
509 fn acquire(dir: &Path) -> Result<Self> {
510 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
511 let path = dir.join("fix.lock");
512 match Self::create(&path) {
513 Ok(claim) => Ok(claim),
514 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
515 if Self::reclaim_if_dead(&path) {
516 Self::create(&path).with_context(|| format!("lock {}", path.display()))
517 } else {
518 bail!(
519 "another `magi fix` is already running for this run ({} exists)",
520 path.display()
521 )
522 }
523 }
524 Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
525 }
526 }
527
528 fn create(path: &Path) -> std::io::Result<Self> {
529 let mut f = std::fs::OpenOptions::new()
530 .write(true)
531 .create_new(true)
532 .open(path)?;
533 use std::io::Write as _;
534 writeln!(f, "{}", std::process::id())?;
536 Ok(Self {
537 path: path.to_owned(),
538 })
539 }
540
541 fn reclaim_if_dead(path: &Path) -> bool {
545 let dead = std::fs::read_to_string(path)
546 .ok()
547 .and_then(|body| body.trim().parse::<u32>().ok())
548 .is_some_and(|pid| !crate::proc::pid_alive(pid));
549 dead && std::fs::remove_file(path).is_ok()
550 }
551}
552
553impl Drop for FixClaim {
554 fn drop(&mut self) {
555 let _ = std::fs::remove_file(&self.path);
556 }
557}
558
559impl Runner {
560 pub async fn start(
562 repo: &Path,
563 instruction: String,
564 config: Config,
565 origin: Origin,
566 ) -> Result<Self> {
567 Self::start_naming(repo, instruction, "", config, origin).await
568 }
569
570 pub async fn start_naming(
574 repo: &Path,
575 instruction: String,
576 also_scan: &str,
577 config: Config,
578 origin: Origin,
579 ) -> Result<Self> {
580 let repo = git::toplevel(repo).await?;
581 let missing = agent::missing_programs(&config.agents);
582 if !missing.is_empty() {
583 bail!(
584 "these agent programs are not on PATH: {}. Fix the roster in \
585 magi.toml or install them.",
586 missing.join(", ")
587 );
588 }
589 let base_branch =
590 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
591 .await?;
592 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
593 let roles = config.resolve_roles()?;
594 let max_parallel = config.graph.max_parallel.max(1);
595 let seeds = refs::resolve(
598 &repo,
599 &base_commit,
600 &config.merge.remote,
601 &format!("{also_scan}\n{instruction}"),
602 )
603 .await;
604 refs::plan(&repo, &seeds).await?;
605 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
606 state.origin = Some(origin);
610 for seed in &seeds {
611 state.event(
612 "seed",
613 refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
614 );
615 }
616 state.seeds = seeds;
617 state.event("start", format!("run {} created", state.id));
618 state.save()?;
619 Ok(Self {
620 state,
621 roles,
622 sem: Arc::new(Semaphore::new(max_parallel)),
623 pause: Pause::new(),
624 interrupt: Pause::new(),
625 })
626 }
627
628 pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
642 Self::review_taking_over(repo, branch, config, None, origin).await
643 }
644
645 pub async fn review_taking_over(
652 repo: &Path,
653 branch: &str,
654 config: Config,
655 takeover: Option<crate::handover::Takeover>,
656 origin: Origin,
657 ) -> Result<Self> {
658 let repo = git::toplevel(repo).await?;
659 let missing = agent::missing_programs(&config.agents);
660 if !missing.is_empty() {
661 bail!(
662 "these agent programs are not on PATH: {}. Fix the roster in \
663 magi.toml or install them.",
664 missing.join(", ")
665 );
666 }
667 let base_branch =
668 git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
669 .await?;
670 if base_branch == branch {
671 bail!("`{branch}` is the base branch; there is nothing to review against");
672 }
673 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
674
675 let roles = config.resolve_roles()?;
676 let max_parallel = config.graph.max_parallel.max(1);
677 let mut state = RunState::new(
678 repo.clone(),
679 base_branch,
680 base_commit.clone(),
681 String::new(),
682 config,
683 );
684 state.origin = Some(origin);
685
686 let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
693 earlier: Vec::new(),
694 home: crate::run::home(),
695 choice: None,
696 });
697 let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
698 if let Some(released) = &released {
699 state.event(
700 "release",
701 format!(
702 "took `{branch}` over from run {}: its worktree was released: {}",
703 crate::run::short_of(&released.old_id),
704 released.audit
705 ),
706 );
707 }
708 if let Some(choice) = takeover.choice.as_ref()
712 && let Err(e) =
713 crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
714 .await
715 {
716 if let Some(released) = &released {
717 released.restore(&repo, branch).await;
718 }
719 return Err(e.context("applying the owner's answer about the diverged branch"));
720 }
721 let opened =
722 Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
723 if opened.is_err()
724 && let Some(released) = &released
725 {
726 released.restore(&repo, branch).await;
727 }
728 opened
729 }
730
731 async fn open_review(
734 repo: &Path,
735 branch: &str,
736 mut state: RunState,
737 roles: ResolvedRoles,
738 max_parallel: usize,
739 base_commit: String,
740 ) -> Result<Self> {
741 sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
742 let start = review_base(
745 repo,
746 &state.config.merge.remote,
747 &state.base_branch,
748 &base_commit,
749 branch,
750 )
751 .await;
752 let log = git::log_oneline(repo, &start, branch)
753 .await
754 .unwrap_or_default();
755 let instruction = format!(
756 "Review the work already on branch `{branch}`. There is no task \
757 statement: what the change claims to do is whatever its commits \
758 say.\n\n{}",
759 if log.trim().is_empty() {
760 "(no commit messages)"
761 } else {
762 log.trim()
763 }
764 );
765 state.instruction = instruction;
766 state.reviewed_commits = Some(
767 git::subjects(repo, &start, branch)
768 .await
769 .unwrap_or_default(),
770 );
771
772 let worktree = state.worktree_root().join("under-review");
775 if let Some(parent) = worktree.parent() {
776 tokio::fs::create_dir_all(parent).await.ok();
777 }
778 let path = worktree.to_string_lossy().to_string();
779 git::git(repo, &["worktree", "add", &path, branch])
780 .await
781 .with_context(|| {
782 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
783 })?;
784
785 let setup = match ensure_setup_config(&mut state, repo).await {
786 Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
787 Err(e) => Err(e),
788 };
789 if let Err(e) = setup {
790 git::worktree_remove(repo, &worktree).await.ok();
791 return Err(e);
792 }
793 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
794 .await
795 .unwrap_or(0);
796 if commits == 0 {
797 git::worktree_remove(repo, &worktree).await.ok();
798 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
799 }
800 let files = git::changed_files(&worktree, &base_commit, "HEAD")
801 .await
802 .map(|f| f.len())
803 .unwrap_or(0);
804 if files == 0
805 && let (Ok(head_tree), Ok(base_tree)) = (
806 git::tree_of(&worktree, "HEAD").await,
807 git::tree_of(&worktree, &base_commit).await,
808 )
809 && head_tree == base_tree
810 {
811 let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
812 git::worktree_remove(repo, &worktree).await.ok();
813 bail!(
814 "`{branch}` at {} has a tree identical to base {}; this usually means \
815 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
816 against `{}/{branch}`) rather than an empty change",
817 short(&head),
818 short(&base_commit),
819 state.config.merge.remote
820 );
821 }
822 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
823 .await
824 .unwrap_or_default();
825
826 state.candidates.push(Candidate {
827 index: 0,
828 label: 'A',
829 agent: EXISTING_BRANCH.to_owned(),
832 branch: branch.to_owned(),
833 worktree,
834 summary: String::new(),
835 stat,
836 files,
837 commits,
838 empty: false,
839 failed: None,
840 verified_noop: None,
841 duration_ms: 0,
842 folded: false,
843 });
844 state.tally = Some(Tally {
845 first_choice: BTreeMap::from([('A', 0)]),
846 borda: BTreeMap::new(),
847 winner: 'A',
848 rankings: 0,
849 unanimous_initial: false,
850 deliberated: false,
851 changed_votes: 0,
852 unanimous_final: false,
853 tie_break: None,
854 judges: 0,
858 present: 0,
859 quorum: 0,
860 met_quorum: true,
861 uncontested: Some("review-only run: nothing competed".to_owned()),
862 });
863 state.status = RunStatus::Reviewing;
864 state.event(
865 "start",
866 format!(
867 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
868 state.id
869 ),
870 );
871 state.save()?;
872 Ok(Self {
873 state,
874 roles,
875 sem: Arc::new(Semaphore::new(max_parallel)),
876 pause: Pause::new(),
877 interrupt: Pause::new(),
878 })
879 }
880
881 pub fn resume(id: &str) -> Result<Self> {
883 let state = RunState::load(id)?;
884 if let Some(to) = &state.released_to {
885 bail!(
886 "run {} cannot be resumed: its worktree was released to run {}",
887 state.short(),
888 crate::run::short_of(to)
889 );
890 }
891 let roles = state.config.resolve_roles()?;
892 let max_parallel = state.config.graph.max_parallel.max(1);
893 Ok(Self {
894 state,
895 roles,
896 sem: Arc::new(Semaphore::new(max_parallel)),
897 pause: Pause::new(),
898 interrupt: Pause::new(),
899 })
900 }
901
902 pub async fn execute(&mut self) -> Result<()> {
909 let result = self.execute_graph().await;
910 self.mark_driver_exited();
911 let ended = if result.is_err() {
912 Some(crate::notices::run_stopped(&self.state.id, &self.state))
913 } else {
914 crate::notices::run_ended(&self.state)
915 };
916 if let Some(notice) = ended {
917 crate::notices::raise(notice);
918 }
919 result
920 }
921
922 fn mark_driver_exited(&mut self) {
931 self.state.driver_exited = true;
932 let pid = std::process::id();
933 let Ok(mut disk) = RunState::load(&self.state.id) else {
934 return;
935 };
936 if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
937 return;
938 }
939 disk.driver_exited = true;
940 if let Err(e) = disk.save() {
941 tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
942 }
943 }
944
945 async fn execute_graph(&mut self) -> Result<()> {
946 self.state.parked = false;
951 self.state.clear_active();
958 if let Ok(disk) = RunState::load(&self.state.id)
976 && let Some(to) = &disk.released_to
977 {
978 bail!(
979 "run {} cannot continue: its worktree was released to run {}",
980 self.state.short(),
981 crate::run::short_of(to)
982 );
983 }
984 let pid = std::process::id();
985 self.state.driver_pid = Some(pid);
986 self.state.driver_started_at = crate::proc::process_started_at(pid);
987 self.state.driver_exited = false;
988 self.state.save()?;
989 if self.state.status == RunStatus::Stalled {
1002 if self.recover_stall().await? {
1003 self.finish_after_tally().await?;
1004 } else {
1005 self.state.save()?;
1007 }
1008 return Ok(());
1009 }
1010 if self.state.status == RunStatus::Landing {
1020 self.run_land().await?;
1021 self.settle_questions();
1026 return Ok(());
1027 }
1028 if self.state.github_text.is_some()
1033 && self.state.merge.is_none()
1034 && self.state.status == RunStatus::Gating
1035 {
1036 self.merge().await?;
1037 self.state.save()?;
1038 return Ok(());
1039 }
1040 self.prep().await?;
1041 if self.park_here()? {
1042 return Ok(());
1043 }
1044 self.advise().await?;
1045 if self.park_here()? {
1046 return Ok(());
1047 }
1048 self.implement().await?;
1049 if self.park_here()? {
1050 return Ok(());
1051 }
1052 if self.state.status == RunStatus::VerifiedNoop {
1056 return Ok(());
1057 }
1058 self.judge().await?;
1059 if self.park_here()? {
1060 return Ok(());
1061 }
1062 self.deliberate().await?;
1063 if self.park_here()? {
1064 return Ok(());
1065 }
1066 self.vote().await?;
1067 if self.park_here()? {
1068 return Ok(());
1069 }
1070 self.tally()?;
1071 if self.state.status == RunStatus::Stalled {
1076 self.state.save()?;
1080 return Ok(());
1081 }
1082 self.finish_after_tally().await?;
1083 Ok(())
1084 }
1085
1086 fn park_here(&mut self) -> Result<bool> {
1093 if !self.pause.parked() && !self.interrupt.parked() {
1098 return Ok(false);
1099 }
1100 let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1101 Some(reason) => format!(
1102 "parked after `{}` ({reason}) — resume to carry on from here",
1103 self.state.status.as_str()
1104 ),
1105 None => format!(
1106 "parked after `{}` — resume to carry on from here",
1107 self.state.status.as_str()
1108 ),
1109 };
1110 self.state.event("park", why);
1111 self.state.parked = true;
1112 self.state.save()?;
1113 Ok(true)
1114 }
1115
1116 pub fn on_pause(&mut self, pause: Pause) {
1118 self.pause = pause;
1119 }
1120
1121 pub fn watch_interrupt(&mut self, pause: Pause) {
1127 self.interrupt = pause;
1128 }
1129
1130 fn settle_questions(&mut self) {
1150 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1151 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1152 }
1153 }
1154
1155 async fn finish_after_tally(&mut self) -> Result<()> {
1158 self.fold_losers().await?;
1159 self.sync_to_base().await?;
1164 if self.state.status == RunStatus::AlreadyInBase {
1165 return Ok(());
1166 }
1167 self.review_loop().await?;
1168 self.sync_to_base().await?;
1169 if self.state.status == RunStatus::AlreadyInBase {
1170 return Ok(());
1171 }
1172 self.gate().await?;
1173 self.merge().await?;
1174 self.state.save()?;
1175 Ok(())
1176 }
1177
1178 async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1185 let result = match ensure_setup_config(&mut self.state, repo).await {
1186 Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1187 Err(e) => Err(e),
1188 };
1189 if let Err(e) = result {
1190 git::worktree_remove(repo, wt).await.ok();
1191 if in_prep {
1192 self.rollback_prep(repo).await;
1193 }
1194 self.state.status = RunStatus::Blocked;
1195 self.state.event(
1196 "setup",
1197 format!("worktree setup failed in {}: {e:#}", wt.display()),
1198 );
1199 self.state.save()?;
1200 return Err(e);
1201 }
1202 Ok(())
1203 }
1204
1205 async fn rollback_prep(&mut self, repo: &Path) {
1207 let root = self.state.worktree_root();
1208 for c in std::mem::take(&mut self.state.candidates) {
1209 git::worktree_remove(repo, &c.worktree).await.ok();
1210 git::branch_delete(repo, &c.branch).await.ok();
1211 }
1212 let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1213 for j in 1..=seats {
1214 git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1215 .await
1216 .ok();
1217 }
1218 for k in 1..=self.state.config.graph.advisors {
1219 git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1220 .await
1221 .ok();
1222 }
1223 }
1224
1225 async fn prep(&mut self) -> Result<()> {
1226 if !self.state.candidates.is_empty() {
1227 return Ok(());
1228 }
1229 self.state.status = RunStatus::Prep;
1230 let repo = self.state.repo.clone();
1231 let base = self.state.base_commit.clone();
1232 let plan = refs::plan(&repo, &self.state.seeds).await?;
1233 let start = plan.start.clone().unwrap_or_else(|| base.clone());
1234 let root = self.state.worktree_root();
1235 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1236
1237 let hooks_dir = self.state.dir().join("hooks");
1240 if self.state.config.blind.commit_msg_hook {
1241 std::fs::create_dir_all(&hooks_dir)
1242 .with_context(|| format!("create {}", hooks_dir.display()))?;
1243 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1244 let path = hooks_dir.join("commit-msg");
1245 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1246 make_executable(&path)?;
1247 git::acquire_worktree_config(&repo).await?;
1255 self.state.enabled_worktree_config = true;
1256 }
1257
1258 ensure_setup_config(&mut self.state, &repo).await?;
1261
1262 for (index, (spec, label)) in self
1263 .roles
1264 .implementers
1265 .clone()
1266 .into_iter()
1267 .zip(labels)
1268 .enumerate()
1269 {
1270 let branch = self.state.branch_for(label);
1271 let worktree = root.join(format!("cand-{label}"));
1272 git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1273 if self.state.config.blind.commit_msg_hook {
1274 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1275 }
1276 git::local_exclude(&worktree, "/.magi/").await?;
1277 if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1278 git::worktree_remove(&repo, &worktree).await.ok();
1281 git::branch_delete(&repo, &branch).await.ok();
1282 self.rollback_prep(&repo).await;
1283 self.state.status = RunStatus::Blocked;
1284 self.state
1285 .event("prep", format!("worktree setup failed: {e:#}"));
1286 self.state.save()?;
1287 return Err(e);
1288 }
1289 for pick in &plan.picks {
1290 if let Err(e) = git::cherry_pick(&worktree, pick).await {
1291 self.state.status = RunStatus::Blocked;
1292 self.state
1293 .event("prep", format!("cannot apply referenced commit: {e}"));
1294 self.state.save()?;
1295 return Err(e);
1296 }
1297 }
1298 self.state.candidates.push(Candidate {
1299 index,
1300 label,
1301 agent: spec.id.clone(),
1302 branch,
1303 worktree,
1304 summary: String::new(),
1305 stat: String::new(),
1306 files: 0,
1307 commits: 0,
1308 empty: false,
1309 failed: None,
1310 verified_noop: None,
1311 duration_ms: 0,
1312 folded: false,
1313 });
1314 }
1315
1316 for j in 1..=self.roles.judges.len() {
1317 let wt = root.join(format!("judge-{j}"));
1318 if !wt.exists() {
1319 git::worktree_add_detached(&repo, &wt, &base).await?;
1320 self.setup_seat_worktree(&repo, &wt, true).await?;
1321 }
1322 }
1323
1324 if self.state.config.graph.advise {
1332 for k in 1..=self.state.config.graph.advisors {
1333 let wt = root.join(format!("advisor-{k}"));
1334 if !wt.exists() {
1335 git::worktree_add_detached(&repo, &wt, &base).await?;
1336 self.setup_seat_worktree(&repo, &wt, true).await?;
1337 }
1338 }
1339 }
1340
1341 let authors: Vec<&str> = self
1346 .roles
1347 .implementers
1348 .iter()
1349 .map(|a| a.id.as_str())
1350 .collect();
1351 let overlap: Vec<String> = self
1352 .roles
1353 .judges
1354 .iter()
1355 .enumerate()
1356 .filter(|(_, j)| authors.contains(&j.id.as_str()))
1357 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1358 .collect();
1359 if !overlap.is_empty() {
1360 let note = format!(
1361 "{} also authored a candidate; blind, but the panel is less \
1362 independent than {} distinct agents would be",
1363 overlap.join(", "),
1364 self.roles.judges.len()
1365 );
1366 self.state.event("prep", note);
1367 }
1368
1369 self.state.event(
1370 "prep",
1371 format!(
1372 "{} candidates, {} judges, base {} ({})",
1373 self.state.candidates.len(),
1374 self.roles.judges.len(),
1375 &self.state.base_commit[..7.min(self.state.base_commit.len())],
1376 self.state.base_branch
1377 ),
1378 );
1379 self.state.status = RunStatus::Implementing;
1380 self.state.save()?;
1381 Ok(())
1382 }
1383
1384 async fn advise(&mut self) -> Result<()> {
1417 let implement_untouched = self
1418 .state
1419 .candidates
1420 .iter()
1421 .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1422 if !self.state.config.graph.advise || self.state.advise_attempted {
1423 return Ok(());
1424 }
1425 if !implement_untouched {
1426 self.state.event(
1427 "advise",
1428 "skipping the design-deliberation stage: at least one \
1429 candidate already shows implementation progress, so this \
1430 run is past the point the stage exists to run before"
1431 .to_owned(),
1432 );
1433 self.state.advise_attempted = true;
1434 self.state.save()?;
1435 return Ok(());
1436 }
1437 let run_id = self.state.id.clone();
1438 let prompts = self.state.config.prompts.clone();
1439 let instruction = self.state.instruction.clone();
1440 let language = self.state.config.graph.language.clone();
1441 let root = self.state.worktree_root();
1442 let n = self.state.config.graph.advisors;
1443 let where_recorded = self.state.dir().join("run.json");
1444
1445 let seats = match self.state.config.advisors() {
1446 Ok(seats) if !seats.is_empty() => seats,
1447 Ok(_) => {
1448 self.state.event(
1449 "advise",
1450 format!(
1451 "[graph] advisors is 0; skipping the design-deliberation \
1452 stage and continuing without a synthesis brief (see {})",
1453 where_recorded.display()
1454 ),
1455 );
1456 self.state.advise_attempted = true;
1457 self.state.save()?;
1458 return Ok(());
1459 }
1460 Err(e) => {
1461 self.state.event(
1462 "advise",
1463 format!(
1464 "could not resolve advisor seats ({e:#}); continuing \
1465 without a design-deliberation brief (see {})",
1466 where_recorded.display()
1467 ),
1468 );
1469 self.state.advise_attempted = true;
1470 self.state.save()?;
1471 return Ok(());
1472 }
1473 };
1474
1475 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1476 let artifacts = agent::artifacts_dir(&self.state.dir());
1477 let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1478
1479 let mut jobs = Vec::new();
1480 for (i, spec) in seats.iter().cloned().enumerate() {
1481 let seat_key = format!("advisor-{}", i + 1);
1482 let seat = self.seat(&seat_key, &spec.id);
1483 jobs.push(SeatJob {
1484 prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1485 spec,
1486 seat,
1487 cwd: worktrees[i % worktrees.len()].clone(),
1488 timeout,
1489 allow_write: false,
1490 sessions: false,
1491 artifacts: artifacts.clone(),
1492 stem: seat_key,
1493 handover: None,
1494 });
1495 }
1496
1497 self.state.event(
1498 "advise",
1499 format!(
1500 "{} advisor seat(s) sketching a design in parallel",
1501 jobs.len()
1502 ),
1503 );
1504 let mut quota_losses = Vec::new();
1505 let cache = self.state.config.cache_dir();
1506 let ctx = WaveCtx {
1507 carry_seats: false,
1508 run: &run_id,
1509 node: "advise",
1510 prompts: &prompts,
1511 cache: cache.as_deref(),
1512 round: None,
1513 };
1514 let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1515 let results = ask_json_wave::<Proposal>(
1516 jobs,
1517 Arc::clone(&self.sem),
1518 self.state.config.graph.retries,
1519 &advisor_roster,
1520 &ctx,
1521 &mut quota_losses,
1522 &mut self.state,
1523 &|p: &Proposal| p.validate(),
1524 )
1525 .await;
1526 self.state.quota.extend(quota_losses);
1527
1528 let mut records = Vec::with_capacity(results.len());
1529 for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1530 let agent_id = seat.agent.clone();
1531 self.state.seats.insert(seat.key.clone(), seat);
1532 match res {
1533 Ok((proposal, out)) => {
1534 self.state
1535 .event("advise", format!("advisor-{} proposed a design", i + 1));
1536 records.push(advise::AdvisorRecord::proposed(
1537 i + 1,
1538 agent_id,
1539 proposal,
1540 out.duration_ms,
1541 ));
1542 }
1543 Err(e) => {
1544 self.state.event(
1545 "advise",
1546 format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1547 );
1548 records.push(advise::AdvisorRecord::failed(
1549 i + 1,
1550 agent_id,
1551 e.to_string(),
1552 ));
1553 }
1554 }
1555 }
1556
1557 let mut advice = advise::Advice {
1558 records,
1559 synthesis: None,
1560 };
1561 if advice.proposals().is_empty() {
1562 self.state.event(
1563 "advise",
1564 "no advisor produced a usable proposal; continuing without a \
1565 synthesis brief"
1566 .to_owned(),
1567 );
1568 } else {
1569 match self
1570 .synthesize_brief(
1571 &advice,
1572 &instruction,
1573 &language,
1574 &worktrees[0],
1575 &artifacts,
1576 &run_id,
1577 &prompts,
1578 cache.as_deref(),
1579 )
1580 .await
1581 {
1582 Ok(Some(text)) => {
1583 self.state.event(
1584 "advise",
1585 "synthesized a design brief for the implementer".to_owned(),
1586 );
1587 advice.synthesis = Some(text);
1588 }
1589 Ok(None) => {
1590 self.state.event(
1591 "advise",
1592 "the synthesis seat produced nothing usable; continuing \
1593 without a design brief"
1594 .to_owned(),
1595 );
1596 }
1597 Err(e) => {
1598 self.state.event(
1599 "advise",
1600 format!("could not synthesize a design brief: {e:#}"),
1601 );
1602 }
1603 }
1604 }
1605 advise::apply_reflection(&mut advice);
1606
1607 self.state.advice = Some(advice);
1608 self.state.advise_attempted = true;
1609 self.state.save()?;
1610 Ok(())
1611 }
1612
1613 #[allow(clippy::too_many_arguments)]
1625 async fn synthesize_brief(
1626 &mut self,
1627 advice: &advise::Advice,
1628 instruction: &str,
1629 language: &str,
1630 cwd: &Path,
1631 artifacts: &Path,
1632 run_id: &str,
1633 prompts: &Prompts,
1634 cache: Option<&Path>,
1635 ) -> Result<Option<String>> {
1636 let chain = agent::pick_chain(
1637 &self.state.config.agents,
1638 self.state.config.roles.synthesizer.as_ref(),
1639 &agent::installed,
1640 "synthesizer",
1641 )?;
1642 let proposals = advice.proposals();
1643 let mut prompt = prompt::with_overlay(
1644 prompt::synthesize_brief(instruction, &proposals, language),
1645 prompts.overlay("advise"),
1646 );
1647 if cache.is_some() {
1648 prompt.push('\n');
1653 prompt.push_str(&prompt::build_cache_note("advise", false));
1654 }
1655 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1656 let mut last = None;
1661 for (n, spec) in chain.iter().enumerate() {
1662 if n > 0 {
1663 self.state
1664 .event("advise", format!("synthesis falling back to {}", spec.id));
1665 }
1666 let mut seat = self.seat("advise-synthesis", &spec.id);
1667 let outcome = agent::invoke(
1668 spec,
1669 &mut seat,
1670 &Invocation {
1671 cwd,
1672 prompt: &prompt,
1673 timeout,
1674 allow_write: false,
1675 unsandboxed: false,
1676 sessions: false,
1677 artifacts,
1678 stem: &if n == 0 {
1679 "advise-synthesis".to_owned()
1680 } else {
1681 format!("advise-synthesis-{}", spec.id)
1682 },
1683 run: run_id,
1684 node: "advise",
1685 cache_dir: None,
1686 attachments: &[],
1687 writable: &[],
1688 },
1689 )
1690 .await;
1691 if outcome.is_ok() {
1692 self.state.seats.insert(seat.key.clone(), seat);
1693 }
1694 let advance = agent::chain_advances(&outcome);
1695 last = Some(outcome);
1696 if !advance {
1697 break;
1698 }
1699 }
1700 let out = last.expect("a chain holds at least one agent")?;
1703 if !out.usable() {
1704 return Ok(None);
1705 }
1706 let text =
1707 verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1708 Ok((!text.trim().is_empty()).then_some(text))
1709 }
1710
1711 async fn implement(&mut self) -> Result<()> {
1714 let run_id = self.state.id.clone();
1719 let prompts = self.state.config.prompts.clone();
1720 let todo: Vec<usize> = self
1721 .state
1722 .candidates
1723 .iter()
1724 .enumerate()
1725 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1726 .map(|(i, _)| i)
1727 .collect();
1728 if todo.is_empty() {
1729 return self.after_implement();
1730 }
1731 self.state.status = RunStatus::Implementing;
1732
1733 let language = self.state.config.graph.language.clone();
1734 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1735 let sessions = self.state.config.graph.sessions;
1736 let artifacts = agent::artifacts_dir(&self.state.dir());
1737 let brief = self
1741 .state
1742 .advice
1743 .as_ref()
1744 .and_then(|a| a.synthesis.as_deref())
1745 .map(str::to_owned);
1746 let attachments = self.state.attachments.clone();
1747
1748 let mut jobs = Vec::new();
1749 for &i in &todo {
1750 let (index, label, worktree) = {
1751 let c = &self.state.candidates[i];
1752 (c.index, c.label, c.worktree.clone())
1753 };
1754 let spec = self.roles.implementers[index].clone();
1755 let seat_key = format!("impl-{label}");
1756 let seat = self.seat(&seat_key, &spec.id);
1757 let instruction = seeded_instruction(&self.state);
1758 jobs.push(SeatJob {
1759 spec,
1760 seat,
1761 prompt: prompt::implement(
1762 &instruction,
1763 &worktree.to_string_lossy(),
1764 &language,
1765 brief.as_deref(),
1766 &attachments,
1767 ),
1768 cwd: worktree,
1769 timeout,
1770 allow_write: true,
1771 sessions,
1772 artifacts: artifacts.clone(),
1773 stem: format!("impl-{label}"),
1774 handover: None,
1775 });
1776 }
1777
1778 self.state.event(
1779 "implement",
1780 format!("{} candidates in parallel", jobs.len()),
1781 );
1782 let mut sent = jobs.clone();
1788 let cache = self.state.config.cache_dir();
1789 let ctx = WaveCtx {
1790 carry_seats: false,
1791 run: &run_id,
1792 node: "implement",
1793 prompts: &prompts,
1794 cache: cache.as_deref(),
1795 round: None,
1796 };
1797 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1798 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1799 .await;
1800 self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1801 .await;
1802 self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1803 .await;
1804
1805 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1806 let seat_key = seat.key.clone();
1807 let agent = seat.agent.clone();
1816 let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1817 self.state.seats.insert(seat.key.clone(), seat);
1818 let label = self.state.candidates[i].label;
1819 let worktree = self.state.candidates[i].worktree.clone();
1820 let base = self.state.base_commit.clone();
1821
1822 let (summary, duration, failed, verified_claim) = match out {
1823 AgentOutcome::Ok(o) => {
1824 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1825 let failed = (!o.usable()).then(|| {
1826 if o.timed_out {
1827 "agent timed out".to_owned()
1828 } else {
1829 format!("agent exited with {:?}", o.exit_code)
1830 }
1831 });
1832 let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1833 (text, o.duration_ms, failed, verified_claim)
1834 }
1835 AgentOutcome::Dropped(o) => {
1841 let why = o
1842 .dropped
1843 .as_ref()
1844 .map(|d| d.why.as_str())
1845 .unwrap_or("the CLI ended the stream without delivering its answer");
1846 (
1847 String::new(),
1848 o.duration_ms,
1849 Some(format!("the CLI dropped the stream ({why})")),
1850 None,
1851 )
1852 }
1853 AgentOutcome::Quota(o) => {
1854 self.state.quota.push(QuotaLoss {
1855 seat: seat_key,
1856 node: "implement".to_owned(),
1857 at: Timestamp::now(),
1858 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1859 });
1860 (
1861 String::new(),
1862 o.duration_ms,
1863 Some("rate limited (quota); produced no change".to_owned()),
1864 None,
1865 )
1866 }
1867 AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1868 };
1869
1870 let rescued = match git::rescue_commit(
1873 &worktree,
1874 &format!("magi: candidate {label} (uncommitted work)"),
1875 )
1876 .await
1877 {
1878 Ok(r) => {
1879 self.state.note_withheld("implement", &r.withheld);
1880 r.committed
1881 }
1882 Err(_) => false,
1883 };
1884 let commits = git::commits_ahead(&worktree, &base, "HEAD")
1885 .await
1886 .unwrap_or(0);
1887 let patch = git::diff(&worktree, &base, "HEAD")
1888 .await
1889 .unwrap_or_default();
1890 let stat = git::diff_stat(&worktree, &base, "HEAD")
1891 .await
1892 .unwrap_or_default();
1893 let files = git::changed_files(&worktree, &base, "HEAD")
1894 .await
1895 .map(|f| f.len())
1896 .unwrap_or(0);
1897 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1898
1899 let c = &mut self.state.candidates[i];
1900 if !exhausted_the_fallback_chain {
1901 c.agent = agent;
1902 }
1903 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1904 c.stat = stat;
1905 c.files = files;
1906 c.commits = commits;
1907 c.duration_ms = duration;
1908 c.empty = commits == 0 || patch.trim().is_empty();
1909 c.failed = match failed {
1912 Some(_) if c.empty => failed,
1913 _ => None,
1914 };
1915 c.verified_noop = if c.empty { verified_claim } else { None };
1920 let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1921 (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1922 (None, true, Some(_), _) => {
1923 format!("candidate {label}: no change produced (agent-verified no-op)")
1924 }
1925 (None, true, None, _) => format!("candidate {label}: no change produced"),
1926 (None, false, _, true) => {
1927 format!(
1928 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1929 )
1930 }
1931 (None, false, _, false) => {
1932 format!("candidate {label}: {files} files, {commits} commits")
1933 }
1934 };
1935 self.state.event("implement", note);
1936 self.state.save()?;
1937 }
1938
1939 self.after_implement()
1940 }
1941
1942 async fn resume_undelivered(
1970 &mut self,
1971 results: &mut [(usize, SeatState, AgentOutcome)],
1972 sent: &[SeatJob],
1973 prompts: &Prompts,
1974 run_id: &str,
1975 ) {
1976 for (wi, seat, out) in results.iter_mut() {
1977 let Some(dropped) = (match &*out {
1978 AgentOutcome::Dropped(o) => o.dropped.clone(),
1979 _ => None,
1980 }) else {
1981 continue;
1982 };
1983 let Some(job) = sent.get(*wi) else { continue };
1984 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1986 self.state.event(
1987 "implement",
1988 format!(
1989 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1990 work is in the tree",
1991 seat.key, dropped.output_tokens, dropped.why
1992 ),
1993 );
1994 continue;
1995 }
1996 if !has_context(&job.spec, seat, job.sessions) {
2004 self.state.event(
2005 "implement",
2006 format!(
2007 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2008 is no session left to resume",
2009 seat.key, dropped.output_tokens, dropped.why
2010 ),
2011 );
2012 continue;
2013 }
2014 self.state.event(
2015 "implement",
2016 format!(
2017 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2018 conversation",
2019 seat.key, dropped.output_tokens, dropped.why
2020 ),
2021 );
2022 let mut retry = job.clone();
2023 retry.seat = seat.clone();
2024 retry.prompt = prompt::resume_after_drop(&dropped.why);
2025 retry.timeout = retry_budget(job.timeout, true);
2026 retry.stem = format!("{}-resume", job.stem);
2027 let cache = self.state.config.cache_dir();
2028 let ctx = WaveCtx {
2029 carry_seats: false,
2030 run: run_id,
2031 node: "implement",
2032 prompts,
2033 cache: cache.as_deref(),
2034 round: None,
2035 };
2036 let (resumed_seat, resumed) =
2037 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2038 *seat = resumed_seat;
2039 *out = resumed;
2040 }
2041 }
2042
2043 async fn resume_seat_handovers(
2108 &mut self,
2109 results: &mut [(usize, SeatState, AgentOutcome)],
2110 sent: &mut [SeatJob],
2111 prompts: &Prompts,
2112 run_id: &str,
2113 ) {
2114 let instruction = seeded_instruction(&self.state);
2115 let language = self.state.config.graph.language.clone();
2116 let brief = self
2117 .state
2118 .advice
2119 .as_ref()
2120 .and_then(|a| a.synthesis.as_deref())
2121 .map(str::to_owned);
2122 let attachments = self.state.attachments.clone();
2123 for (wi, seat, out) in results.iter_mut() {
2124 let others: BTreeSet<String> = sent
2127 .iter()
2128 .enumerate()
2129 .filter(|(j, _)| j != wi)
2130 .map(|(_, j)| j.spec.id.clone())
2131 .collect();
2132 let Some(job) = sent.get_mut(*wi) else {
2133 continue;
2134 };
2135 let start = self
2140 .roles
2141 .implementer_roster
2142 .iter()
2143 .position(|s| s.id == job.spec.id)
2144 .unwrap_or(0);
2145 let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2146 let mut fallback_attempt = 0usize;
2147 let mut prev: Option<FailClass> = None;
2148 while let Some(cur) = FailClass::of(&*out) {
2149 if !should_hand_over(prev.as_ref(), &cur) {
2150 break;
2151 }
2152 let Some(next) =
2153 pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2154 .cloned()
2155 else {
2156 break;
2157 };
2158 tried.insert(next.id.clone());
2159 fallback_attempt += 1;
2160
2161 if let Ok(r) = git::rescue_commit(
2162 &job.cwd,
2163 &format!(
2164 "magi: candidate {} (uncommitted work before {} fallback)",
2165 seat.key,
2166 if cur == FailClass::Quota {
2167 "quota"
2168 } else {
2169 "handover"
2170 }
2171 ),
2172 )
2173 .await
2174 {
2175 self.state.note_withheld("implement", &r.withheld);
2176 }
2177
2178 record_handover(
2179 &mut self.state,
2180 "implement",
2181 &seat.key,
2182 &seat.agent,
2183 &next.id,
2184 &cur,
2185 &fail_reason(&*out),
2186 );
2187 prev = Some(cur.clone());
2188
2189 let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2190 self.state.seats.insert(seat.key.clone(), new_seat.clone());
2191 job.spec = next.clone();
2199 let mut retry = job.clone();
2200 retry.seat = new_seat;
2201 retry.prompt = prompt::implement(
2202 &instruction,
2203 &job.cwd.to_string_lossy(),
2204 &language,
2205 brief.as_deref(),
2206 &attachments,
2207 );
2208 retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2209 let cache = self.state.config.cache_dir();
2210 let ctx = WaveCtx {
2211 carry_seats: false,
2212 run: run_id,
2213 node: "implement",
2214 prompts,
2215 cache: cache.as_deref(),
2216 round: None,
2217 };
2218 let (fallback_seat, fallback_out) = run_one(
2219 retry,
2220 Arc::clone(&self.sem),
2221 &ctx,
2222 &mut self.state,
2223 fallback_attempt,
2224 )
2225 .await;
2226 *seat = fallback_seat;
2227 *out = fallback_out;
2228 }
2229 }
2230 }
2231
2232 async fn resume_unconfirmed_commands(
2256 &mut self,
2257 results: &mut [(usize, SeatState, AgentOutcome)],
2258 sent: &[SeatJob],
2259 prompts: &Prompts,
2260 run_id: &str,
2261 ) {
2262 for (wi, seat, out) in results.iter_mut() {
2263 let AgentOutcome::Ok(o) = &*out else {
2264 continue;
2265 };
2266 if !has_unconfirmed_command(&o.commands) {
2267 continue;
2268 }
2269 let Some(job) = sent.get(*wi) else { continue };
2270 if !has_context(&job.spec, seat, job.sessions) {
2271 self.state.event(
2272 "implement",
2273 format!(
2274 "{}: the reply named a command whose own CLI never confirmed the exit \
2275 status of, but there is no session left to resume",
2276 seat.key
2277 ),
2278 );
2279 continue;
2280 }
2281 self.state.event(
2282 "implement",
2283 format!(
2284 "{}: the reply named a command whose own CLI never confirmed the exit \
2285 status of; resuming the conversation",
2286 seat.key
2287 ),
2288 );
2289 let mut retry = job.clone();
2290 retry.seat = seat.clone();
2291 retry.prompt = prompt::resume_incomplete(
2292 "a command in your last reply had no confirmed exit status",
2293 );
2294 retry.timeout = retry_budget(job.timeout, true);
2295 retry.stem = format!("{}-confirm", job.stem);
2296 let cache = self.state.config.cache_dir();
2297 let ctx = WaveCtx {
2298 carry_seats: false,
2299 run: run_id,
2300 node: "implement",
2301 prompts,
2302 cache: cache.as_deref(),
2303 round: None,
2304 };
2305 let (resumed_seat, resumed) =
2306 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2307 *seat = resumed_seat;
2308 *out = resumed;
2309 }
2310 }
2311
2312 async fn continue_fix_report(
2333 &mut self,
2334 mut seat: SeatState,
2335 parse_err: String,
2336 job: &SeatJob,
2337 prompts: &Prompts,
2338 run_id: &str,
2339 round: usize,
2340 ) -> (
2341 SeatState,
2342 Option<FixReport>,
2343 Option<String>,
2344 ContinuationRecord,
2345 ) {
2346 let mut last_err = parse_err;
2347 let mut cumulative_wait_ms = 0u64;
2348 let mut attempts = 0usize;
2349 loop {
2350 if !has_context(&job.spec, &seat, job.sessions) {
2351 self.state.event(
2352 "fix",
2353 format!(
2354 "round {round}: fixer's reply had no adoption report ({last_err}); no \
2355 session left to resume into"
2356 ),
2357 );
2358 let outcome = if attempts == 0 {
2359 ContinuationOutcome::NoSession
2360 } else {
2361 ContinuationOutcome::Exhausted
2362 };
2363 return (
2364 seat,
2365 None,
2366 Some(format!("unparsable fix report: {last_err}")),
2367 ContinuationRecord {
2368 attempts,
2369 cumulative_wait_ms,
2370 outcome,
2371 },
2372 );
2373 }
2374 if attempts >= MAX_FIX_CONTINUATIONS {
2375 self.state.event(
2376 "fix",
2377 format!(
2378 "round {round}: fixer's reply still had no adoption report after \
2379 {attempts} continuation(s) ({last_err}); giving up"
2380 ),
2381 );
2382 return (
2383 seat,
2384 None,
2385 Some(format!(
2386 "unparsable fix report after {attempts} continuation(s): {last_err}"
2387 )),
2388 ContinuationRecord {
2389 attempts,
2390 cumulative_wait_ms,
2391 outcome: ContinuationOutcome::Exhausted,
2392 },
2393 );
2394 }
2395 attempts += 1;
2396 self.state.event(
2397 "fix",
2398 format!(
2399 "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2400 the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2401 ),
2402 );
2403 let mut retry = job.clone();
2404 retry.seat = seat.clone();
2405 retry.prompt = prompt::resume_incomplete(&last_err);
2406 retry.timeout = retry_budget(job.timeout, true);
2407 retry.stem = format!("{}-continue{attempts}", job.stem);
2408 let cache = self.state.config.cache_dir();
2409 let ctx = WaveCtx {
2410 carry_seats: false,
2411 run: run_id,
2412 node: "fix",
2413 prompts,
2414 cache: cache.as_deref(),
2415 round: Some(round),
2416 };
2417 let (resumed_seat, resumed_out) = run_one(
2418 retry,
2419 Arc::clone(&self.sem),
2420 &ctx,
2421 &mut self.state,
2422 attempts,
2423 )
2424 .await;
2425 seat = resumed_seat;
2426 match resumed_out {
2427 AgentOutcome::Ok(o) => {
2428 cumulative_wait_ms += o.duration_ms;
2429 match verdict::extract_json::<FixReport>(&o.text) {
2430 Ok(report) if !has_unconfirmed_command(&o.commands) => {
2431 self.state.event(
2432 "fix",
2433 format!(
2434 "round {round}: fixer's adoption report recovered after \
2435 {attempts} continuation(s)"
2436 ),
2437 );
2438 return (
2439 seat,
2440 Some(report),
2441 None,
2442 ContinuationRecord {
2443 attempts,
2444 cumulative_wait_ms,
2445 outcome: ContinuationOutcome::Resumed,
2446 },
2447 );
2448 }
2449 Ok(_) => {
2457 last_err = "the reply parsed, but it reported a command whose own CLI \
2458 never confirmed an exit status"
2459 .to_owned();
2460 }
2461 Err(e) => last_err = e.to_string(),
2462 }
2463 }
2464 AgentOutcome::Quota(o) => {
2465 cumulative_wait_ms += o.duration_ms;
2466 self.state.quota.push(QuotaLoss {
2467 seat: seat.key.clone(),
2468 node: "fix".to_owned(),
2469 at: Timestamp::now(),
2470 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2471 });
2472 self.state.event(
2473 "fix",
2474 format!(
2475 "round {round}: continuation rate limited (quota); not retrying now"
2476 ),
2477 );
2478 return (
2479 seat,
2480 None,
2481 Some("rate limited (quota) while recovering the fix report".to_owned()),
2482 ContinuationRecord {
2483 attempts,
2484 cumulative_wait_ms,
2485 outcome: ContinuationOutcome::QuotaLost,
2486 },
2487 );
2488 }
2489 AgentOutcome::Dropped(o) => {
2490 cumulative_wait_ms += o.duration_ms;
2491 let why = o
2492 .dropped
2493 .as_ref()
2494 .map(|d| d.why.as_str())
2495 .unwrap_or("the CLI ended the stream without delivering its answer");
2496 last_err = format!("the CLI dropped the stream ({why})");
2497 }
2498 AgentOutcome::Failed(e) => last_err = e,
2499 }
2500 }
2501 }
2502
2503 fn after_implement(&mut self) -> Result<()> {
2504 if self.state.leaks.is_empty() {
2506 let cfg = self.state.config.blind.clone();
2507 let mut leaks = Vec::new();
2508 for c in &self.state.candidates {
2509 let Some(patch) =
2510 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2511 else {
2512 continue;
2513 };
2514 leaks.extend(blind::scan(
2515 &format!("candidate {} patch", c.label),
2516 &patch,
2517 &cfg.vendor_tokens,
2518 ));
2519 }
2520 if !leaks.is_empty() {
2521 let summary = leaks
2522 .iter()
2523 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2524 .collect::<Vec<_>>()
2525 .join(", ");
2526 match cfg.on_leak {
2527 LeakPolicy::Fail => {
2528 self.state.status = RunStatus::Failed;
2529 self.state
2530 .event("blind", format!("vendor text in a patch: {summary}"));
2531 self.state.leaks = leaks;
2532 self.state.save()?;
2533 self.settle_questions();
2534 bail!(
2535 "blind.on_leak = \"fail\" and vendor text reached a \
2536 judged patch: {summary}"
2537 );
2538 }
2539 LeakPolicy::Redact => self.state.event(
2540 "blind",
2541 format!("redacting vendor text for judging: {summary}"),
2542 ),
2543 LeakPolicy::Warn => self.state.event(
2544 "blind",
2545 format!("vendor text present in a judged patch (shown as-is): {summary}"),
2546 ),
2547 }
2548 self.state.leaks = leaks;
2549 }
2550 }
2551
2552 if self.state.viable().is_empty() {
2553 if self.state.all_candidates_verified_noop() {
2554 self.state.status = RunStatus::VerifiedNoop;
2565 self.state.save()?;
2566 self.settle_questions();
2567 return Ok(());
2568 }
2569 self.state.status = RunStatus::Failed;
2570 self.state.save()?;
2571 self.settle_questions();
2572 bail!("no candidate produced a change; nothing to judge");
2573 }
2574 self.state.status = RunStatus::Judging;
2575 self.state.save()?;
2576 Ok(())
2577 }
2578
2579 async fn judge(&mut self) -> Result<()> {
2582 let run_id = self.state.id.clone();
2587 let prompts = self.state.config.prompts.clone();
2588 if !self.state.judgements.is_empty() || self.state.judge_skipped {
2589 return Ok(());
2590 }
2591 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2592 if viable.len() == 1 {
2593 self.state.judge_skipped = true;
2600 self.state.event(
2601 "judge",
2602 format!(
2603 "only candidate {} produced a change; judging skipped",
2604 viable[0].label
2605 ),
2606 );
2607 self.state.save()?;
2608 return Ok(());
2609 }
2610 self.state.status = RunStatus::Judging;
2611
2612 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2613 let language = self.state.config.graph.language.clone();
2614 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2615 let sessions = self.state.config.graph.sessions;
2616 let artifacts = agent::artifacts_dir(&self.state.dir());
2617 let root = self.state.worktree_root();
2618 let base_short = short(&self.state.base_commit);
2619
2620 let mut jobs = Vec::new();
2621 let mut orders = Vec::new();
2622 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2623 let order = blind::presentation_order(viable.len(), j, self.state.seed);
2624 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2625 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2626 let seat_key = format!("judge-{}", j + 1);
2627 let seat = self.seat(&seat_key, &spec.id);
2628 jobs.push(SeatJob {
2629 prompt: prompt::judge(
2630 &self.state.instruction,
2631 &views,
2632 self.roles.judges.len(),
2633 &base_short,
2634 &language,
2635 ),
2636 spec,
2637 seat,
2638 cwd: root.join(format!("judge-{}", j + 1)),
2639 timeout,
2640 allow_write: false,
2641 sessions,
2642 artifacts: artifacts.clone(),
2643 stem: format!("judge-{}", j + 1),
2644 handover: None,
2645 });
2646 }
2647
2648 self.state.event(
2649 "judge",
2650 format!(
2651 "{} judges ranking {} candidates blind",
2652 jobs.len(),
2653 viable.len()
2654 ),
2655 );
2656 let labels_for_check = labels.clone();
2657 let mut quota_losses = Vec::new();
2658 let cache = self.state.config.cache_dir();
2659 let ctx = WaveCtx {
2660 carry_seats: false,
2661 run: &run_id,
2662 node: "judge",
2663 prompts: &prompts,
2664 cache: cache.as_deref(),
2665 round: None,
2666 };
2667 let results = ask_json_wave::<Ranking>(
2668 jobs,
2669 Arc::clone(&self.sem),
2670 self.state.config.graph.retries,
2671 &self.roles.judge_roster,
2672 &ctx,
2673 &mut quota_losses,
2674 &mut self.state,
2675 &move |r: &Ranking| r.validate(&labels_for_check),
2676 )
2677 .await;
2678 self.state.quota.extend(quota_losses);
2679
2680 for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2681 let agent_id = seat.agent.clone();
2682 self.state.seats.insert(seat.key.clone(), seat);
2683 let mut record = Judgement {
2684 judge: j + 1,
2685 seat: format!("judge-{}", j + 1),
2686 agent: agent_id,
2687 ranking: Vec::new(),
2688 reasons: BTreeMap::new(),
2689 confidence: None,
2690 order: orders[j].clone(),
2691 failed: None,
2692 duration_ms: 0,
2693 };
2694 match res {
2695 Ok((ranking, out)) => {
2696 record.ranking = ranking.normalized();
2697 record.reasons = ranking.reasons;
2698 record.confidence = ranking.confidence;
2699 record.duration_ms = out.duration_ms;
2700 self.state.event(
2701 "judge",
2702 format!(
2703 "judge {} ranked {}",
2704 j + 1,
2705 record.ranking.iter().collect::<String>()
2706 ),
2707 );
2708 }
2709 Err(e) => {
2710 record.failed = Some(e.to_string());
2711 self.state
2712 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2713 }
2714 }
2715 self.state.judgements.push(record);
2716 self.state.save()?;
2717 }
2718 Ok(())
2719 }
2720
2721 async fn deliberate(&mut self) -> Result<()> {
2724 let run_id = self.state.id.clone();
2729 let prompts = self.state.config.prompts.clone();
2730 if !self.state.deliberation.is_empty() {
2731 return Ok(());
2732 }
2733 let tops: Vec<char> = self
2734 .state
2735 .judgements
2736 .iter()
2737 .filter_map(|j| j.ranking.first().copied())
2738 .collect();
2739 let rounds = self.state.config.graph.deliberate_rounds;
2740 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2741 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2742 self.state.event(
2743 "deliberate",
2744 format!("judges agreed on {} outright; no deliberation", tops[0]),
2745 );
2746 }
2747 self.state.status = RunStatus::Voting;
2748 self.state.save()?;
2749 return Ok(());
2750 }
2751
2752 self.state.status = RunStatus::Deliberating;
2753 self.state.event(
2754 "deliberate",
2755 format!(
2756 "split: first choices were {} — opening {rounds} round(s)",
2757 tops.iter().collect::<String>()
2758 ),
2759 );
2760
2761 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2762 let language = self.state.config.graph.language.clone();
2763 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2764 let sessions = self.state.config.graph.sessions;
2765 let artifacts = agent::artifacts_dir(&self.state.dir());
2766 let root = self.state.worktree_root();
2767 let base_short = short(&self.state.base_commit);
2768
2769 for round in 1..=rounds {
2773 let mut turns: Vec<DeliberationTurn> = Vec::new();
2774 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2775 if self.state.judgements[j].failed.is_some() {
2776 continue;
2777 }
2778 let seat_key = format!("judge-{}", j + 1);
2779 let spec = self.occupant(&seat_key, spec);
2780 let mut seat = self.seat(&seat_key, &spec.id);
2781 let transcript = self.transcript(&turns, j);
2782 let build = |context: Option<&str>| {
2783 prompt::deliberate(
2784 &self.state.instruction,
2785 context,
2786 &transcript,
2787 round,
2788 rounds,
2789 &language,
2790 )
2791 };
2792 let block = self.candidate_block(&viable, &base_short);
2793 let full = build(Some(&block));
2794 let text = if has_context(&spec, &seat, sessions) {
2795 build(None)
2796 } else {
2797 full.clone()
2798 };
2799 let job = SeatJob {
2800 spec,
2801 seat: seat.clone(),
2802 prompt: text,
2803 cwd: root.join(format!("judge-{}", j + 1)),
2804 timeout,
2805 allow_write: false,
2806 sessions,
2807 artifacts: artifacts.clone(),
2808 stem: format!("delib-{round}-judge-{}", j + 1),
2809 handover: Some(full),
2810 };
2811 let cache = self.state.config.cache_dir();
2812 let ctx = WaveCtx {
2813 carry_seats: false,
2814 run: &run_id,
2815 node: "deliberate",
2816 prompts: &prompts,
2817 cache: cache.as_deref(),
2818 round: None,
2819 };
2820 let mut losses = Vec::new();
2824 let mut results = ask_wave_with::<String>(
2825 vec![job],
2826 Arc::clone(&self.sem),
2827 0,
2828 &self.roles.judge_roster,
2829 &ctx,
2830 &mut losses,
2831 &mut self.state,
2832 &|text: &str| {
2833 Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2834 },
2835 )
2836 .await;
2837 self.state.quota.extend(losses);
2838 let (updated, res, _) = results.pop().expect("one job in, one result out");
2839 seat = updated;
2840 let agent_id = seat.agent.clone();
2841 self.state.seats.insert(seat.key.clone(), seat);
2842 let body = match res {
2843 Ok((body, _)) => body,
2844 Err(e) => {
2847 self.state
2848 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2849 continue;
2850 }
2851 };
2852 let tentative = verdict::extract_json::<Position>(&body)
2853 .ok()
2854 .and_then(|p| p.tentative)
2855 .and_then(|s| s.trim().chars().next())
2856 .map(|c| c.to_ascii_uppercase());
2857 self.state.event(
2858 "deliberate",
2859 format!(
2860 "round {round}: judge {} now favours {}",
2861 j + 1,
2862 tentative.map_or("—".to_owned(), |c| c.to_string())
2863 ),
2864 );
2865 turns.push(DeliberationTurn {
2866 judge: j + 1,
2867 agent: agent_id,
2868 body: blind::sanitize_prose(&body, &self.state.config.blind),
2869 tentative,
2870 });
2871 }
2872 self.state
2873 .deliberation
2874 .push(DeliberationRound { round, turns });
2875 self.state.save()?;
2876 }
2877
2878 self.state.status = RunStatus::Voting;
2879 self.state.save()?;
2880 Ok(())
2881 }
2882
2883 async fn vote(&mut self) -> Result<()> {
2886 let run_id = self.state.id.clone();
2891 let prompts = self.state.config.prompts.clone();
2892 if !self.state.votes.is_empty() {
2893 return Ok(());
2894 }
2895 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2896 if viable.len() == 1 {
2897 return Ok(());
2898 }
2899 self.state.status = RunStatus::Voting;
2900
2901 let language = self.state.config.graph.language.clone();
2902 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2903 let sessions = self.state.config.graph.sessions;
2904 let artifacts = agent::artifacts_dir(&self.state.dir());
2905 let root = self.state.worktree_root();
2906 let base_short = short(&self.state.base_commit);
2907 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2908
2909 let mut jobs = Vec::new();
2910 let mut seats_at = Vec::new();
2911 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2912 if self
2913 .state
2914 .judgements
2915 .get(j)
2916 .is_some_and(|r| r.failed.is_some())
2917 {
2918 continue;
2919 }
2920 let seat_key = format!("judge-{}", j + 1);
2921 let spec = self.occupant(&seat_key, spec);
2922 let seat = self.seat(&seat_key, &spec.id);
2923 let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2924 let text = if has_context(&spec, &seat, sessions) {
2925 prompt::final_vote(&viable, &language)
2926 } else {
2927 full.clone()
2928 };
2929 jobs.push(SeatJob {
2930 spec,
2931 seat,
2932 prompt: text,
2933 cwd: root.join(format!("judge-{}", j + 1)),
2934 timeout,
2935 allow_write: false,
2936 sessions,
2937 artifacts: artifacts.clone(),
2938 stem: format!("vote-judge-{}", j + 1),
2939 handover: Some(full),
2940 });
2941 seats_at.push(j);
2942 }
2943
2944 self.state.event(
2945 "vote",
2946 format!(
2947 "collecting {} final votes one by one, privately",
2948 jobs.len()
2949 ),
2950 );
2951 let allowed = viable.clone();
2952 let mut quota_losses = Vec::new();
2953 let cache = self.state.config.cache_dir();
2954 let ctx = WaveCtx {
2955 carry_seats: false,
2956 run: &run_id,
2957 node: "vote",
2958 prompts: &prompts,
2959 cache: cache.as_deref(),
2960 round: None,
2961 };
2962 let results = ask_json_wave::<FinalVote>(
2963 jobs,
2964 Arc::clone(&self.sem),
2965 self.state.config.graph.retries,
2966 &self.roles.judge_roster,
2967 &ctx,
2968 &mut quota_losses,
2969 &mut self.state,
2970 &move |v: &FinalVote| match v.label() {
2971 Some(c) if allowed.contains(&c) => Ok(()),
2972 other => bail!("vote {other:?} is not one of {allowed:?}"),
2973 },
2974 )
2975 .await;
2976 self.state.quota.extend(quota_losses);
2977
2978 for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2979 let agent_id = seat.agent.clone();
2980 self.state.seats.insert(seat.key.clone(), seat);
2981 let initial = self
2982 .state
2983 .judgements
2984 .get(j)
2985 .and_then(|r| r.ranking.first().copied());
2986 let mut record = VoteRecord {
2987 judge: j + 1,
2988 agent: agent_id,
2989 vote: None,
2990 reason: String::new(),
2991 changed: false,
2992 };
2993 match res {
2994 Ok((v, _)) => {
2995 record.vote = v.label();
2996 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2997 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2998 self.state.event(
2999 "vote",
3000 format!(
3001 "judge {} voted {}{}",
3002 j + 1,
3003 record.vote.unwrap_or('?'),
3004 if record.changed { " (changed)" } else { "" }
3005 ),
3006 );
3007 }
3008 Err(e) => {
3009 self.state
3010 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3011 }
3012 }
3013 self.state.votes.push(record);
3014 self.state.save()?;
3015 }
3016 Ok(())
3017 }
3018
3019 fn tally(&mut self) -> Result<()> {
3022 if self.state.tally.is_some() {
3023 return Ok(());
3024 }
3025 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3026 let tops: Vec<char> = self
3027 .state
3028 .judgements
3029 .iter()
3030 .filter_map(|j| j.ranking.first().copied())
3031 .collect();
3032 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3033
3034 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3037 let mut cast: Vec<char> = Vec::new();
3038 for (i, j) in self.state.judgements.iter().enumerate() {
3039 let vote = self
3040 .state
3041 .votes
3042 .iter()
3043 .find(|v| v.judge == i + 1)
3044 .and_then(|v| v.vote)
3045 .or_else(|| j.ranking.first().copied());
3046 if let Some(v) = vote {
3047 *first_choice.entry(v).or_insert(0) += 1;
3048 cast.push(v);
3049 }
3050 }
3051
3052 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3053 for j in &self.state.judgements {
3054 let n = j.ranking.len();
3055 for (pos, label) in j.ranking.iter().enumerate() {
3056 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3057 }
3058 }
3059
3060 let best = first_choice.values().copied().max().unwrap_or(0);
3061 let mut leaders: Vec<char> = first_choice
3062 .iter()
3063 .filter(|(_, v)| **v == best)
3064 .map(|(k, _)| *k)
3065 .collect();
3066 let mut tie_break = None;
3067 if leaders.len() > 1 {
3068 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3069 let borda_leaders: Vec<char> = leaders
3070 .iter()
3071 .copied()
3072 .filter(|l| borda[l] == top_borda)
3073 .collect();
3074 tie_break = Some(if borda_leaders.len() == 1 {
3075 format!(
3076 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3077 leaders.len()
3078 )
3079 } else {
3080 format!(
3081 "{} way tie on both first-choice votes and Borda points, broken by label order",
3082 leaders.len()
3083 )
3084 });
3085 leaders = borda_leaders;
3086 leaders.sort_unstable();
3087 }
3088 let winner = *leaders
3089 .first()
3090 .or(viable.first())
3091 .context("no candidate to declare a winner from")?;
3092
3093 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3094 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3095 let deliberated = !self.state.deliberation.is_empty();
3096
3097 let quota_seats: std::collections::BTreeSet<&str> =
3101 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3102 let mut present = 0usize;
3103 for (i, j) in self.state.judgements.iter().enumerate() {
3104 if quota_seats.contains(j.seat.as_str()) {
3105 continue;
3106 }
3107 let ranked = !j.ranking.is_empty() && j.failed.is_none();
3108 let voted = self
3109 .state
3110 .votes
3111 .iter()
3112 .any(|v| v.judge == i + 1 && v.vote.is_some());
3113 if ranked || voted {
3114 present += 1;
3115 }
3116 }
3117 let needs_quorum = viable.len() > 1;
3123 let judges_total = if needs_quorum {
3124 self.roles.judges.len()
3125 } else {
3126 0
3127 };
3128 let quorum = if needs_quorum {
3129 judges_total / 2 + 1
3130 } else {
3131 0
3132 };
3133 let met_quorum = !needs_quorum || present >= quorum;
3134 let uncontested = (!needs_quorum).then(|| {
3135 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3136 });
3137
3138 self.state.event(
3139 "tally",
3140 match &uncontested {
3141 Some(reason) => format!("winner {winner} — {reason}"),
3142 None => format!(
3143 "winner {winner} — votes {} | initial {} | {} changed | \
3144 {present}/{judges_total} judges{}",
3145 first_choice
3146 .iter()
3147 .map(|(k, v)| format!("{k}:{v}"))
3148 .collect::<Vec<_>>()
3149 .join(" "),
3150 if unanimous_initial {
3151 "unanimous"
3152 } else {
3153 "split"
3154 },
3155 changed_votes,
3156 if met_quorum {
3157 String::new()
3158 } else {
3159 format!(" — below quorum ({quorum} required)")
3160 },
3161 ),
3162 },
3163 );
3164 if !met_quorum {
3165 self.state.event(
3166 "stall",
3167 format!(
3168 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3169 the run stops here, resumable"
3170 ),
3171 );
3172 }
3173 self.state.tally = Some(Tally {
3174 first_choice,
3175 borda,
3176 winner,
3177 rankings: tops.len(),
3178 unanimous_initial,
3179 deliberated,
3180 changed_votes,
3181 unanimous_final,
3182 tie_break,
3183 judges: judges_total,
3184 present,
3185 quorum,
3186 met_quorum,
3187 uncontested,
3188 });
3189 self.state.status = if met_quorum {
3190 RunStatus::Reviewing
3191 } else {
3192 RunStatus::Stalled
3193 };
3194 self.state.save()?;
3195 Ok(())
3196 }
3197
3198 #[allow(clippy::too_many_lines)]
3219 async fn recover_stall(&mut self) -> Result<bool> {
3220 let run_id = self.state.id.clone();
3225 let prompts = self.state.config.prompts.clone();
3226 let quota_seats: BTreeSet<&str> =
3231 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3232 let absent: Vec<String> = self
3233 .state
3234 .judgements
3235 .iter()
3236 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3237 .map(|j| j.seat.clone())
3238 .collect();
3239 if absent.is_empty() {
3240 return Ok(false);
3241 }
3242 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3243 if viable.len() <= 1 {
3244 return Ok(false);
3245 }
3246 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3247 let language = self.state.config.graph.language.clone();
3248 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3249 let sessions = self.state.config.graph.sessions;
3250 let artifacts = agent::artifacts_dir(&self.state.dir());
3251 let root = self.state.worktree_root();
3252 let base_short = short(&self.state.base_commit);
3253 let candidates: Vec<Candidate> = viable.clone();
3254
3255 let mut positions: Vec<usize> = absent
3257 .iter()
3258 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3259 .collect();
3260 if positions.is_empty() {
3261 return Ok(false);
3262 }
3263 positions.sort_unstable();
3264 positions.dedup();
3265
3266 let mut judge_jobs = Vec::new();
3268 for &j in &positions {
3269 let order = blind::presentation_order(viable.len(), j, self.state.seed);
3270 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3271 let seat_key = format!("judge-{}", j + 1);
3272 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3273 let seat = self.seat(&seat_key, &spec.id);
3274 judge_jobs.push(SeatJob {
3275 spec,
3276 seat,
3277 prompt: prompt::judge(
3278 &self.state.instruction,
3279 &views,
3280 self.roles.judges.len(),
3281 &base_short,
3282 &language,
3283 ),
3284 cwd: root.join(seat_key),
3285 timeout,
3286 allow_write: false,
3287 sessions,
3288 artifacts: artifacts.clone(),
3289 stem: format!("judge-{}-recover", j + 1),
3290 handover: None,
3291 });
3292 }
3293
3294 let labels_for_check = labels.clone();
3295 let mut judge_losses = Vec::new();
3296 let retries = self.state.config.graph.retries;
3297 let cache = self.state.config.cache_dir();
3298 let ctx = WaveCtx {
3299 carry_seats: false,
3300 run: &run_id,
3301 node: "judge",
3302 prompts: &prompts,
3303 cache: cache.as_deref(),
3304 round: None,
3305 };
3306 let results = ask_json_wave::<Ranking>(
3307 judge_jobs,
3308 Arc::clone(&self.sem),
3309 retries,
3310 &self.roles.judge_roster,
3311 &ctx,
3312 &mut judge_losses,
3313 &mut self.state,
3314 &move |r: &Ranking| r.validate(&labels_for_check),
3315 )
3316 .await;
3317
3318 let mut recovered: BTreeSet<usize> = BTreeSet::new();
3320 for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3321 let agent_id = seat.agent.clone();
3322 self.state.seats.insert(seat.key.clone(), seat);
3323 let record = &mut self.state.judgements[j];
3324 match res {
3325 Ok((ranking, out)) => {
3326 record.agent = agent_id;
3327 record.ranking = ranking.normalized();
3328 record.reasons = ranking.reasons;
3329 record.confidence = ranking.confidence;
3330 record.failed = None;
3331 record.duration_ms = out.duration_ms;
3332 recovered.insert(j);
3333 self.state.event(
3334 "recover",
3335 format!("judge {} ranked again after the limit", j + 1),
3336 );
3337 }
3338 Err(e) => {
3339 self.state
3340 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3341 }
3342 }
3343 }
3344
3345 let mut vote_jobs = Vec::new();
3347 let mut vote_pos: Vec<usize> = Vec::new();
3348 for &j in &recovered {
3349 let seat_key = format!("judge-{}", j + 1);
3350 let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3351 let seat = self.seat(&seat_key, &spec.id);
3352 let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3353 let text = if has_context(&spec, &seat, sessions) {
3354 prompt::final_vote(&labels, &language)
3355 } else {
3356 full.clone()
3357 };
3358 vote_jobs.push(SeatJob {
3359 spec,
3360 seat,
3361 prompt: text,
3362 cwd: root.join(seat_key),
3363 timeout,
3364 allow_write: false,
3365 sessions,
3366 artifacts: artifacts.clone(),
3367 stem: format!("vote-judge-{}-recover", j + 1),
3368 handover: Some(full),
3369 });
3370 vote_pos.push(j);
3371 }
3372 let allowed = labels.clone();
3373 let mut vote_losses = Vec::new();
3374 let vote_retries = self.state.config.graph.retries;
3375 let vote_cache = self.state.config.cache_dir();
3376 let ctx = WaveCtx {
3377 carry_seats: false,
3378 run: &run_id,
3379 node: "vote",
3380 prompts: &prompts,
3381 cache: vote_cache.as_deref(),
3382 round: None,
3383 };
3384 let votes = ask_json_wave::<FinalVote>(
3385 vote_jobs,
3386 Arc::clone(&self.sem),
3387 vote_retries,
3388 &self.roles.judge_roster,
3389 &ctx,
3390 &mut vote_losses,
3391 &mut self.state,
3392 &move |v: &FinalVote| match v.label() {
3393 Some(c) if allowed.contains(&c) => Ok(()),
3394 other => bail!("vote {other:?} is not one of {allowed:?}"),
3395 },
3396 )
3397 .await;
3398 for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3399 let agent_id = seat.agent.clone();
3400 self.state.seats.insert(seat.key.clone(), seat);
3401 match res {
3402 Ok((v, _)) => {
3403 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3404 rec.vote = v.label();
3405 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3406 } else {
3407 self.state.votes.push(VoteRecord {
3408 judge: j + 1,
3409 agent: agent_id,
3410 vote: v.label(),
3411 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3412 changed: false,
3413 });
3414 }
3415 self.state.event(
3416 "recover",
3417 format!("judge {} voted again after the limit", j + 1),
3418 );
3419 }
3420 Err(e) => {
3421 self.state
3422 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3423 }
3424 }
3425 }
3426
3427 let recovered_keys: BTreeSet<String> = recovered
3431 .iter()
3432 .map(|&j| format!("judge-{}", j + 1))
3433 .collect();
3434 self.state
3435 .quota
3436 .retain(|q| !recovered_keys.contains(&q.seat));
3437 for loss in judge_losses.into_iter().chain(vote_losses) {
3441 if recovered_keys.contains(&loss.seat) {
3442 continue;
3443 }
3444 self.state.quota.retain(|q| q.seat != loss.seat);
3445 self.state.quota.push(loss);
3446 }
3447
3448 self.state.tally = None;
3450 self.tally()?;
3451 Ok(self
3452 .state
3453 .tally
3454 .as_ref()
3455 .map(|t| t.met_quorum)
3456 .unwrap_or(false))
3457 }
3458
3459 async fn fold_losers(&mut self) -> Result<()> {
3462 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3463 return Ok(());
3464 };
3465 let repo = self.state.repo.clone();
3466 let mut folded = Vec::new();
3467 for i in 0..self.state.candidates.len() {
3468 let c = &self.state.candidates[i];
3469 if c.label == winner || c.folded {
3470 continue;
3471 }
3472 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3473 git::worktree_remove(&repo, &wt).await.ok();
3474 git::branch_delete(&repo, &branch).await.ok();
3475 self.state.candidates[i].folded = true;
3476 folded.push(label.to_string());
3477 }
3478 let root = self.state.worktree_root();
3480 for j in 1..=self.roles.judges.len() {
3481 let wt = root.join(format!("judge-{j}"));
3482 if wt.exists() {
3483 git::worktree_remove(&repo, &wt).await.ok();
3484 }
3485 }
3486 if self.state.config.graph.advise {
3489 for k in 1..=self.state.config.graph.advisors {
3490 let wt = root.join(format!("advisor-{k}"));
3491 if wt.exists() {
3492 git::worktree_remove(&repo, &wt).await.ok();
3493 }
3494 }
3495 }
3496 if !folded.is_empty() {
3497 self.state
3498 .event("fold", format!("folded candidates {}", folded.join(", ")));
3499 self.state.save()?;
3500 }
3501 Ok(())
3502 }
3503
3504 async fn sync_to_base(&mut self) -> Result<()> {
3544 if self.state.status == RunStatus::AlreadyInBase {
3545 return Ok(());
3546 }
3547 let conflicted = self
3548 .state
3549 .base_sync
3550 .as_ref()
3551 .is_some_and(|s| s.conflict.is_some());
3552 let Some(winner) = self.state.winner().cloned() else {
3553 return Ok(());
3554 };
3555
3556 let repo = self.state.repo.clone();
3557 let remote = self.state.config.merge.remote.clone();
3558 let base_branch = self.state.base_branch.clone();
3559 let tracking = format!("{remote}/{base_branch}");
3560
3561 git::fetch(&repo, &remote, &base_branch).await.ok();
3562 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3567 return Ok(());
3568 };
3569
3570 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3571 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3572 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3573
3574 if (behind > 0 || conflicted || head == tip)
3583 && self
3584 .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3585 .await?
3586 {
3587 return Ok(());
3588 }
3589 if conflicted {
3590 return Ok(());
3591 }
3592
3593 if behind == 0 {
3594 if let Some(from) = self
3603 .state
3604 .rebase_fixes
3605 .iter()
3606 .rev()
3607 .find_map(|r| r.from.clone())
3608 && from != head
3609 && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3610 .await
3611 .is_ok_and(|o| o.ok())
3612 {
3613 git::sync_to_head(&winner.worktree).await?;
3614 }
3615 let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3621 if let Some(why) = &conflict {
3622 self.state.status = RunStatus::Blocked;
3623 self.state.event("land", why.clone());
3624 }
3625 self.state.base_sync = Some(BaseSync {
3626 tip,
3627 behind: 0,
3628 attempts,
3629 conflict,
3630 already_in: None,
3631 });
3632 self.state.save()?;
3633 return Ok(());
3634 }
3635
3636 if attempts >= BASE_SYNC_ROUNDS {
3637 let why = format!(
3638 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3639 rebase(s); rebasing again would only race it",
3640 winner.branch
3641 );
3642 self.state.status = RunStatus::Blocked;
3643 self.state.base_sync = Some(BaseSync {
3644 tip,
3645 behind,
3646 attempts,
3647 conflict: Some(why.clone()),
3648 already_in: None,
3649 });
3650 self.state.event("land", why);
3651 self.state.save()?;
3652 return Ok(());
3653 }
3654
3655 self.state.event(
3656 "land",
3657 format!(
3658 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3659 winner.branch
3660 ),
3661 );
3662 self.state.save()?;
3663
3664 let branch_tracking = format!("{remote}/{}", winner.branch);
3670 let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3671 let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3672 git::rev_parse(&repo, &branch_tracking).await.ok()
3673 } else {
3674 None
3675 };
3676 if let Some(theirs) = &remote_tip
3680 && !git::is_ancestor(&repo, theirs, &head).await
3681 && !crate::reconcile::origin_missing(&repo, &head, theirs)
3682 .await
3683 .is_ok_and(|missing| missing.is_empty())
3684 {
3685 let why = format!(
3686 "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3687 them",
3688 short(theirs),
3689 winner.branch
3690 );
3691 self.state.status = RunStatus::Blocked;
3692 self.state.base_sync = Some(BaseSync {
3693 tip,
3694 behind,
3695 attempts,
3696 conflict: Some(why.clone()),
3697 already_in: None,
3698 });
3699 self.state.event("land", why);
3700 self.state.save()?;
3701 return Ok(());
3702 }
3703
3704 let scratch = self.state.dir().join("base-sync");
3705 let rebased = match crate::rebase::rebase_with_fixer(
3706 &mut self.state,
3707 &scratch,
3708 &winner.branch,
3709 &tracking,
3710 )
3711 .await
3712 {
3713 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3714 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3715 Err(e) => Err(e),
3716 };
3717 let attempts = attempts + 1;
3718 match rebased {
3719 Ok(None) => {
3720 git::sync_to_head(&winner.worktree).await?;
3724 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3725 let mut conflict = None;
3726 if let Some(pinned) = &remote_tip {
3727 let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3728 match pushed {
3729 Ok(o) if o.ok() => self.state.event(
3730 "land",
3731 format!("pushed rebased {} to {remote}", winner.branch),
3732 ),
3733 Ok(o) => {
3734 conflict = Some(format!(
3735 "rebased {} locally but {remote} refused the push (it moved since {}; someone may have pushed): {}",
3736 winner.branch,
3737 short(pinned),
3738 o.stderr.chars().take(600).collect::<String>()
3739 ));
3740 }
3741 Err(e) => {
3742 conflict = Some(format!(
3743 "rebased {} locally but could not push it: {e:#}",
3744 winner.branch
3745 ));
3746 }
3747 }
3748 }
3749 if let Some(why) = &conflict {
3750 self.state.status = RunStatus::Blocked;
3751 self.state.event("land", why.clone());
3752 }
3753 self.state.base_sync = Some(BaseSync {
3754 tip: tip.clone(),
3755 behind: 0,
3756 attempts,
3757 conflict,
3758 already_in: None,
3759 });
3760 self.state
3761 .event("land", format!("rebased {} onto {tracking}", winner.branch));
3762 }
3763 Ok(Some(conflict)) => {
3764 let why = format!(
3765 "{} conflicts with {tracking} and did not rebase: {}",
3766 winner.branch,
3767 conflict.chars().take(600).collect::<String>()
3768 );
3769 self.state.status = RunStatus::Blocked;
3770 self.state.base_sync = Some(BaseSync {
3771 tip,
3772 behind,
3773 attempts,
3774 conflict: Some(why.clone()),
3775 already_in: None,
3776 });
3777 self.state.event("land", why);
3778 }
3779 Err(e) => {
3780 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3781 self.state.status = RunStatus::Blocked;
3782 self.state.base_sync = Some(BaseSync {
3783 tip,
3784 behind,
3785 attempts,
3786 conflict: Some(why.clone()),
3787 already_in: None,
3788 });
3789 self.state.event("land", why);
3790 }
3791 }
3792 self.state.save()?;
3793 Ok(())
3794 }
3795
3796 async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3802 let repo = self.state.repo.clone();
3803 let remote = self.state.config.merge.remote.clone();
3804 let fetched = git::fetch(&repo, &remote, branch).await;
3805 if !matches!(&fetched, Ok(o) if o.ok()) {
3806 return None;
3807 }
3808 let branch_tracking = format!("{remote}/{branch}");
3809 let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3810 if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3811 return None;
3812 }
3813 if !crate::reconcile::origin_missing(&repo, head, &theirs)
3814 .await
3815 .is_ok_and(|missing| missing.is_empty())
3816 {
3817 return Some(format!(
3818 "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3819 them",
3820 short(&theirs)
3821 ));
3822 }
3823 match git::push_pinned(&repo, &remote, branch, &theirs).await {
3824 Ok(o) if o.ok() => {
3825 self.state
3826 .event("land", format!("pushed rebased {branch} to {remote}"));
3827 None
3828 }
3829 Ok(o) => Some(format!(
3830 "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3831 someone may have pushed): {}",
3832 short(&theirs),
3833 o.stderr.chars().take(600).collect::<String>()
3834 )),
3835 Err(e) => Some(format!(
3836 "{branch} is rebased locally but could not be pushed: {e:#}"
3837 )),
3838 }
3839 }
3840
3841 async fn settle_already_in(
3858 &mut self,
3859 branch: &str,
3860 tip: &str,
3861 head: &str,
3862 attempts: usize,
3863 behind: usize,
3864 ) -> Result<bool> {
3865 let repo = self.state.repo.clone();
3866 let remote = self.state.config.merge.remote.clone();
3867 let start = self.state.base_commit.clone();
3868 let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3869 Ok(Some(e)) => e,
3870 Ok(None) => return Ok(false),
3871 Err(e) => {
3872 tracing::warn!("already-in-base check for {branch}: {e:#}");
3873 return Ok(false);
3874 }
3875 };
3876 let mut verified = vec![head.to_owned()];
3877 let fetched = git::fetch(&repo, &remote, branch).await;
3878 if matches!(&fetched, Ok(o) if o.ok())
3879 && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3880 && theirs != head
3881 {
3882 match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3883 Ok(Some(_)) => verified.push(theirs),
3884 _ => return Ok(false),
3885 }
3886 }
3887 let base_branch = self.state.base_branch.clone();
3888 let message = format!(
3889 "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3890 land",
3891 evidence.names(),
3892 evidence.proof.as_str()
3893 );
3894 let closed =
3895 crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3896 match closed {
3897 Ok(Ok(url)) => self
3898 .state
3899 .event("land", format!("closed {url}: superseded on {base_branch}")),
3900 Ok(Err(why)) => self
3901 .state
3902 .event("land", format!("did not close a pull request: {why}")),
3903 Err(e) => {
3904 let why = format!(
3905 "{branch} is already in {remote}/{base_branch}, but its pull request could \
3906 not be closed ({e:#}); resume to retry"
3907 );
3908 self.state.status = RunStatus::Blocked;
3909 self.state.base_sync = Some(BaseSync {
3910 tip: tip.to_owned(),
3911 behind,
3912 attempts,
3913 conflict: Some(why.clone()),
3914 already_in: None,
3915 });
3916 self.state.event("land", why);
3917 self.state.save()?;
3918 return Ok(true);
3919 }
3920 }
3921 self.state.status = RunStatus::AlreadyInBase;
3922 self.state.base_sync = Some(BaseSync {
3923 tip: tip.to_owned(),
3924 behind,
3925 attempts,
3926 conflict: None,
3927 already_in: Some(evidence),
3928 });
3929 self.state.event("land", message);
3930 self.state.save()?;
3931 self.settle_questions();
3932 Ok(true)
3933 }
3934
3935 fn landing_base(&self) -> String {
3945 self.state
3946 .base_sync
3947 .as_ref()
3948 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3949 }
3950
3951 pub async fn fix_selected(
3984 &mut self,
3985 ids: &[String],
3986 reason: &str,
3987 allow_stale: bool,
3988 ) -> Result<()> {
3989 let reason = reason.trim();
3990 if reason.is_empty() {
3991 bail!("a fix request needs a reason — that is the operator's own record of why");
3992 }
3993 if ids.is_empty() {
3994 bail!("no finding id given");
3995 }
3996 if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3997 bail!(
3998 "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3999 has already concluded — can be given a targeted fix. A run still \
4000 in progress should simply be resumed; a `merged` run's branch has \
4001 already landed, so its answer is a fresh `magi review <branch>`, \
4002 not reopening this run's own record",
4003 self.state.id,
4004 self.state.status.as_str()
4005 );
4006 }
4007 let Some(winner) = self.state.winner().cloned() else {
4008 bail!("run {} has no winning candidate to fix", self.state.id);
4009 };
4010 if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4011 bail!(
4012 "branch `{}` no longer exists; this run cannot be extended",
4013 winner.branch
4014 );
4015 }
4016 let home = crate::run::home();
4017 if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4018 bail!(
4019 "run {} is currently being worked on by another magi process",
4020 self.state.id
4021 );
4022 }
4023 let _claim = FixClaim::acquire(&self.state.dir())?;
4029
4030 let mut seen = BTreeSet::new();
4034 let mut findings = Vec::new();
4035 let mut missing = Vec::new();
4036 for id in ids {
4037 if !seen.insert(id.clone()) {
4038 continue;
4039 }
4040 match self.state.finding(id) {
4041 Some((round, rec, f)) => findings.push(OperatorFixFinding {
4042 id: f.id.clone(),
4043 severity: f.severity,
4044 reviewer_vote: rec.vote,
4045 round: round.round,
4046 round_head: round.head.clone(),
4047 reviewer: rec.reviewer,
4048 agent: rec.agent.clone(),
4049 file: f.file.clone(),
4050 line: f.line,
4051 title: f.title.clone(),
4052 detail: f.detail.clone(),
4053 outcome: OperatorFixOutcome::Pending,
4054 }),
4055 None => missing.push(id.clone()),
4056 }
4057 }
4058 if !missing.is_empty() {
4059 bail!(
4060 "unknown finding id(s): {}; nothing was changed",
4061 missing.join(", ")
4062 );
4063 }
4064
4065 let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4066 let stale_details: Vec<(String, String)> = findings
4067 .iter()
4068 .filter(|f| f.round_head != head_at_request)
4069 .map(|f| (f.id.clone(), f.round_head.clone()))
4070 .collect();
4071 let stale = !stale_details.is_empty();
4072 if stale && !allow_stale {
4073 bail!(
4074 "the branch has moved since some finding(s) were raised — {} — now \
4075 at {}; pass --allow-stale to fix anyway, or re-run review first",
4076 stale_details
4077 .iter()
4078 .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4079 .collect::<Vec<_>>()
4080 .join(", "),
4081 short(&head_at_request)
4082 );
4083 }
4084
4085 let request = OperatorFixRequest {
4086 requested_at: Timestamp::now(),
4087 reason: reason.to_owned(),
4088 findings,
4089 head_at_request: head_at_request.clone(),
4090 allow_stale,
4091 stale,
4092 fix: None,
4093 result_head: None,
4094 follow_up_review_run: None,
4095 };
4096 self.state.event(
4097 "fix",
4098 format!(
4099 "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4100 request.findings.len(),
4101 request
4102 .findings
4103 .iter()
4104 .map(|f| f.id.as_str())
4105 .collect::<Vec<_>>()
4106 .join(", "),
4107 ),
4108 );
4109 self.state.operator_fixes.push(request);
4116 self.state.save()?;
4117 let request_index = self.state.operator_fixes.len() - 1;
4118
4119 if winner.worktree.exists() {
4128 let dirty = git::git(
4131 &winner.worktree,
4132 &["status", "--porcelain", "--untracked-files=all"],
4133 )
4134 .await?;
4135 let only_withheld = dirty.lines().all(|l| {
4136 l.strip_prefix("?? ")
4137 .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4138 });
4139 if !only_withheld {
4140 bail!(
4141 "`{}` has uncommitted changes; refusing to touch it — commit or \
4142 discard them first",
4143 winner.worktree.display()
4144 );
4145 }
4146 git::worktree_remove(&self.state.repo, &winner.worktree)
4147 .await
4148 .ok();
4149 }
4150 let fix_worktree = self.state.worktree_root().join("operator-fix");
4151 let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4152 git::git(
4153 &self.state.repo,
4154 &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4155 )
4156 .await
4157 .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4158 if !git::is_clean(&fix_worktree).await? {
4159 git::worktree_remove(&self.state.repo, &fix_worktree)
4160 .await
4161 .ok();
4162 bail!(
4163 "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4164 winner.branch
4165 );
4166 }
4167 let repo_for_setup = self.state.repo.clone();
4168 let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4169 Ok(()) => {
4170 worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4171 }
4172 Err(e) => Err(e),
4173 };
4174 if let Err(e) = setup {
4175 git::worktree_remove(&self.state.repo, &fix_worktree)
4176 .await
4177 .ok();
4178 return Err(e);
4179 }
4180
4181 let run_id = self.state.id.clone();
4182 let prompts = self.state.config.prompts.clone();
4183 let language = self.state.config.graph.language.clone();
4184 let sessions = self.state.config.graph.sessions;
4185 let artifacts = agent::artifacts_dir(&self.state.dir());
4186 let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4187 .findings
4188 .iter()
4189 .map(|f| Finding {
4190 id: f.id.clone(),
4191 severity: f.severity,
4192 file: f.file.clone(),
4193 line: f.line,
4194 title: f.title.clone(),
4195 detail: f.detail.clone(),
4196 })
4197 .collect();
4198 let fix_prompt = prompt::operator_fix(
4199 &self.state.instruction,
4200 &finding_list,
4201 reason,
4202 &stale_details,
4203 &head_at_request,
4204 &language,
4205 );
4206 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4207 let (job, seat, out) = self
4208 .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4209 prompt: fix_prompt.clone(),
4210 spec,
4211 seat,
4212 cwd: fix_worktree.clone(),
4213 timeout,
4214 allow_write: true,
4215 sessions,
4216 artifacts: artifacts.clone(),
4217 stem: "operator-fix".to_owned(),
4218 handover: None,
4219 })
4220 .await;
4221 let agent_id = seat.agent.clone();
4222
4223 let mut fix = FixRecord {
4224 agent: agent_id,
4225 addressed: Vec::new(),
4226 rejected: Vec::new(),
4227 notes: String::new(),
4228 committed: false,
4229 failed: None,
4230 duration_ms: 0,
4231 continuation: None,
4232 };
4233 let mut final_seat = seat.clone();
4234 match out {
4235 AgentOutcome::Ok(o) => {
4236 fix.duration_ms = o.duration_ms;
4237 let parsed = verdict::extract_json::<FixReport>(&o.text);
4238 let incomplete_reason = match &parsed {
4239 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4240 "the reply parsed, but it reported a command whose own CLI \
4241 never confirmed an exit status"
4242 .to_owned(),
4243 ),
4244 Ok(_) => None,
4245 Err(e) => Some(e.to_string()),
4246 };
4247 match incomplete_reason {
4248 None => {
4249 let report = parsed.expect("checked Ok above");
4250 fix.addressed = report.addressed;
4251 fix.rejected = report.rejected;
4252 fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4253 }
4254 Some(reason) => {
4255 let (resumed_seat, resolved, failure, cont) = self
4256 .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4257 .await;
4258 fix.duration_ms += cont.cumulative_wait_ms;
4259 fix.continuation = Some(cont);
4260 final_seat = resumed_seat;
4261 match resolved {
4262 Some(report) => {
4263 fix.addressed = report.addressed;
4264 fix.rejected = report.rejected;
4265 fix.notes =
4266 blind::sanitize_prose(&report.notes, &self.state.config.blind);
4267 }
4268 None => fix.failed = failure,
4269 }
4270 }
4271 }
4272 }
4273 AgentOutcome::Dropped(o) => {
4274 fix.duration_ms = o.duration_ms;
4275 let why = o
4276 .dropped
4277 .as_ref()
4278 .map(|d| d.why.as_str())
4279 .unwrap_or("the CLI ended the stream without delivering its answer");
4280 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4281 }
4282 AgentOutcome::Quota(o) => {
4283 self.state.quota.push(QuotaLoss {
4284 seat: final_seat.key.clone(),
4285 node: "fix".to_owned(),
4286 at: Timestamp::now(),
4287 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4288 });
4289 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4290 }
4291 AgentOutcome::Failed(e) => fix.failed = Some(e),
4292 }
4293 if fix.continuation.is_none() {
4294 fix.continuation = Some(ContinuationRecord::not_needed());
4295 }
4296 self.state.seats.insert(final_seat.key.clone(), final_seat);
4297
4298 let rescue_message = format!(
4299 "magi: operator-selected fix ({}) (uncommitted work)",
4300 self.state.operator_fixes[request_index]
4301 .findings
4302 .iter()
4303 .map(|f| f.id.as_str())
4304 .collect::<Vec<_>>()
4305 .join(", ")
4306 );
4307 if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4308 self.state.note_withheld("fix", &r.withheld);
4309 }
4310 let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4311 fix.committed = after != head_at_request;
4312 git::worktree_remove(&self.state.repo, &fix_worktree)
4313 .await
4314 .ok();
4315
4316 self.state.event(
4317 "fix",
4318 match &fix.failed {
4319 Some(reason) => format!(
4320 "operator fix: adoption report was lost ({reason}); {}",
4321 if fix.committed {
4322 "committed"
4323 } else {
4324 "NO new commit"
4325 }
4326 ),
4327 None => format!(
4328 "operator fix: {} addressed, {} rejected, {}",
4329 fix.addressed.len(),
4330 fix.rejected.len(),
4331 if fix.committed {
4332 "committed"
4333 } else {
4334 "NO new commit"
4335 }
4336 ),
4337 },
4338 );
4339
4340 for f in &mut self.state.operator_fixes[request_index].findings {
4347 f.outcome = if fix.failed.is_some() {
4348 OperatorFixOutcome::Unreported
4349 } else if fix.addressed.contains(&f.id) {
4350 OperatorFixOutcome::Addressed
4351 } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4352 OperatorFixOutcome::Rejected { why: r.why.clone() }
4353 } else {
4354 OperatorFixOutcome::Unreported
4355 };
4356 }
4357
4358 let committed = fix.committed;
4359 if committed {
4360 self.state.operator_fixes[request_index].result_head = Some(after.clone());
4361 }
4362 self.state.operator_fixes[request_index].fix = Some(fix);
4363 self.state.save()?;
4366
4367 if committed {
4368 self.state.event(
4369 "fix",
4370 format!(
4371 "operator fix committed {}; opening a follow-up review-only run",
4372 short(&after)
4373 ),
4374 );
4375 let origin =
4378 Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4379 match Self::review(
4380 &self.state.repo,
4381 &winner.branch,
4382 self.state.config.clone(),
4383 origin,
4384 )
4385 .await
4386 {
4387 Ok(mut follow_up) => {
4388 follow_up.state.event(
4389 "start",
4390 format!(
4391 "requested by an operator fix on run {} for finding(s) {}",
4392 self.state.id,
4393 self.state.operator_fixes[request_index]
4394 .findings
4395 .iter()
4396 .map(|f| f.id.as_str())
4397 .collect::<Vec<_>>()
4398 .join(", "),
4399 ),
4400 );
4401 follow_up.state.save()?;
4402 let follow_up_id = follow_up.state.id.clone();
4403 let adopted = match crate::direct::adopt(
4406 &follow_up.state,
4407 self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4408 ) {
4409 Ok(a) => a,
4410 Err(e) => {
4411 self.state.event(
4414 "fix",
4415 format!(
4416 "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4417 ),
4418 );
4419 self.state.save()?;
4420 return Ok(());
4421 }
4422 };
4423 let executed = follow_up.execute().await;
4424 let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4425 if let Some(a) = adopted {
4426 a.finish(&follow_up.state, executed);
4427 }
4428 if let Some(e) = failure {
4429 self.state.event(
4430 "fix",
4431 format!(
4432 "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4433 ),
4434 );
4435 }
4436 self.state.operator_fixes[request_index].follow_up_review_run =
4437 Some(follow_up_id);
4438 }
4439 Err(e) => {
4440 self.state.event(
4441 "fix",
4442 format!("committed the fix but could not open a follow-up review: {e:#}"),
4443 );
4444 }
4445 }
4446 self.state.save()?;
4447 }
4448
4449 Ok(())
4450 }
4451
4452 async fn ask_fixer(
4472 &mut self,
4473 winner: &Candidate,
4474 node: &'static str,
4475 round: Option<usize>,
4476 build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4477 ) -> (SeatJob, SeatState, AgentOutcome) {
4478 let run_id = self.state.id.clone();
4479 let prompts = self.state.config.prompts.clone();
4480 let cache = self.state.config.cache_dir();
4481 let attempts = fixer::attempts(&self.state, &self.roles, winner);
4482 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4483 let mut last = None;
4484 for (i, (spec, key)) in attempts.into_iter().enumerate() {
4485 let seat = self.seat(&key, &spec.id);
4486 let mut job = build(spec.clone(), seat);
4487 if i > 0 {
4488 job.stem = format!("{}-{}", job.stem, spec.id);
4489 }
4490 let ctx = WaveCtx {
4491 carry_seats: false,
4492 run: &run_id,
4493 node,
4494 prompts: &prompts,
4495 cache: cache.as_deref(),
4496 round,
4497 };
4498 let (seat, out) =
4499 run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4500 let advances = match &out {
4501 AgentOutcome::Ok(o) => agent::output_advances(o),
4502 _ => true,
4503 };
4504 if let Some(next) = ids.get(i + 1)
4505 && advances
4506 {
4507 self.state.seats.insert(seat.key.clone(), seat.clone());
4508 let class =
4509 FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4510 let reason = match &out {
4511 AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4512 other => fail_reason(other),
4513 };
4514 record_handover(
4515 &mut self.state,
4516 node,
4517 &seat.key,
4518 &spec.id,
4519 next,
4520 &class,
4521 &reason,
4522 );
4523 continue;
4524 }
4525 last = Some((job, seat, out));
4526 break;
4527 }
4528 last.expect("the fixer chain always has an entry")
4529 }
4530
4531 async fn review_loop(&mut self) -> Result<()> {
4532 if self
4537 .state
4538 .base_sync
4539 .as_ref()
4540 .is_some_and(|s| s.conflict.is_some())
4541 {
4542 return Ok(());
4543 }
4544 let run_id = self.state.id.clone();
4549 let prompts = self.state.config.prompts.clone();
4550 let Some(winner) = self.state.winner().cloned() else {
4551 return Ok(());
4552 };
4553 let max_rounds = self.state.config.graph.review_rounds;
4554 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4564 if status == RunStatus::Gating {
4567 self.record_contested_handoff();
4568 }
4569 self.state.status = status;
4570 self.state.save()?;
4571 return Ok(());
4572 }
4573 self.state.status = RunStatus::Reviewing;
4574 if self
4584 .state
4585 .reviews
4586 .last()
4587 .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4588 {
4589 let shell = self.state.config.shell();
4590 return self
4591 .stop_reviewing(
4592 "the last round's own verification never resolved",
4593 &shell,
4594 &winner.worktree,
4595 )
4596 .await;
4597 }
4598
4599 let repo = self.state.repo.clone();
4600 let root = self.state.worktree_root();
4601 let language = self.state.config.graph.language.clone();
4602 let sessions = self.state.config.graph.sessions;
4603 let artifacts = agent::artifacts_dir(&self.state.dir());
4604 let base = self.landing_base();
4605 let base_short = short(&base);
4606 let reviewers = self.roles.reviewers.clone();
4607 let shell = self.state.config.shell();
4608
4609 for round in (self.state.reviews.len() + 1)..=max_rounds {
4610 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4611 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4612 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4613 let prev_verification = self
4622 .state
4623 .reviews
4624 .last()
4625 .and_then(|r| r.verification_summary(&head));
4626
4627 let mut jobs = Vec::new();
4631 for (r, spec) in reviewers.iter().cloned().enumerate() {
4632 let wt = root.join(format!("review-{}", r + 1));
4633 if wt.exists() {
4636 git::reset_detached(&wt, &head).await?;
4637 } else {
4638 git::worktree_add_detached(&repo, &wt, &head).await?;
4639 }
4640 self.setup_seat_worktree(&repo, &wt, false).await?;
4641 let seat_key = format!("review-{}", r + 1);
4642 let spec = pick_start_spec(
4645 &self.roles.reviewer_roster,
4646 spec,
4647 self.state.seat_history.get(&seat_key),
4648 );
4649 let seat = self.seat(&seat_key, &spec.id);
4650 jobs.push(SeatJob {
4651 prompt: prompt::review(&prompt::ReviewCtx {
4652 instruction: &self.state.instruction,
4653 branch: &winner.branch,
4654 base_short: &base_short,
4655 stat: &stat,
4656 patch: &patch,
4657 verification: prev_verification.as_ref(),
4658 reviewers: reviewers.len(),
4659 round,
4660 rounds: max_rounds,
4661 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4664 lens: Lens::for_seat(r),
4665 language: &language,
4666 }),
4667 spec,
4668 seat,
4669 cwd: wt,
4670 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4671 allow_write: false,
4672 sessions,
4673 artifacts: artifacts.clone(),
4674 stem: format!("review-{round}-{}", r + 1),
4675 handover: None,
4676 });
4677 }
4678
4679 self.state.event(
4680 "review",
4681 format!(
4682 "round {round}: {} reviewers on {}",
4683 jobs.len(),
4684 short(&head)
4685 ),
4686 );
4687 let mut quota_losses = Vec::new();
4688 let review_retries = self.state.config.graph.retries;
4689 let review_cache = self.state.config.cache_dir();
4690 let ctx = WaveCtx {
4691 carry_seats: true,
4692 run: &run_id,
4693 node: "review",
4694 prompts: &prompts,
4695 cache: review_cache.as_deref(),
4696 round: Some(round),
4697 };
4698 let results = ask_json_wave::<Review>(
4699 jobs,
4700 Arc::clone(&self.sem),
4701 review_retries,
4702 &self.roles.reviewer_roster,
4703 &ctx,
4704 &mut quota_losses,
4705 &mut self.state,
4706 &|_: &Review| Ok(()),
4707 )
4708 .await;
4709 let round_quota_missing = quota_losses.len();
4713 self.state.quota.extend(quota_losses);
4714
4715 let mut records = Vec::new();
4716 let mut all_findings = Vec::new();
4717 for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4718 let agent_id = seat.agent.clone();
4719 self.state.seats.insert(seat.key.clone(), seat);
4720 let mut record = ReviewRecord {
4721 reviewer: r + 1,
4722 agent: agent_id,
4723 summary: String::new(),
4724 findings: Vec::new(),
4725 vote: None,
4726 failed: None,
4727 duration_ms: 0,
4728 attempts,
4734 };
4735 match res {
4736 Ok((review, out)) => {
4737 record.summary =
4745 blind::sanitize_prose(&review.summary, &self.state.config.blind);
4746 record.vote = Some(review.vote);
4747 record.duration_ms = out.duration_ms;
4748 for (n, mut f) in review.findings.into_iter().enumerate() {
4749 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4752 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4753 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4754 f.file = f
4760 .file
4761 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4762 all_findings.push(f.clone());
4763 record.findings.push(f);
4764 }
4765 self.state.event(
4766 "review",
4767 format!(
4768 "round {round}: reviewer {} voted {} with {} finding(s)",
4769 r + 1,
4770 review.vote.label(),
4771 record.findings.len()
4772 ),
4773 );
4774 }
4775 Err(e) => {
4776 record.failed = Some(e.to_string());
4777 self.state.event(
4778 "review",
4779 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4780 );
4781 }
4782 }
4783 records.push(record);
4784 }
4785
4786 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4793 let vote_split =
4794 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4795 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4796 if vote_split {
4797 self.state.event(
4798 "review",
4799 format!(
4800 "round {round}: votes split ({}) — one round of reconsideration",
4801 initial_votes
4802 .iter()
4803 .map(|v| v.label())
4804 .collect::<Vec<_>>()
4805 .join(", ")
4806 ),
4807 );
4808 let panel: Vec<ReviewSeatReport<'_>> = records
4811 .iter()
4812 .filter_map(|r| {
4813 r.vote.map(|vote| ReviewSeatReport {
4814 reviewer: r.reviewer,
4815 vote,
4816 summary: &r.summary,
4817 findings: &r.findings,
4818 })
4819 })
4820 .collect();
4821
4822 let mut jobs = Vec::new();
4823 let mut seats_at = Vec::new();
4824 for (r, spec) in reviewers.iter().cloned().enumerate() {
4825 if records[r].vote.is_none() {
4829 continue;
4830 }
4831 let wt = root.join(format!("review-{}", r + 1));
4832 let seat_key = format!("review-{}", r + 1);
4833 let spec = self.occupant(&seat_key, spec);
4834 let seat = self.seat(&seat_key, &spec.id);
4835 let build = |with_patch: bool| {
4843 prompt::review_reconsider(&ReviewReconsiderCtx {
4844 instruction: &self.state.instruction,
4845 reviewer: r + 1,
4846 lens: Lens::for_seat(r),
4847 panel: &panel,
4848 patch: with_patch.then_some(ReviewPatch {
4849 branch: &winner.branch,
4850 base_short: &base_short,
4851 stat: &stat,
4852 patch: &patch,
4853 }),
4854 round,
4855 rounds: max_rounds,
4856 language: &language,
4857 })
4858 };
4859 let full = build(true);
4860 let prompt = if has_context(&spec, &seat, sessions) {
4861 build(false)
4862 } else {
4863 full.clone()
4864 };
4865 jobs.push(SeatJob {
4866 prompt,
4867 spec,
4868 seat,
4869 cwd: wt,
4870 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4871 allow_write: false,
4872 sessions,
4873 artifacts: artifacts.clone(),
4874 stem: format!("review-{round}-reconsider-{}", r + 1),
4875 handover: Some(full),
4876 });
4877 seats_at.push(r);
4878 }
4879
4880 let mut recon_quota_losses = Vec::new();
4881 let recon_cache = self.state.config.cache_dir();
4882 let recon_ctx = WaveCtx {
4883 carry_seats: true,
4884 run: &run_id,
4885 node: "review",
4886 prompts: &prompts,
4887 cache: recon_cache.as_deref(),
4888 round: Some(round),
4889 };
4890 let recon_results = ask_json_wave::<ReviewRevote>(
4891 jobs,
4892 Arc::clone(&self.sem),
4893 review_retries,
4894 &self.roles.reviewer_roster,
4895 &recon_ctx,
4896 &mut recon_quota_losses,
4897 &mut self.state,
4898 &|_: &ReviewRevote| Ok(()),
4899 )
4900 .await;
4901 self.state.quota.extend(recon_quota_losses);
4902
4903 for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4904 let agent_id = seat.agent.clone();
4905 self.state.seats.insert(seat.key.clone(), seat);
4906 let mut rec = ReviewRevoteRecord {
4907 reviewer: r + 1,
4908 agent: agent_id,
4909 vote: None,
4910 reason: String::new(),
4911 failed: None,
4912 };
4913 match res {
4914 Ok((rv, _)) => {
4915 rec.vote = Some(rv.vote);
4916 rec.reason =
4917 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4918 self.state.event(
4919 "review",
4920 format!(
4921 "round {round}: reviewer {} revoted {}",
4922 r + 1,
4923 rv.vote.label()
4924 ),
4925 );
4926 }
4927 Err(e) => {
4928 rec.failed = Some(e.to_string());
4929 self.state.event(
4930 "review",
4931 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4932 );
4933 }
4934 }
4935 reconsideration.push(rec);
4936 }
4937 } else if initial_votes.len() > 1 {
4938 self.state.event(
4939 "review",
4940 format!(
4941 "round {round}: votes agreed ({}) — no reconsideration",
4942 initial_votes[0].label()
4943 ),
4944 );
4945 }
4946
4947 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4948 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4949 let defer_e2e =
4960 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4961 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4962 let reason =
4963 format!("{blocking} blocking finding(s) already required a fix this round");
4964 self.state.event(
4965 "verify",
4966 format!(
4967 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4968 {}); it will run once a round has none left",
4969 short(&head)
4970 ),
4971 );
4972 (Vec::new(), false, true, Some(reason))
4973 } else {
4974 let e2e_commands = self.state.config.verify.e2e.clone();
4975 let cache_dir = self.state.config.cache_dir();
4976 let context = format!("round {round}");
4977 let (e2e, verify_retried) = with_cache_lease(
4978 &mut self.state,
4979 cache_dir.as_deref(),
4980 "e2e",
4981 "e2e",
4982 &winner.worktree,
4983 &head,
4984 verify_timeout,
4985 &context,
4986 |state, budget| {
4987 let shell = shell.clone();
4988 let e2e_commands = e2e_commands.clone();
4989 let worktree = winner.worktree.clone();
4990 let context = context.clone();
4991 async move {
4992 run_e2e_with_retry(
4993 state,
4994 &shell,
4995 &e2e_commands,
4996 &worktree,
4997 budget,
4998 &context,
4999 )
5000 .await
5001 }
5002 },
5003 )
5004 .await;
5005 (e2e, verify_retried, false, None)
5006 };
5007
5008 let expected = records.len();
5009 let answered = records.iter().filter(|r| r.failed.is_none()).count();
5010 let incomplete = answered < expected;
5011 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5012 let policy = self.state.config.graph.incomplete_review;
5013 let clean = round_is_clean(
5014 blocking,
5015 e2e_ok,
5016 answered,
5017 expected,
5018 round_quota_missing,
5019 policy,
5020 );
5021
5022 let mut round_record = ReviewRound {
5023 round,
5024 head: head.clone(),
5025 verified_head: None,
5026 verified_at: None,
5027 reviews: records,
5028 e2e,
5029 verify_retried,
5030 e2e_deferred,
5031 e2e_defer_reason,
5032 fix: None,
5033 blocking,
5034 answered,
5035 expected,
5036 clean,
5037 progressed: false,
5038 vote_split,
5039 reconsideration,
5040 verdict: None,
5041 };
5042 round_record.verdict = ReviewVote::worst(
5046 round_record
5047 .final_votes()
5048 .into_iter()
5049 .map(|(_, _, vote)| vote),
5050 );
5051 if !matches!(
5062 round_record.e2e_status(),
5063 E2eStatus::Deferred | E2eStatus::NotConfigured
5064 ) {
5065 round_record.verified_head = Some(head.clone());
5066 round_record.verified_at = Some(Timestamp::now());
5067 }
5068 let this_round_verification = round_record.verification_summary(&head);
5069
5070 if incomplete {
5071 let missing: Vec<String> = round_record
5072 .reviews
5073 .iter()
5074 .filter(|r| r.failed.is_some())
5075 .map(|r| format!("review-{}", r.reviewer))
5076 .collect();
5077 self.state.event(
5078 "review",
5079 format!(
5080 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5081 missing.join(", ")
5082 ),
5083 );
5084 }
5085
5086 if clean {
5087 self.state.event(
5088 "review",
5089 if incomplete && policy == IncompleteReviewPolicy::Warn {
5090 format!(
5091 "round {round}: clean (warn policy, incomplete panel) — no \
5092 blocking findings from the seats that answered, verification green"
5093 )
5094 } else if incomplete {
5095 format!(
5096 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5097 quorum) — no blocking findings from the seats that answered, \
5098 verification green",
5099 expected - answered
5100 )
5101 } else {
5102 format!("round {round}: clean — no blocking findings, verification green")
5103 },
5104 );
5105 self.state.reviews.push(round_record);
5106 self.state.status = RunStatus::Gating;
5107 self.state.save()?;
5108 return Ok(());
5109 }
5110
5111 if incomplete && blocking == 0 && e2e_ok {
5119 self.state.reviews.push(round_record);
5120 self.state.save()?;
5121 if round == max_rounds {
5122 self.state.status = RunStatus::Blocked;
5123 self.state.event(
5124 "review",
5125 format!(
5126 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5127 refusing to call it clean",
5128 expected - answered
5129 ),
5130 );
5131 return Ok(());
5132 }
5133 continue;
5134 }
5135
5136 if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5148 self.state.reviews.push(round_record);
5149 return self
5150 .stop_reviewing(
5151 "the round's own verification could not run",
5152 &shell,
5153 &winner.worktree,
5154 )
5155 .await;
5156 }
5157
5158 if round == max_rounds {
5159 self.state.reviews.push(round_record);
5160 return self
5161 .stop_reviewing(
5162 &format!(
5163 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5164 ),
5165 &shell,
5166 &winner.worktree,
5167 )
5168 .await;
5169 }
5170
5171 let blocking_findings: Vec<_> = all_findings
5174 .iter()
5175 .filter(|f| f.severity.blocks())
5176 .cloned()
5177 .collect();
5178 let fix_prompt = prompt::fix(
5179 &self.state.instruction,
5180 &blocking_findings,
5181 this_round_verification.as_ref(),
5182 round,
5183 max_rounds,
5184 &language,
5185 );
5186 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5187 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5188 let (job, seat, out) = self
5189 .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5190 prompt: fix_prompt.clone(),
5191 spec,
5192 seat,
5193 cwd: winner.worktree.clone(),
5194 timeout,
5195 allow_write: true,
5196 sessions,
5197 artifacts: artifacts.clone(),
5198 stem: format!("fix-{round}"),
5199 handover: None,
5200 })
5201 .await;
5202 let agent_id = seat.agent.clone();
5203
5204 let mut fix = FixRecord {
5205 agent: agent_id,
5206 addressed: Vec::new(),
5207 rejected: Vec::new(),
5208 notes: String::new(),
5209 committed: false,
5210 failed: None,
5211 duration_ms: 0,
5212 continuation: None,
5213 };
5214 let mut continuation = ContinuationRecord::not_needed();
5215 let mut final_seat = seat.clone();
5216 match out {
5217 AgentOutcome::Ok(o) => {
5218 fix.duration_ms = o.duration_ms;
5219 let parsed = verdict::extract_json::<FixReport>(&o.text);
5220 let incomplete_reason = match &parsed {
5227 Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5228 "the reply parsed, but it reported a command whose own CLI never \
5229 confirmed an exit status"
5230 .to_owned(),
5231 ),
5232 Ok(_) => None,
5233 Err(e) => Some(e.to_string()),
5234 };
5235 match incomplete_reason {
5236 None => {
5237 let report = parsed.expect("checked Ok above");
5238 fix.addressed = report.addressed;
5239 fix.rejected = report.rejected;
5240 fix.notes =
5241 blind::sanitize_prose(&report.notes, &self.state.config.blind);
5242 }
5243 Some(reason) => {
5244 let (resumed_seat, resolved, failure, cont) = self
5245 .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5246 .await;
5247 fix.duration_ms += cont.cumulative_wait_ms;
5248 continuation = cont;
5249 final_seat = resumed_seat;
5250 match resolved {
5251 Some(report) => {
5252 fix.addressed = report.addressed;
5253 fix.rejected = report.rejected;
5254 fix.notes = blind::sanitize_prose(
5255 &report.notes,
5256 &self.state.config.blind,
5257 );
5258 }
5259 None => fix.failed = failure,
5260 }
5261 }
5262 }
5263 }
5264 AgentOutcome::Dropped(o) => {
5266 fix.duration_ms = o.duration_ms;
5267 let why = o
5268 .dropped
5269 .as_ref()
5270 .map(|d| d.why.as_str())
5271 .unwrap_or("the CLI ended the stream without delivering its answer");
5272 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5273 }
5274 AgentOutcome::Quota(o) => {
5275 self.state.quota.push(QuotaLoss {
5276 seat: final_seat.key.clone(),
5277 node: "fix".to_owned(),
5278 at: Timestamp::now(),
5279 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5280 });
5281 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5282 }
5283 AgentOutcome::Failed(e) => fix.failed = Some(e),
5284 }
5285 fix.continuation = Some(continuation);
5286 self.state.seats.insert(final_seat.key.clone(), final_seat);
5287 if let Ok(r) = git::rescue_commit(
5288 &winner.worktree,
5289 &format!("magi: review round {round} fixes (uncommitted work)"),
5290 )
5291 .await
5292 {
5293 self.state.note_withheld("fix", &r.withheld);
5294 }
5295 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5296 fix.committed = after != before;
5297 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5305 let progressed = diff_after != patch;
5306 let commit_note = if fix.committed {
5307 "committed"
5308 } else {
5309 "NO new commit"
5310 };
5311 let tree_note = if progressed {
5312 "changed vs base"
5313 } else {
5314 "unchanged vs base"
5315 };
5316 self.state.event(
5317 "fix",
5318 match &fix.failed {
5319 Some(reason) => {
5325 format!(
5326 "round {round}: fixer's adoption report was lost ({reason}); \
5327 {commit_note}, tree {tree_note}"
5328 )
5329 }
5330 None => format!(
5331 "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5332 {tree_note}{}",
5333 fix.addressed.len(),
5334 fix.rejected.len(),
5335 if continuation.outcome == ContinuationOutcome::Resumed {
5336 format!(
5337 " (adoption report recovered after {} continuation(s))",
5338 continuation.attempts
5339 )
5340 } else {
5341 String::new()
5342 },
5343 ),
5344 },
5345 );
5346 round_record.fix = Some(fix);
5347 round_record.progressed = progressed;
5348 self.state.reviews.push(round_record);
5349 self.state.save()?;
5350
5351 if matches!(
5364 continuation.outcome,
5365 ContinuationOutcome::Exhausted
5366 | ContinuationOutcome::QuotaLost
5367 | ContinuationOutcome::NoSession
5368 ) {
5369 return self
5370 .stop_reviewing(
5371 "the fixer's adoption report never came back, even after resuming its \
5372 own seat; refusing to start another round against the same worktree \
5373 while that is unresolved",
5374 &shell,
5375 &winner.worktree,
5376 )
5377 .await;
5378 }
5379
5380 let streak = self
5381 .state
5382 .reviews
5383 .iter()
5384 .rev()
5385 .take_while(|r| !r.progressed)
5386 .count();
5387 if streak >= STAGNANT_LIMIT {
5388 return self
5389 .stop_reviewing(
5390 &format!(
5391 "the tree has not moved against base for {streak} round(s) in a row"
5392 ),
5393 &shell,
5394 &winner.worktree,
5395 )
5396 .await;
5397 }
5398 }
5399 Ok(())
5400 }
5401
5402 fn record_contested_handoff(&mut self) {
5436 if self.state.contested_handoff.is_some() {
5437 return;
5438 }
5439 let Some(contested) = self
5440 .state
5441 .reviews
5442 .last()
5443 .and_then(ReviewRound::contested_handoff)
5444 else {
5445 return;
5446 };
5447 self.state.event(
5448 "review",
5449 format!(
5450 "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5451 wait for the owner's approval",
5452 contested.findings.len(),
5453 contested.rejecters.len()
5454 ),
5455 );
5456 self.state.contested_handoff = Some(contested);
5457 }
5458
5459 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5460 let round_idx = self.state.reviews.len() - 1;
5461 let needs_catchup_run = matches!(
5469 self.state.reviews[round_idx].e2e_status(),
5470 E2eStatus::Deferred | E2eStatus::ResourceBlocked
5471 );
5472 if needs_catchup_run {
5473 let round = self.state.reviews[round_idx].round;
5474 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5475 let commands = self.state.config.verify.e2e.clone();
5476 let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5477 let cache_dir = self.state.config.cache_dir();
5478 let context = format!(
5479 "round {round}: verification unresolved, catching up before the final decision"
5480 );
5481 let (outcomes, verify_retried) = with_cache_lease(
5482 &mut self.state,
5483 cache_dir.as_deref(),
5484 "e2e",
5485 "e2e",
5486 worktree,
5487 &attempted_head,
5488 timeout,
5489 &context,
5490 |state, budget| {
5491 let shell = shell.to_vec();
5492 let commands = commands.clone();
5493 let context = context.clone();
5494 async move {
5495 run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5496 .await
5497 }
5498 },
5499 )
5500 .await;
5501 let last = &mut self.state.reviews[round_idx];
5502 last.e2e = outcomes;
5503 last.verify_retried = verify_retried;
5504 last.verified_head = Some(attempted_head);
5511 last.verified_at = Some(Timestamp::now());
5512 if verify_inconclusive(&last.e2e) {
5513 self.state.save()?;
5520 return Ok(());
5521 }
5522 last.e2e_deferred = false;
5523 }
5524 let last = &self.state.reviews[round_idx];
5525 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5526
5527 match last.e2e_status() {
5528 E2eStatus::Failed => {
5529 let red: Vec<String> = last
5530 .e2e
5531 .iter()
5532 .filter(|o| !o.ok())
5533 .map(|o| {
5534 format!(
5535 "`{}` -> {:?}\n{}",
5536 o.command,
5537 o.code,
5538 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5539 )
5540 })
5541 .collect();
5542 self.state
5543 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5544 self.state.status = RunStatus::Blocked;
5545 }
5546 E2eStatus::ResourceBlocked => {
5551 self.state.event(
5552 "review",
5553 format!(
5554 "{why}; e2e could not run (shared build cache unavailable); not \
5555 deciding yet"
5556 ),
5557 );
5558 }
5559 E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5560 self.state.event(
5561 "review",
5562 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5563 );
5564 self.record_contested_handoff();
5565 self.state.status = RunStatus::Gating;
5566 }
5567 }
5568 self.state.save()?;
5569 Ok(())
5570 }
5571
5572 async fn gate(&mut self) -> Result<()> {
5575 if self.state.status == RunStatus::Failed
5587 || self
5588 .state
5589 .base_sync
5590 .as_ref()
5591 .is_some_and(|s| s.conflict.is_some())
5592 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5593 != Some(RunStatus::Gating)
5594 {
5595 return Ok(());
5596 }
5597 if self.state.gate_ran {
5598 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5609 self.state.status = RunStatus::Blocked;
5610 self.state.save()?;
5611 }
5612 return Ok(());
5613 }
5614 let Some(winner) = self.state.winner().cloned() else {
5615 return Ok(());
5616 };
5617 self.state.status = RunStatus::Gating;
5618 let mut outcomes = self.run_gate(&winner).await?;
5619 loop {
5620 if verify_inconclusive(&outcomes) {
5631 self.state.save()?;
5632 return Ok(());
5633 }
5634 if outcomes.iter().all(CommandOutcome::ok) {
5635 break;
5636 }
5637 match self.gate_fix_round(&winner, &outcomes).await? {
5638 GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5639 GateFix::Stop => break,
5640 GateFix::Defer => {
5641 self.state.save()?;
5642 return Ok(());
5643 }
5644 }
5645 }
5646 let passed = outcomes.iter().all(CommandOutcome::ok);
5647 self.state.gate = outcomes;
5648 self.state.gate_ran = true;
5649 if !passed {
5650 self.state.status = RunStatus::Blocked;
5651 let spent = self.state.gate_fixes.len();
5652 self.state.event(
5653 "gate",
5654 if spent == 0 {
5655 "gate failed; not merging".to_owned()
5656 } else {
5657 format!("gate failed after {spent} gate-fix round(s); not merging")
5658 },
5659 );
5660 }
5661 self.state.save()?;
5662 Ok(())
5663 }
5664
5665 async fn run_pre_gate(&mut self, winner: &Candidate) {
5675 let commands = self.state.config.verify.pre_gate.clone();
5676 if commands.is_empty() {
5677 return;
5678 }
5679 let shell = self.state.config.shell();
5680 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5681 let (outcomes, _) = run_commands(
5682 &mut self.state,
5683 "pre_gate",
5684 "pre_gate",
5685 0,
5686 &shell,
5687 &commands,
5688 &winner.worktree,
5689 timeout,
5690 )
5691 .await;
5692 for o in &outcomes {
5693 if !o.ok() {
5694 tracing::warn!(
5695 "pre_gate `{}` failed ({:?}); the gate decides",
5696 o.command,
5697 o.code
5698 );
5699 }
5700 self.state.event(
5701 "pre_gate",
5702 format!(
5703 "`{}` -> {}",
5704 o.command,
5705 if o.ok() {
5706 "pass".to_owned()
5707 } else {
5708 format!(
5709 "FAIL ({:?})\n{}",
5710 o.code,
5711 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5712 )
5713 }
5714 ),
5715 );
5716 }
5717 self.state.pre_gate = outcomes;
5718 match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5719 Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5720 Ok(head) => {
5721 self.state
5722 .event("pre_gate", format!("committed mechanical fixes ({head})"));
5723 self.state.pre_gate_commit = Some(head);
5724 }
5725 Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5726 },
5727 Ok(false) => {}
5728 Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5729 }
5730 if let Err(e) = self.state.save() {
5731 tracing::warn!("could not persist the pre_gate record: {e:#}");
5732 }
5733 }
5734
5735 async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5738 self.run_pre_gate(winner).await;
5739 let shell = self.state.config.shell();
5740 let gate_commands = self.state.config.verify.gate.clone();
5741 let outcomes = if gate_commands.is_empty() {
5750 Vec::new()
5751 } else {
5752 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5753 let cache_dir = self.state.config.cache_dir();
5754 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5755 let (outcomes, _) = with_cache_lease(
5756 &mut self.state,
5757 cache_dir.as_deref(),
5758 "gate",
5759 "gate",
5760 &winner.worktree,
5761 &head,
5762 timeout,
5763 "final gate",
5764 |state, budget| {
5765 let shell = shell.clone();
5766 let gate_commands = gate_commands.clone();
5767 let worktree = winner.worktree.clone();
5768 async move {
5769 let (outcomes, timed_out_pids) = run_commands(
5770 state,
5771 "gate",
5772 "gate",
5773 0,
5774 &shell,
5775 &gate_commands,
5776 &worktree,
5777 budget,
5778 )
5779 .await;
5780 (outcomes, false, timed_out_pids)
5781 }
5782 },
5783 )
5784 .await;
5785 outcomes
5786 };
5787 if outcomes.is_empty() {
5788 self.state.event(
5793 "gate",
5794 "no gate commands configured; nothing to check, passing",
5795 );
5796 }
5797 for o in &outcomes {
5798 self.state.event(
5799 "gate",
5800 format!(
5801 "`{}` -> {}",
5802 o.command,
5803 if o.ok() {
5804 "pass".to_owned()
5805 } else {
5806 format!(
5807 "FAIL ({:?})\n{}",
5808 o.code,
5809 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5810 )
5811 }
5812 ),
5813 );
5814 }
5815 Ok(outcomes)
5816 }
5817
5818 async fn gate_fix_round(
5831 &mut self,
5832 winner: &Candidate,
5833 outcomes: &[CommandOutcome],
5834 ) -> Result<GateFix> {
5835 let cap = self.state.config.graph.gate_fix_rounds;
5836 let spent = self.state.gate_fixes.len();
5837 if spent >= cap {
5838 if cap > 0 {
5839 self.state.event(
5840 "gate",
5841 format!("{spent} gate-fix round(s) spent and the gate still fails"),
5842 );
5843 }
5844 return Ok(GateFix::Stop);
5845 }
5846 if !gate_fixable(outcomes) {
5847 self.state.event(
5848 "gate",
5849 "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5850 command or similar); not spending a fix round on it",
5851 );
5852 return Ok(GateFix::Stop);
5853 }
5854 let min_free = self.state.config.disk.min_free_bytes;
5855 if min_free > 0 {
5856 match crate::disk::free_bytes(&winner.worktree) {
5857 Ok(free) if crate::disk::enough_space(free, min_free) => {}
5858 Ok(free) => {
5859 self.state.event(
5860 "gate",
5861 format!(
5862 "only {free} bytes free ({min_free} required by `[disk] \
5863 min_free_bytes`); not spending a fix round on a failure the disk \
5864 may explain"
5865 ),
5866 );
5867 return Ok(GateFix::Stop);
5868 }
5869 Err(e) => {
5870 self.state.event(
5871 "gate",
5872 format!("free disk space could not be measured ({e:#}); no fix round"),
5873 );
5874 return Ok(GateFix::Stop);
5875 }
5876 }
5877 }
5878
5879 let attempt = spent + 1;
5880 let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5881 let base = self.landing_base();
5882 let fix_prompt = prompt::gate_fix(
5883 &self.state.instruction,
5884 &failed,
5885 attempt,
5886 cap,
5887 &self.state.config.graph.language,
5888 );
5889 let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5890 let sessions = self.state.config.graph.sessions;
5891 let artifacts = agent::artifacts_dir(&self.state.dir());
5892 self.state.event(
5893 "gate",
5894 format!("gate failed; gate-fix round {attempt} of {cap}"),
5895 );
5896 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5897 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5898 let (_, seat, out) = self
5899 .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
5900 prompt: fix_prompt.clone(),
5901 spec,
5902 seat,
5903 cwd: winner.worktree.clone(),
5904 timeout,
5905 allow_write: true,
5906 sessions,
5907 artifacts: artifacts.clone(),
5908 stem: format!("gate-fix-{attempt}"),
5909 handover: None,
5910 })
5911 .await;
5912 let mut record = GateFixRecord {
5913 agent: seat.agent.clone(),
5914 failed,
5915 notes: String::new(),
5916 committed: false,
5917 error: None,
5918 };
5919 match out {
5920 AgentOutcome::Ok(o) => {
5921 if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5924 record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5925 }
5926 }
5927 AgentOutcome::Dropped(_) => {
5928 record.error = Some("the CLI dropped the stream".to_owned());
5929 }
5930 AgentOutcome::Quota(o) => {
5931 self.state.quota.push(QuotaLoss {
5932 seat: seat.key.clone(),
5933 node: "gate-fix".to_owned(),
5934 at: Timestamp::now(),
5935 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5936 });
5937 record.error = Some("rate limited (quota); fixer could not run".to_owned());
5938 }
5939 AgentOutcome::Failed(e) => record.error = Some(e),
5940 }
5941 self.state.seats.insert(seat.key.clone(), seat);
5942 if let Ok(r) = git::rescue_commit(
5943 &winner.worktree,
5944 &format!("magi: gate fix {attempt} (uncommitted work)"),
5945 )
5946 .await
5947 {
5948 self.state.note_withheld("gate-fix", &r.withheld);
5949 }
5950 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5951 record.committed = after != before;
5952 let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5953 let note = record.error.clone();
5954 self.state.gate_fixes.push(record);
5955 self.state.save()?;
5956 if !changed {
5957 self.state.event(
5958 "gate",
5959 match note {
5960 Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5961 None => format!("gate-fix round {attempt}: the tree did not change"),
5962 },
5963 );
5964 return Ok(GateFix::Stop);
5965 }
5966 self.state.event(
5967 "gate",
5968 format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5969 );
5970
5971 let commands = self.state.config.verify.e2e.clone();
5972 if !commands.is_empty() {
5973 let shell = self.state.config.shell();
5974 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5975 let cache_dir = self.state.config.cache_dir();
5976 let context = format!("gate-fix round {attempt}");
5977 let (e2e, _) = with_cache_lease(
5978 &mut self.state,
5979 cache_dir.as_deref(),
5980 "e2e",
5981 "e2e",
5982 &winner.worktree,
5983 &after,
5984 timeout,
5985 &context,
5986 |state, budget| {
5987 let shell = shell.clone();
5988 let commands = commands.clone();
5989 let context = context.clone();
5990 let worktree = winner.worktree.clone();
5991 async move {
5992 run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5993 .await
5994 }
5995 },
5996 )
5997 .await;
5998 if verify_inconclusive(&e2e) {
5999 return Ok(GateFix::Defer);
6000 }
6001 if e2e.iter().any(|o| !o.ok()) {
6002 self.state.event(
6003 "gate",
6004 format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6005 );
6006 return Ok(GateFix::Stop);
6007 }
6008 }
6009 Ok(GateFix::Retry)
6010 }
6011
6012 async fn guarded_pr_message(
6019 &mut self,
6020 winner: &Candidate,
6021 facts: Option<&BranchFacts>,
6022 posting: bool,
6023 ) -> Result<Option<PrMessage>> {
6024 let mut pr = pr_message_raw(&self.state, winner.label, facts);
6025 if !posting {
6026 let identity = crate::scrub::Identity::current();
6027 return Ok(Some(PrMessage {
6028 title: crate::scrub::scrub(&pr.title, &identity),
6029 body: crate::scrub::scrub(&pr.body, &identity),
6030 }));
6031 }
6032 let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6035 if let Some(known) = self
6036 .state
6037 .github_text
6038 .clone()
6039 .filter(|g| g.fingerprint == raw_fp)
6040 {
6041 return self.settle_withheld(winner, &pr, known).await;
6042 }
6043 let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6044 let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6045 let identity = crate::scrub::Identity::current();
6046 if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6047 || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6048 && !violations.contains(&crate::github_text::Violation::SensitiveData)
6049 {
6050 violations.push(crate::github_text::Violation::SensitiveData);
6051 }
6052 let has_language = violations
6056 .iter()
6057 .any(|v| *v != crate::github_text::Violation::SensitiveData);
6058 if self.state.config.graph.github_text_guard && !violations.is_empty() {
6059 self.state.event(
6060 "github-text",
6061 if has_language {
6062 format!("description rejected: {violations:?}; requesting one rewrite")
6063 } else {
6064 format!("description rejected: {violations:?}; asking the owner")
6065 },
6066 );
6067 let mut rewritten = false;
6068 if has_language
6069 && let Some(spec) = self
6070 .state
6071 .config
6072 .agents
6073 .iter()
6074 .find(|a| a.id == winner.agent)
6075 .cloned()
6076 {
6077 let key = format!("impl-{}", winner.label);
6078 let seat = self.seat(&key, &spec.id);
6079 let prompt = format!(
6080 "Rewrite only the following pull request description. The posting gate reported {violations:?}. Write English prose and remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the complete Markdown body. Preserve the magi run marker.\n\nTITLE: {}\n{}",
6081 pr.title, pr.body
6082 );
6083 let job = SeatJob {
6084 spec,
6085 seat,
6086 cwd: winner.worktree.clone(),
6087 prompt,
6088 timeout: retry_budget(
6089 Duration::from_secs(self.state.config.graph.timeout_implement),
6090 true,
6091 ),
6092 allow_write: false,
6093 sessions: self.state.config.graph.sessions,
6094 artifacts: agent::artifacts_dir(&self.state.dir()),
6095 stem: "github-text-rewrite".to_owned(),
6096 handover: None,
6097 };
6098 let prompts = self.state.config.prompts.clone();
6099 let cache = self.state.config.cache_dir();
6100 let run = self.state.id.clone();
6101 let ctx = WaveCtx {
6102 run: &run,
6103 node: "github-text",
6104 prompts: &prompts,
6105 cache: cache.as_deref(),
6106 round: None,
6107 carry_seats: false,
6108 };
6109 let (seat, outcome) =
6110 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6111 self.state.seats.insert(seat.key.clone(), seat);
6112 if let AgentOutcome::Ok(output) = outcome
6113 && let Some(title) = summary_title(&output.text)
6114 {
6115 let mut body = summary_without_title(&output.text);
6116 let rewrite = PrMessage {
6117 title: title.clone(),
6118 body: body.clone(),
6119 };
6120 let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6121 if !body.trim().is_empty()
6122 && crate::github_text::check_with(&title, &body, redecision).is_empty()
6123 && crate::github_text::shareable(&title)
6124 && crate::github_text::shareable(&body)
6125 {
6126 decision = redecision;
6127 let marker = format!("magi:run/{}", self.state.id);
6128 if !body.contains(&marker) {
6129 body.push_str(&format!("\n\n{marker}"));
6130 }
6131 pr = PrMessage { title, body };
6132 rewritten = true;
6133 self.state
6134 .event("github-text", "description rewrite passed");
6135 }
6136 }
6137 }
6138 if !rewritten {
6139 self.state.event(
6140 "github-text",
6141 "description rewrite unavailable or rejected; asking the owner",
6142 );
6143 if let Some(w) =
6144 crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6145 {
6146 self.retire_github_text_question("the withheld text changed");
6149 let record = crate::run::GithubTextAsk {
6150 fingerprint: raw_fp,
6151 title: w.title,
6152 body: w.body,
6153 categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6154 question: None,
6155 asks: 0,
6156 resolved: false,
6157 chosen_title: None,
6158 };
6159 return self.settle_withheld(winner, &pr, record).await;
6160 }
6161 }
6162 }
6163 let (title, body) =
6164 crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6165 Ok(Some(PrMessage { title, body }))
6166 }
6167
6168 fn retire_github_text_question(&mut self, why: &str) {
6170 let Some(id) = self
6171 .state
6172 .github_text
6173 .as_ref()
6174 .and_then(|g| g.question.clone())
6175 else {
6176 return;
6177 };
6178 let _ = ask::Questions::open().update(&id, |q| {
6179 q.abandon(why);
6180 Ok(())
6181 });
6182 }
6183
6184 async fn settle_withheld(
6188 &mut self,
6189 winner: &Candidate,
6190 pr: &PrMessage,
6191 mut g: crate::run::GithubTextAsk,
6192 ) -> Result<Option<PrMessage>> {
6193 use crate::github_text as gt;
6194 let store = ask::Questions::open();
6195 let timeout = self.state.config.graph.answer_timeout;
6196 if !g.resolved {
6197 let mut standing = g
6198 .question
6199 .as_ref()
6200 .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6201 if let Some(q) = standing.as_ref()
6202 && q.status.open()
6203 && gt::expired(q, timeout)
6204 {
6205 let why = format!("no answer within {}s of asking", timeout.max(1));
6206 standing = store
6207 .update(&q.id, |q| {
6208 q.abandon(&why);
6209 Ok(())
6210 })
6211 .ok()
6212 .map(|(q, ())| q);
6213 }
6214 let mut ask_again: Option<(Vec<String>, Option<String>)> = None;
6215 match standing {
6216 None if g.asks <= 1 => {
6219 g.asks = 0;
6220 ask_again = Some((g.categories.clone(), None));
6221 }
6222 None => {
6223 g.resolved = true;
6224 g.chosen_title = None;
6225 }
6226 Some(q) if q.status.open() => {
6227 self.park_on_github_text(&g)?;
6228 return Ok(None);
6229 }
6230 Some(q) => match gt::read_reply(&q) {
6231 gt::Reply::Fallback => {
6232 g.resolved = true;
6233 g.chosen_title = None;
6234 }
6235 gt::Reply::Title(text) => {
6236 let first = text
6237 .lines()
6238 .map(str::trim)
6239 .find(|l| !l.is_empty())
6240 .unwrap_or("");
6241 let decision = if first.is_empty() {
6242 None
6243 } else {
6244 let candidate = PrMessage {
6245 title: first.to_owned(),
6246 body: String::new(),
6247 };
6248 self.judge_pr_language(&winner.worktree, &candidate).await
6249 };
6250 match gt::vet_title(&text, decision) {
6251 Ok(title) => {
6252 g.resolved = true;
6253 g.chosen_title = Some(title);
6254 }
6255 Err(cats) if g.asks < 2 => {
6256 ask_again = Some((
6257 cats.into_iter().map(str::to_owned).collect(),
6258 Some(first.to_owned()),
6259 ))
6260 }
6261 Err(_) => {
6262 g.resolved = true;
6263 g.chosen_title = None;
6264 }
6265 }
6266 }
6267 },
6268 }
6269 if let Some((categories, replacement)) = ask_again {
6270 let retry = replacement.is_some();
6271 let (shown_title, shown_body) = match replacement.as_deref() {
6274 Some(t) => (t, ""),
6275 None => (pr.title.as_str(), pr.body.as_str()),
6276 };
6277 let w = gt::Withheld {
6278 title: g.title || retry,
6279 body: g.body && !retry,
6280 categories,
6281 };
6282 let artifact = match crate::run::write_artifact(
6285 &self.state,
6286 >::artifact_name(>::fingerprint(shown_title, shown_body)),
6287 >::artifact_text(shown_title, shown_body),
6288 ) {
6289 Ok(p) => Some(p),
6290 Err(e) => {
6291 tracing::warn!("could not write the withheld-text artifact: {e:#}");
6292 None
6293 }
6294 };
6295 let mut q = ask::Question::new(
6296 self.state.id.clone(),
6297 gt::ASK_NODE.to_owned(),
6298 gt::ASK_SEAT.to_owned(),
6299 gt::question_summary(&self.state.config.graph.language, &w),
6300 gt::question_detail(&w, shown_title, shown_body, retry, artifact.as_deref()),
6301 gt::question_choices(&w),
6302 );
6303 q.answer_timeout = timeout;
6304 g.question = Some(q.id.clone());
6307 g.asks += 1;
6308 self.state.github_text = Some(g.clone());
6309 self.state.event(
6310 "github-text",
6311 format!("asking the owner about the withheld text ({})", q.short()),
6312 );
6313 self.state.save()?;
6314 store
6315 .put(&mut q)
6316 .context("file the posting-gate question")?;
6317 if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6318 tracing::warn!(
6319 "could not notify about posting-gate question {}: {e:#}",
6320 q.short()
6321 );
6322 }
6323 self.park_on_github_text(&g)?;
6324 return Ok(None);
6325 }
6326 }
6327 let resolved = g.chosen_title.clone();
6328 self.state.event(
6329 "github-text",
6330 if resolved.is_some() {
6331 "posting the owner's replacement title"
6332 } else {
6333 "posting the neutral text for the withheld fields"
6334 },
6335 );
6336 self.state.github_text = Some(g.clone());
6337 self.state.save()?;
6338 let neutral = |cat: &str| g.categories.iter().any(|c| c == cat);
6341 let title = match (g.title, resolved) {
6342 (true, Some(t)) => t,
6343 (true, None) if neutral("title-language") => gt::NEUTRAL_TITLE.to_owned(),
6344 _ => pr.title.clone(),
6345 };
6346 let body = if g.body && neutral("body-language") {
6347 format!("{}\n\nmagi:run/{}", gt::NEUTRAL_BODY, self.state.id)
6348 } else {
6349 pr.body.clone()
6350 };
6351 let english = gt::LanguageDecision {
6353 title_english: true,
6354 body_english: true,
6355 body_complete: true,
6356 };
6357 let (title, body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6358 Ok(Some(PrMessage { title, body }))
6359 }
6360
6361 fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6362 self.state.github_text = Some(g.clone());
6363 self.state.parked = true;
6364 self.state.event(
6365 "github-text",
6366 "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6367 );
6368 self.state.save()
6369 }
6370
6371 async fn judge_pr_language(
6374 &mut self,
6375 cwd: &Path,
6376 pr: &PrMessage,
6377 ) -> Option<crate::github_text::LanguageDecision> {
6378 self.state.config.roles.language_judge.as_ref()?;
6379 let decision =
6380 crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6381 self.state.event(
6382 "github-text",
6383 if decision.is_some() {
6384 "language decided by the language judge"
6385 } else {
6386 "language judge unavailable; using built-in heuristics"
6387 },
6388 );
6389 decision
6390 }
6391
6392 async fn merge(&mut self) -> Result<()> {
6393 if self
6408 .state
6409 .base_sync
6410 .as_ref()
6411 .is_some_and(|s| s.conflict.is_some())
6412 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6413 != Some(RunStatus::Gating)
6414 || !self.state.gate_status().ok()
6423 {
6424 return Ok(());
6425 }
6426 if self.state.merge.is_some() {
6435 return Ok(());
6436 }
6437 let Some(winner) = self.state.winner().cloned() else {
6438 return Ok(());
6439 };
6440 let repo = self.state.repo.clone();
6441 let base = self.state.base_branch.clone();
6442 let mode = self.state.config.merge.mode;
6443 let style = self.state.config.merge.style;
6444 let facts = if is_review_run(&self.state) {
6445 refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6446 let start = review_base(
6447 &repo,
6448 &self.state.config.merge.remote,
6449 &base,
6450 &self.state.base_commit,
6451 &winner.branch,
6452 )
6453 .await;
6454 branch_facts(&repo, &start, &winner.branch).await
6455 } else {
6456 None
6457 };
6458 let leaked = if is_review_run(&self.state) {
6461 leaked_subjects(&self.state, &winner.branch).await
6462 } else {
6463 Some(Vec::new())
6464 };
6465 let Some(pr) = self
6469 .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6470 .await?
6471 else {
6472 return Ok(());
6473 };
6474 let message = pr.commit_message();
6475
6476 let outcome = match mode {
6477 MergeMode::None => MergeOutcome {
6478 mode,
6479 ok: true,
6480 detail: manual_merge_command(style, &repo, &winner.branch, &message),
6481 empty: false,
6482 },
6483 MergeMode::Pr | MergeMode::Local
6484 if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6485 {
6486 MergeOutcome {
6487 mode,
6488 ok: false,
6489 detail: empty_candidate_detail(&self.state, &base),
6490 empty: true,
6491 }
6492 }
6493 MergeMode::Local => {
6494 let on = git::current_branch(&repo).await?;
6495 if on.as_deref() != Some(base.as_str()) {
6496 MergeOutcome {
6497 mode,
6498 ok: false,
6499 detail: format!(
6500 "{} has {} checked out, not the base branch {base}",
6501 repo.display(),
6502 on.unwrap_or_else(|| "a detached HEAD".to_owned())
6503 ),
6504 empty: false,
6505 }
6506 } else if !git::is_clean(&repo).await? {
6507 MergeOutcome {
6508 mode,
6509 ok: false,
6510 detail: format!("{} is dirty; refusing to merge", repo.display()),
6511 empty: false,
6512 }
6513 } else {
6514 let out = match style {
6515 MergeStyle::Merge => {
6516 git::merge_no_ff(&repo, &winner.branch, &message).await?
6517 }
6518 MergeStyle::Squash => {
6519 git::merge_squash(&repo, &winner.branch, &message).await?
6520 }
6521 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6522 };
6523 MergeOutcome {
6524 mode,
6525 ok: out.ok(),
6526 detail: if out.ok() { out.stdout } else { out.stderr },
6527 empty: false,
6528 }
6529 }
6530 }
6531 MergeMode::Pr => {
6532 let remote = self.state.config.merge.remote.clone();
6533 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6534 if !pushed.ok() {
6535 MergeOutcome {
6536 mode,
6537 ok: false,
6538 detail: pushed.stderr,
6539 empty: false,
6540 }
6541 } else {
6542 let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6548 let out = match pr_merge_plan(found) {
6549 PrPlan::Create => {
6550 gh_pr_create(
6551 &winner.worktree,
6552 &base,
6553 &winner.branch,
6554 &pr.title,
6555 &pr.body,
6556 )
6557 .await
6558 }
6559 PrPlan::Adopt { url, title } => {
6560 self.state
6561 .event("merge", format!("Pr: adopted open pull request {url}"));
6562 if title != pr.title
6563 && (!is_review_run(&self.state)
6564 || leaked
6565 .as_deref()
6566 .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6567 && let Err(e) = land::set_pr_title(
6568 &mut self.state,
6569 &winner.worktree,
6570 &url,
6571 &pr.title,
6572 )
6573 .await
6574 {
6575 tracing::warn!("could not refresh title of {url}: {e:#}");
6576 self.state
6577 .event("merge", format!("Pr: title refresh failed: {e:#}"));
6578 }
6579 Ok(url)
6580 }
6581 PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6582 };
6583 match out {
6584 Ok(url) => MergeOutcome {
6585 mode,
6586 ok: true,
6587 detail: url,
6588 empty: false,
6589 },
6590 Err(e) => MergeOutcome {
6591 mode,
6592 ok: false,
6593 detail: e.to_string(),
6594 empty: false,
6595 },
6596 }
6597 }
6598 }
6599 };
6600
6601 self.state.status = match (mode, outcome.ok) {
6602 (MergeMode::None, _) => RunStatus::Ready,
6603 (_, true) => RunStatus::Merged,
6604 (_, false) => RunStatus::Blocked,
6605 };
6606 self.state.event(
6607 "merge",
6608 format!(
6609 "{:?}: {}",
6610 mode,
6611 outcome.detail.lines().next().unwrap_or("")
6612 ),
6613 );
6614 self.state.merge = Some(outcome);
6615 self.state.save()?;
6616
6617 if self.state.config.graph.land
6623 && mode == MergeMode::Pr
6624 && self.state.status == RunStatus::Merged
6625 {
6626 self.run_land().await?;
6627 }
6628 self.settle_questions();
6633 Ok(())
6634 }
6635
6636 async fn run_land(&mut self) -> Result<()> {
6647 let url = self
6648 .state
6649 .merge
6650 .as_ref()
6651 .map(|m| m.detail.clone())
6652 .unwrap_or_default();
6653 let url = url.lines().next().unwrap_or("").trim().to_owned();
6654 if !url.starts_with("http") {
6655 return Ok(());
6656 }
6657 match land::land(&mut self.state, &url).await {
6660 Ok(pr) if self.state.parked => {
6661 let _ = pr;
6665 }
6666 Ok(pr) => {
6667 self.state.status = match pr.state {
6668 land::PrLifecycle::Merged => RunStatus::Merged,
6669 _ => RunStatus::Blocked,
6670 };
6671 if bump::should_release_bump(self.state.status)
6678 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6679 {
6680 self.state
6686 .event("bump", format!("release bump skipped: {e:#}"));
6687 }
6688 if self.state.status == RunStatus::Merged {
6691 crate::followup::after_merge(&mut self.state, &pr.url).await;
6692 }
6693 self.state.save()?;
6694 }
6695 Err(e) => {
6696 self.state.status = RunStatus::Blocked;
6697 self.state.event("land", format!("gave up: {e}"));
6698 self.state.save()?;
6699 }
6700 }
6701 Ok(())
6702 }
6703
6704 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6708 if let Some(existing) = self.state.seats.get(key)
6709 && existing.agent == agent
6710 {
6711 return existing.clone();
6712 }
6713 let fresh = if self.state.seats.contains_key(key) {
6716 handover_seat(key, agent, self.state.next_seat_seed())
6717 } else {
6718 SeatState::new(key, agent, self.state.seed)
6719 };
6720 self.state.seats.insert(key.to_owned(), fresh.clone());
6721 fresh
6722 }
6723
6724 fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6730 match self.state.seats.get(key) {
6731 Some(s) if s.agent != spec.id => {
6732 self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6733 }
6734 _ => spec,
6735 }
6736 }
6737
6738 fn view(&self, c: &Candidate) -> CandidateView {
6740 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6741 .unwrap_or_default();
6742 let (patch, _) = blind::sanitize_patch(
6743 &format!("candidate {} patch", c.label),
6744 &raw,
6745 &self.state.config.blind,
6746 );
6747 CandidateView {
6748 label: c.label,
6749 branch: c.branch.clone(),
6750 summary: c.summary.clone(),
6751 stat: c.stat.clone(),
6752 patch,
6753 }
6754 }
6755
6756 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6758 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6759 prompt::judge(
6760 "(see above)",
6761 &views,
6762 self.roles.judges.len(),
6763 base_short,
6764 "en",
6765 )
6766 }
6767
6768 fn vote_prompt_full(
6774 &self,
6775 j: usize,
6776 labels: &[char],
6777 language: &str,
6778 candidates: &[Candidate],
6779 base_short: &str,
6780 ) -> String {
6781 let mut text = format!(
6782 "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6783 prompt::final_vote(labels, language),
6784 self.state.instruction,
6785 self.candidate_block(candidates, base_short)
6786 );
6787 if let Some(own) = self
6788 .state
6789 .judgements
6790 .get(j)
6791 .filter(|r| !r.ranking.is_empty())
6792 {
6793 let reasons = own
6794 .reasons
6795 .iter()
6796 .map(|(k, v)| format!("- {k}: {v}"))
6797 .collect::<Vec<_>>()
6798 .join("\n");
6799 text.push_str(&format!(
6800 "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6801 own.ranking.iter().collect::<String>(),
6802 if reasons.is_empty() {
6803 ""
6804 } else {
6805 ", because:\n"
6806 }
6807 ));
6808 }
6809 if !self.state.deliberation.is_empty() {
6810 text.push_str("\n# What was argued before this vote\n");
6811 for t in self.transcript(&[], j) {
6812 text.push_str(&format!(
6813 "\n## {}{}\n\n{}\n",
6814 t.who,
6815 if t.is_self { " (you)" } else { "" },
6816 t.body.trim()
6817 ));
6818 }
6819 }
6820 text
6821 }
6822
6823 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6830 let mut turns = Vec::new();
6831 for j in &self.state.judgements {
6832 if j.ranking.is_empty() {
6833 continue;
6834 }
6835 let reasons = j
6836 .reasons
6837 .iter()
6838 .map(|(k, v)| format!("- {k}: {v}"))
6839 .collect::<Vec<_>>()
6840 .join("\n");
6841 turns.push(Turn {
6842 who: format!("Judge {} (opening ranking)", j.judge),
6843 is_self: j.judge == self_idx + 1,
6844 body: format!(
6845 "Ranked {}{}{reasons}",
6846 j.ranking.iter().collect::<String>(),
6847 if reasons.is_empty() {
6848 ""
6849 } else {
6850 ", because:\n"
6851 }
6852 ),
6853 });
6854 }
6855 for t in self
6856 .state
6857 .deliberation
6858 .iter()
6859 .flat_map(|r| r.turns.iter())
6860 .chain(current)
6861 {
6862 turns.push(Turn {
6863 who: format!("Judge {}", t.judge),
6864 is_self: t.judge == self_idx + 1,
6865 body: t.body.clone(),
6866 });
6867 }
6868 turns
6869 }
6870}
6871
6872fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6874 agent::has_session(spec.kind, seat, sessions)
6875}
6876
6877fn next_untried_in_roster<'a>(
6898 roster: &'a [AgentSpec],
6899 start: usize,
6900 tried: &BTreeSet<String>,
6901) -> Option<&'a AgentSpec> {
6902 roster
6903 .get(start + 1..)?
6904 .iter()
6905 .find(|s| !tried.contains(&s.id))
6906}
6907
6908fn pick_successor<'a>(
6929 roster: &'a [AgentSpec],
6930 start: usize,
6931 tried: &BTreeSet<String>,
6932 carried: Option<&BTreeSet<String>>,
6933 others: &BTreeSet<String>,
6934) -> Option<&'a AgentSpec> {
6935 let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6936 roster
6937 .get(start + 1..)
6938 .and_then(|tail| {
6939 tail.iter()
6940 .filter(free)
6941 .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6942 })
6943 .or_else(|| {
6944 carried
6945 .is_some()
6946 .then(|| roster.iter().find(free))
6947 .flatten()
6948 })
6949 .or_else(|| match carried {
6950 Some(c) => next_for_seat(roster, start, tried, c),
6951 None => next_untried_in_roster(roster, start, tried),
6952 })
6953}
6954
6955fn next_for_seat<'a>(
6965 roster: &'a [AgentSpec],
6966 start: usize,
6967 round_tried: &BTreeSet<String>,
6968 carried_failed: &BTreeSet<String>,
6969) -> Option<&'a AgentSpec> {
6970 roster
6971 .get(start + 1..)?
6972 .iter()
6973 .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6974 .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6975}
6976
6977fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6983 let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6984 return spec;
6985 };
6986 let ok = |id: &str| !h.failed.contains(id);
6987 if let Some(last) = h.last_ok.as_deref()
6988 && ok(last)
6989 && let Some(s) = roster.iter().find(|s| s.id == last)
6990 {
6991 return s.clone();
6992 }
6993 if ok(&spec.id) {
6994 return spec;
6995 }
6996 let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6997 roster
6998 .get(from + 1..)
6999 .into_iter()
7000 .flatten()
7001 .chain(roster.iter())
7002 .find(|s| ok(&s.id))
7003 .cloned()
7004 .unwrap_or(spec)
7005}
7006
7007pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
7011 SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
7012}
7013
7014const TIMED_OUT: &str = "timed out";
7017
7018impl FailClass {
7019 fn of(out: &AgentOutcome) -> Option<Self> {
7021 match out {
7022 AgentOutcome::Ok(_) => None,
7023 AgentOutcome::Quota(_) => Some(Self::Quota),
7024 AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7025 AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7026 AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7027 }
7028 }
7029
7030 fn stem_word(&self) -> &'static str {
7032 match self {
7033 Self::Quota => "quota",
7034 _ => "handover",
7035 }
7036 }
7037}
7038
7039fn failure_signature(msg: &str) -> String {
7043 let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7044 let mut out = Vec::new();
7045 for word in line.split_whitespace() {
7046 if word.contains('/') || word.contains('\\') {
7047 out.push("<path>".to_owned());
7048 continue;
7049 }
7050 let mut w = String::new();
7051 let mut in_digits = false;
7052 for c in word.chars() {
7053 if c.is_ascii_digit() {
7054 if !in_digits {
7055 w.push('#');
7056 }
7057 in_digits = true;
7058 } else {
7059 in_digits = false;
7060 w.push(c);
7061 }
7062 }
7063 out.push(w);
7064 }
7065 out.join(" ").chars().take(120).collect()
7066}
7067
7068fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7075 match cur {
7076 FailClass::Quota | FailClass::Timeout => true,
7077 FailClass::Other(_) => prev != Some(cur),
7078 }
7079}
7080
7081fn fail_reason(out: &AgentOutcome) -> String {
7083 match out {
7084 AgentOutcome::Ok(_) => String::new(),
7085 AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7086 AgentOutcome::Dropped(o) => format!(
7087 "the CLI dropped the stream ({})",
7088 o.dropped
7089 .as_ref()
7090 .map(|d| d.why.as_str())
7091 .unwrap_or("it ended without delivering its answer")
7092 ),
7093 AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7094 }
7095}
7096
7097pub(crate) fn record_handover(
7100 state: &mut RunState,
7101 node: &str,
7102 seat: &str,
7103 from: &str,
7104 to: &str,
7105 class: &FailClass,
7106 reason: &str,
7107) {
7108 let message = if *class == FailClass::Quota {
7109 format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7110 } else {
7111 format!("{seat}: handed over {from} -> {to} ({reason})")
7112 };
7113 state.event(node, message);
7114 state.handovers.push(Handover {
7115 at: Timestamp::now(),
7116 node: node.to_owned(),
7117 seat: seat.to_owned(),
7118 from: from.to_owned(),
7119 to: to.to_owned(),
7120 reason: reason.to_owned(),
7121 });
7122}
7123
7124fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7138 commands.iter().any(|c| c.exit_code.is_none())
7139}
7140
7141fn verified_noop_claim(
7154 usable: bool,
7155 commands: &[agent::CommandEvidence],
7156 text: &str,
7157) -> Option<String> {
7158 (usable && !has_unconfirmed_command(commands))
7159 .then(|| verdict::verified_noop(text))
7160 .flatten()
7161}
7162
7163fn short(commit: &str) -> String {
7164 commit.chars().take(7).collect()
7165}
7166
7167fn make_executable(path: &Path) -> Result<()> {
7168 #[cfg(unix)]
7169 {
7170 use std::os::unix::fs::PermissionsExt as _;
7171 let mut perms = std::fs::metadata(path)?.permissions();
7172 perms.set_mode(0o755);
7173 std::fs::set_permissions(path, perms)?;
7174 }
7175 #[cfg(not(unix))]
7176 {
7177 let _ = path;
7178 }
7179 Ok(())
7180}
7181
7182struct WaveCtx<'a> {
7189 run: &'a str,
7192 node: &'a str,
7194 prompts: &'a Prompts,
7195 cache: Option<&'a Path>,
7197 round: Option<usize>,
7200 carry_seats: bool,
7204}
7205
7206async fn run_one(
7208 job: SeatJob,
7209 sem: Arc<Semaphore>,
7210 ctx: &WaveCtx<'_>,
7211 state: &mut RunState,
7212 attempt: usize,
7213) -> (SeatState, AgentOutcome) {
7214 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7215 .await
7216 .pop()
7217 .expect("one job in, one result out");
7218 (seat, out)
7219}
7220
7221async fn wave(
7227 jobs: Vec<SeatJob>,
7228 sem: Arc<Semaphore>,
7229 ctx: &WaveCtx<'_>,
7230 state: &mut RunState,
7231 attempt: usize,
7232) -> Vec<(usize, SeatState, AgentOutcome)> {
7233 let WaveCtx {
7234 run,
7235 node,
7236 prompts,
7237 cache,
7238 round,
7239 carry_seats: _,
7240 } = *ctx;
7241 for job in &jobs {
7242 state.seat_started(node, &job.seat.key, job.timeout, attempt);
7243 }
7244 if let Err(e) = state.save() {
7245 tracing::warn!("could not persist in-progress seats: {e:#}");
7250 }
7251 let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7267 let wait_started = Instant::now();
7268 let cache_guard = if let Some(cache_dir) = cache {
7269 if jobs_had_a_writer {
7270 let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7271 let budget = jobs
7272 .iter()
7273 .map(|j| j.timeout)
7274 .max()
7275 .unwrap_or(Duration::from_secs(60));
7276 acquire_cache_lease(state, cache_dir, &owner, budget, node)
7277 .await
7278 .ok()
7279 } else {
7280 None
7281 }
7282 } else {
7283 None
7284 };
7285 let waited_for_lease = wait_started.elapsed();
7292 let mut set = tokio::task::JoinSet::new();
7293 let overlay = prompts.overlay(node);
7294 for (i, mut job) in jobs.into_iter().enumerate() {
7295 job.timeout = job.timeout.saturating_sub(waited_for_lease);
7296 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7297 if cache.is_some() {
7298 job.prompt.push('\n');
7299 job.prompt
7300 .push_str(&prompt::build_cache_note(node, job.allow_write));
7301 }
7302 let sem = Arc::clone(&sem);
7303 let run = run.to_owned();
7304 let node = node.to_owned();
7305 let attachments = if node == "implement" {
7308 state.attachments.clone()
7309 } else {
7310 Vec::new()
7311 };
7312 let cache = cache
7323 .filter(|_| job.allow_write && cache_guard.is_some())
7324 .map(Path::to_path_buf);
7325 set.spawn(async move {
7326 let _permit = sem.acquire().await;
7327 let mut seat = job.seat;
7328 let out = agent::invoke(
7329 &job.spec,
7330 &mut seat,
7331 &Invocation {
7332 cwd: &job.cwd,
7333 prompt: &job.prompt,
7334 timeout: job.timeout,
7335 allow_write: job.allow_write,
7336 unsandboxed: false,
7337 sessions: job.sessions,
7338 artifacts: &job.artifacts,
7339 stem: &job.stem,
7340 run: &run,
7341 node: &node,
7342 cache_dir: cache.as_deref(),
7343 attachments: &attachments,
7344 writable: &[],
7345 },
7346 )
7347 .await;
7348 let out = match out {
7349 Ok(o) if o.usable() => AgentOutcome::Ok(o),
7350 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7351 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7359 Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7360 Ok(o) => AgentOutcome::Failed(format!(
7361 "exited with {:?} and no usable output",
7362 o.exit_code
7363 )),
7364 Err(e) => AgentOutcome::Failed(e.to_string()),
7365 };
7366 (i, seat, out)
7367 });
7368 }
7369 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7370 while let Some(joined) = set.join_next().await {
7371 let (i, seat, out) = match joined {
7372 Ok(v) => v,
7373 Err(e) => {
7377 tracing::error!("agent task panicked: {e}");
7378 continue;
7379 }
7380 };
7381 state.seat_finished(&seat.key);
7382 record_jobs(state, node, round, &seat.key, &out);
7383 if let Err(e) = state.save() {
7384 tracing::warn!("could not persist a seat's completion: {e:#}");
7385 }
7386 if collected.len() <= i {
7387 collected.resize_with(i + 1, || None);
7388 }
7389 collected[i] = Some((i, seat, out));
7390 }
7391 if state
7397 .active
7398 .values()
7399 .any(|a| a.node == node && a.attempt == attempt)
7400 {
7401 state
7402 .active
7403 .retain(|_, a| !(a.node == node && a.attempt == attempt));
7404 if let Err(e) = state.save() {
7405 tracing::warn!("could not persist the end of a wave: {e:#}");
7406 }
7407 }
7408 if let Some(cache_dir) = cache
7415 && jobs_had_a_writer
7416 {
7417 crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7418 }
7419 if let Some(guard) = cache_guard {
7420 guard.release();
7421 }
7422 collected.into_iter().flatten().collect()
7423}
7424
7425fn record_jobs(
7436 state: &mut RunState,
7437 node: &str,
7438 round: Option<usize>,
7439 seat: &str,
7440 out: &AgentOutcome,
7441) {
7442 let commands: &[agent::CommandEvidence] = match out {
7443 AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7444 AgentOutcome::Failed(_) => &[],
7445 };
7446 let checked_at = Timestamp::now();
7447 for c in commands {
7448 state.jobs.push(JobRecord {
7449 node: node.to_owned(),
7450 round,
7451 seat: seat.to_owned(),
7452 id: c.id.clone(),
7453 description: c.description.clone(),
7454 checked_at,
7455 status: match c.exit_code {
7456 Some(0) => JobStatus::Completed,
7457 Some(_) => JobStatus::Failed,
7458 None => JobStatus::Unknown,
7459 },
7460 exit_code: c.exit_code,
7461 result_summary: c.result_summary.clone(),
7462 source: c.source.clone(),
7463 });
7464 }
7465}
7466
7467fn round_is_clean(
7488 blocking: usize,
7489 e2e_ok: bool,
7490 answered: usize,
7491 expected: usize,
7492 quota_missing: usize,
7493 policy: IncompleteReviewPolicy,
7494) -> bool {
7495 if blocking != 0 || !e2e_ok {
7496 return false;
7497 }
7498 if answered == expected || policy == IncompleteReviewPolicy::Warn {
7499 return true;
7500 }
7501 answered > 0 && expected - answered <= quota_missing
7502}
7503
7504fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7528 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7529 return Some(RunStatus::Gating);
7530 }
7531 let last = reviews.last()?;
7532 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7533 if reviews.len() < max_rounds && !stagnant {
7534 return None;
7535 }
7536 if last.incomplete() && last.blocking == 0 {
7537 return Some(RunStatus::Blocked);
7538 }
7539 if last.e2e_status() == E2eStatus::ResourceBlocked {
7540 return None;
7541 }
7542 Some(if last.e2e.iter().all(CommandOutcome::ok) {
7543 RunStatus::Gating
7544 } else {
7545 RunStatus::Blocked
7546 })
7547}
7548
7549pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7564 if nudged {
7565 (full / 4).max(Duration::from_secs(120)).min(full)
7566 } else {
7567 full
7568 }
7569}
7570
7571#[allow(clippy::too_many_arguments)]
7600async fn ask_json_wave<T>(
7601 jobs: Vec<SeatJob>,
7602 sem: Arc<Semaphore>,
7603 retries: usize,
7604 roster: &[AgentSpec],
7605 ctx: &WaveCtx<'_>,
7606 losses: &mut Vec<QuotaLoss>,
7607 state: &mut RunState,
7608 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7609) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7610where
7611 T: serde::de::DeserializeOwned + Send + 'static,
7612{
7613 ask_wave_with(
7614 jobs,
7615 sem,
7616 retries,
7617 roster,
7618 ctx,
7619 losses,
7620 state,
7621 &|text: &str| {
7622 let v = verdict::extract_json::<T>(text)?;
7623 validate(&v)?;
7624 Ok(v)
7625 },
7626 )
7627 .await
7628}
7629
7630#[allow(clippy::too_many_arguments)]
7639async fn ask_wave_with<T>(
7640 jobs: Vec<SeatJob>,
7641 sem: Arc<Semaphore>,
7642 retries: usize,
7643 roster: &[AgentSpec],
7644 ctx: &WaveCtx<'_>,
7645 losses: &mut Vec<QuotaLoss>,
7646 state: &mut RunState,
7647 parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7648) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7649where
7650 T: Send + 'static,
7651{
7652 let n = jobs.len();
7653 let originals: Vec<SeatJob> = jobs;
7654 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7655 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7656 let mut nudges: Vec<usize> = vec![0; n];
7663 let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7667 let mut tried: Vec<BTreeSet<String>> = specs
7668 .iter()
7669 .map(|s| BTreeSet::from([s.id.clone()]))
7670 .collect();
7671 let starts: Vec<usize> = specs
7672 .iter()
7673 .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7674 .collect();
7675 let carry = ctx.carry_seats && !roster.is_empty();
7676 let carried: Vec<BTreeSet<String>> = originals
7679 .iter()
7680 .map(|j| {
7681 state
7682 .seat_history
7683 .get(&j.seat.key)
7684 .filter(|_| carry)
7685 .map(|h| h.failed.clone())
7686 .unwrap_or_default()
7687 })
7688 .collect();
7689 let mut prev: Vec<Option<FailClass>> = originals
7690 .iter()
7691 .map(|j| {
7692 state
7693 .seat_history
7694 .get(&j.seat.key)
7695 .filter(|_| carry)
7696 .and_then(|h| h.last_fail.clone())
7697 })
7698 .collect();
7699 let next_agent =
7700 |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7701 let others: BTreeSet<String> = specs
7702 .iter()
7703 .enumerate()
7704 .filter(|(j, _)| *j != i)
7705 .map(|(_, s)| s.id.clone())
7706 .collect();
7707 pick_successor(
7708 roster,
7709 starts[i],
7710 tried,
7711 carry.then(|| &carried[i]),
7712 &others,
7713 )
7714 .cloned()
7715 };
7716 let mut fresh: Vec<Option<String>> = vec![None; n];
7717 let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7718 let mut pending: Vec<usize> = (0..n).collect();
7719
7720 let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7725 for round in 0..max_rounds {
7726 if pending.is_empty() {
7727 break;
7728 }
7729 let mut batch = Vec::with_capacity(pending.len());
7730 let mut renudged: Vec<&str> = Vec::new();
7731 for &i in &pending {
7732 let src = &originals[i];
7733 let full: &str = match &src.handover {
7738 Some(h) if specs[i].id != src.spec.id => h,
7739 _ => &src.prompt,
7740 };
7741 let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7744 (
7745 full.to_owned(),
7746 src.timeout,
7747 format!("{}-{word}-{}", src.stem, specs[i].id),
7748 )
7749 } else if nudges[i] == 0 {
7750 (full.to_owned(), src.timeout, src.stem.clone())
7751 } else {
7752 renudged.push(src.seat.key.as_str());
7753 let why = done[i]
7754 .as_ref()
7755 .and_then(|r| r.as_ref().err().map(ToString::to_string))
7756 .unwrap_or_else(|| "no parsable answer".to_owned());
7757 let nudge = prompt::nudge(&why);
7758 let nudged = has_context(&specs[i], &seats[i], src.sessions);
7759 let prompt = if nudged {
7760 nudge
7761 } else {
7762 format!("{full}\n\n---\n\n{nudge}")
7763 };
7764 (
7765 prompt,
7766 retry_budget(src.timeout, nudged),
7767 format!("{}-retry{}", src.stem, nudges[i]),
7768 )
7769 };
7770 batch.push(SeatJob {
7771 spec: specs[i].clone(),
7772 seat: seats[i].clone(),
7773 cwd: src.cwd.clone(),
7774 prompt,
7775 timeout,
7776 allow_write: src.allow_write,
7777 sessions: src.sessions,
7778 artifacts: src.artifacts.clone(),
7779 stem,
7780 handover: None,
7781 });
7782 }
7783
7784 if !renudged.is_empty() {
7785 state.event(
7786 ctx.node,
7787 format!("retry {round}: re-asking {}", renudged.join(", ")),
7788 );
7789 }
7790 let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7791 let mut still = Vec::new();
7792 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7793 seats[i] = seat;
7794 let class = FailClass::of(&out);
7795 let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7798 && nudges[i] < retries
7799 && !roster.is_empty();
7800 if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7801 let next = should_hand_over(prev[i].as_ref(), &cur)
7802 .then(|| next_agent(i, &tried[i], &specs))
7803 .flatten();
7804 if carry {
7805 let h = state
7806 .seat_history
7807 .entry(originals[i].seat.key.clone())
7808 .or_default();
7809 h.failed.insert(specs[i].id.clone());
7810 h.last_fail = Some(cur.clone());
7811 if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7812 h.last_ok = None;
7813 }
7814 if let Err(e) = state.save() {
7817 tracing::warn!("could not persist a seat's failure history: {e:#}");
7818 }
7819 }
7820 if let Some(next) = next {
7821 record_handover(
7822 state,
7823 ctx.node,
7824 &originals[i].seat.key,
7825 &specs[i].id,
7826 &next.id,
7827 &cur,
7828 &fail_reason(&out),
7829 );
7830 tried[i].insert(next.id.clone());
7831 prev[i] = Some(cur.clone());
7832 seats[i] =
7833 handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7834 specs[i] = next;
7835 fresh[i] = Some(cur.stem_word().to_owned());
7836 nudges[i] = 0;
7837 done[i] = Some(Err(anyhow::anyhow!(
7838 "handed over after: {}",
7839 fail_reason(&out)
7840 )));
7841 still.push(i);
7842 continue;
7843 }
7844 }
7845 if carry
7846 && !nudge_first
7847 && let Some(cur) = class.clone()
7848 {
7849 let h = state
7851 .seat_history
7852 .entry(originals[i].seat.key.clone())
7853 .or_default();
7854 h.failed.insert(specs[i].id.clone());
7855 h.last_fail = Some(cur);
7856 }
7857 let parsed = match out {
7858 AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7859 AgentOutcome::Quota(o) => {
7860 last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7861 Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7862 }
7863 AgentOutcome::Dropped(o) => {
7867 let why = o
7868 .dropped
7869 .as_ref()
7870 .map(|d| d.why.as_str())
7871 .unwrap_or("the CLI ended the stream without delivering its answer");
7872 Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7873 }
7874 AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7875 };
7876 let quota = class == Some(FailClass::Quota);
7877 let failed = parsed.is_err();
7878 done[i] = Some(parsed);
7879 if carry && !failed {
7880 let h = state
7881 .seat_history
7882 .entry(originals[i].seat.key.clone())
7883 .or_default();
7884 h.failed.remove(&specs[i].id);
7885 h.last_ok = Some(specs[i].id.clone());
7886 h.last_fail = None;
7887 }
7888 let agent_failure = class.is_some()
7896 && !nudge_first
7897 && !roster.is_empty()
7898 && next_agent(i, &tried[i], &specs).is_some();
7899 if failed && !quota && !agent_failure && nudges[i] < retries {
7900 nudges[i] += 1;
7901 still.push(i);
7902 }
7903 }
7904 pending = still;
7905 }
7906
7907 for (i, q) in last_quota.into_iter().enumerate() {
7910 if let Some(reset) = q {
7911 losses.push(QuotaLoss {
7912 seat: originals[i].seat.key.clone(),
7913 node: ctx.node.to_owned(),
7914 at: Timestamp::now(),
7915 reset,
7916 });
7917 }
7918 }
7919
7920 seats
7921 .into_iter()
7922 .zip(done)
7923 .zip(nudges)
7924 .map(|((seat, res), attempts)| {
7925 (
7926 seat,
7927 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7928 attempts,
7929 )
7930 })
7931 .collect()
7932}
7933
7934async fn acquire_cache_lease(
7947 state: &mut RunState,
7948 cache_dir: &Path,
7949 owner: &crate::cache::Owner,
7950 budget: Duration,
7951 context: &str,
7952) -> Result<crate::cache::Guard> {
7953 let home = crate::run::home();
7954 let started = Instant::now();
7955 let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7956 Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7957 Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7958 Err(e) => {
7959 state.event(
7960 "verify",
7961 format!("{context}: could not check the shared build cache: {e:#}"),
7962 );
7963 if let Err(e2) = state.save() {
7964 tracing::warn!("could not persist a cache-check failure: {e2:#}");
7965 }
7966 return Err(e);
7967 }
7968 };
7969 state.event(
7970 "verify",
7971 format!(
7972 "{context}: waiting for the shared build cache at {} ({})",
7973 cache_dir.display(),
7974 busy.describe()
7975 ),
7976 );
7977 if let Err(e) = state.save() {
7978 tracing::warn!("could not persist a cache wait: {e:#}");
7979 }
7980 let remaining = budget.saturating_sub(started.elapsed());
7981 match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7982 Ok(g) => Ok(g),
7983 Err(e) => {
7984 state.event("verify", format!("{context}: {e:#}"));
7985 if let Err(e2) = state.save() {
7986 tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7987 }
7988 Err(e)
7989 }
7990 }
7991}
7992
7993#[allow(clippy::too_many_arguments)]
8014async fn with_cache_lease<'s, F, Fut>(
8015 state: &'s mut RunState,
8016 cache_dir: Option<&Path>,
8017 node: &str,
8018 seat: &str,
8019 worktree: &Path,
8020 head: &str,
8021 budget: Duration,
8022 context: &str,
8023 body: F,
8024) -> (Vec<CommandOutcome>, bool)
8025where
8026 F: FnOnce(&'s mut RunState, Duration) -> Fut,
8027 Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8028{
8029 let Some(cache_dir) = cache_dir else {
8030 let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8031 return (outcomes, retried);
8032 };
8033 let home = crate::run::home();
8034 let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8035 let started = Instant::now();
8036 let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8037 Ok(g) => g,
8038 Err(e) => {
8039 return (
8040 vec![CommandOutcome {
8041 command: "(waiting for the shared build cache)".to_owned(),
8042 code: None,
8043 output_tail: e.to_string(),
8044 duration_ms: started.elapsed().as_millis() as u64,
8045 resource_blocked: true,
8046 }],
8047 false,
8048 );
8049 }
8050 };
8051 let identity = crate::cache::Identity::new(worktree, head);
8052 if let Err(e) = crate::cache::ensure_fresh(
8053 &home,
8054 cache_dir,
8055 &identity,
8056 &state.config.verify.cargo_manifests(),
8057 ) {
8058 state.event(
8066 "verify",
8067 format!(
8068 "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8069 worktree.display(),
8070 short(head)
8071 ),
8072 );
8073 guard.release();
8074 return (
8075 vec![CommandOutcome {
8076 command: "(confirming the shared build cache is fresh)".to_owned(),
8077 code: None,
8078 output_tail: e.to_string(),
8079 duration_ms: started.elapsed().as_millis() as u64,
8080 resource_blocked: true,
8081 }],
8082 false,
8083 );
8084 }
8085 let remaining = budget.saturating_sub(started.elapsed());
8086 let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8087 if !timed_out_pids.is_empty() {
8092 wait_for_timed_out_children_to_die(&timed_out_pids).await;
8093 }
8094 guard.release();
8095 (outcomes, retried)
8096}
8097
8098async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8110 wait_for_pids_with(
8111 pids,
8112 crate::proc::pid_alive,
8113 LEASE_RELEASE_POLL,
8114 LEASE_RELEASE_MAX_WAIT,
8115 )
8116 .await;
8117}
8118
8119async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8125 pids: &[u32],
8126 alive: F,
8127 poll: Duration,
8128 max_wait: Duration,
8129) {
8130 let deadline = Instant::now() + max_wait;
8131 loop {
8132 if pids.iter().all(|&pid| !alive(pid)) {
8133 return;
8134 }
8135 if Instant::now() >= deadline {
8136 return;
8137 }
8138 tokio::time::sleep(poll).await;
8139 }
8140}
8141
8142fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8149 outcomes.iter().any(|o| o.resource_blocked)
8150}
8151
8152enum GateFix {
8154 Retry,
8156 Stop,
8159 Defer,
8162}
8163
8164fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8172 let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8173 red.peek().is_some()
8174 && red.all(|o| {
8175 !o.resource_blocked
8176 && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8177 && !o.output_tail.trim().is_empty()
8178 })
8179}
8180
8181fn e2e_outcome_label(o: &CommandOutcome) -> String {
8185 if o.ok() {
8186 return "pass".to_owned();
8187 }
8188 let reason = if o.build_failed() {
8189 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8190 } else {
8191 format!("FAIL ({:?})", o.code)
8192 };
8193 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8194}
8195
8196async fn run_e2e_with_retry(
8204 state: &mut RunState,
8205 shell: &[String],
8206 commands: &[String],
8207 worktree: &Path,
8208 timeout: Duration,
8209 context: &str,
8210) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8211 let (mut e2e, mut timed_out_pids) = run_commands(
8212 state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8213 )
8214 .await;
8215 for o in &e2e {
8216 state.event(
8217 "verify",
8218 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8219 );
8220 }
8221 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8225 if verify_retried {
8226 state.event(
8227 "verify",
8228 format!(
8229 "{context}: verify could not build/link, not a test result — retrying once \
8230 before concluding"
8231 ),
8232 );
8233 let retried = run_commands(
8234 state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8235 )
8236 .await;
8237 e2e = retried.0;
8238 timed_out_pids.extend(retried.1);
8241 for o in &e2e {
8242 state.event(
8243 "verify",
8244 format!(
8245 "{context}: retry `{}` -> {}",
8246 o.command,
8247 e2e_outcome_label(o)
8248 ),
8249 );
8250 }
8251 }
8252 (e2e, verify_retried, timed_out_pids)
8253}
8254
8255#[allow(clippy::too_many_arguments)]
8271async fn run_commands(
8272 state: &mut RunState,
8273 node: &str,
8274 task: &str,
8275 attempt: usize,
8276 shell: &[String],
8277 commands: &[String],
8278 cwd: &Path,
8279 timeout: Duration,
8280) -> (Vec<CommandOutcome>, Vec<u32>) {
8281 if commands.is_empty() {
8282 return (Vec::new(), Vec::new());
8287 }
8288 let mut out = Vec::new();
8289 let mut timed_out_pids = Vec::new();
8290 let total = commands.len();
8291 for (idx, command) in commands.iter().enumerate() {
8292 state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8293 if let Err(e) = state.save() {
8294 tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8295 }
8296 let started = Instant::now();
8297 let mut cmd = tokio::process::Command::new(&shell[0]);
8298 cmd.quiet();
8299 cmd.args(&shell[1..])
8300 .arg(command)
8301 .current_dir(cwd)
8302 .stdin(std::process::Stdio::null())
8303 .stdout(std::process::Stdio::piped())
8304 .stderr(std::process::Stdio::piped())
8305 .kill_on_drop(true);
8306 let spawned = cmd.spawn();
8307 let (code, body) = match spawned {
8308 Ok(child) => {
8309 let pid = child.id();
8314 match tokio::time::timeout(timeout, child.wait_with_output()).await {
8315 Ok(Ok(o)) => {
8316 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8317 body.push_str(&String::from_utf8_lossy(&o.stderr));
8318 (o.status.code(), body)
8319 }
8320 Ok(Err(e)) => (None, format!("failed to run: {e}")),
8321 Err(_) => {
8322 if let Some(pid) = pid {
8323 timed_out_pids.push(pid);
8324 }
8325 (None, format!("timed out after {}s", timeout.as_secs()))
8326 }
8327 }
8328 }
8329 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8330 };
8331 out.push(CommandOutcome {
8332 command: command.clone(),
8333 code,
8334 output_tail: tail(&body, OUTPUT_TAIL),
8335 duration_ms: started.elapsed().as_millis() as u64,
8336 resource_blocked: false,
8337 });
8338 }
8339 state.task_finished(task);
8340 if let Err(e) = state.save() {
8341 tracing::warn!("could not persist the end of {task}: {e:#}");
8342 }
8343 (out, timed_out_pids)
8344}
8345
8346fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8358 let repo = repo.display();
8359 match style {
8360 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8361 MergeStyle::Squash => {
8362 let subject = message
8365 .lines()
8366 .next()
8367 .unwrap_or(branch)
8368 .replace(['\\', '"', '$', '`'], "");
8369 format!(
8370 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8371 )
8372 }
8373 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8374 }
8375}
8376
8377const PR_TITLE_MAX: usize = 240;
8388
8389fn english_title(text: &str, max: usize) -> Option<String> {
8400 let line = queue::first_line(text)?;
8401 let chars: Vec<char> = line.chars().collect();
8402 let (kept, mark) = if chars.len() <= max {
8403 (line.to_owned(), "")
8404 } else if let Some(end) = sentence_end(&chars, max) {
8405 (chars[..end].iter().collect::<String>(), "")
8406 } else {
8407 let room = max.saturating_sub(3);
8408 let cut = if chars[room].is_whitespace() {
8411 room
8412 } else {
8413 chars[..room]
8414 .iter()
8415 .rposition(|c| c.is_whitespace())
8416 .unwrap_or(room)
8417 };
8418 let head: String = chars[..cut].iter().collect();
8419 let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8420 (head.to_owned(), "...")
8421 };
8422 if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8423 return None;
8424 }
8425 Some(format!("{kept}{mark}"))
8426}
8427
8428fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8432 const MIN: usize = 20;
8433 for i in MIN..max.min(chars.len()) {
8434 if !matches!(chars[i], '.' | '!' | '?') {
8435 continue;
8436 }
8437 let Some(&next) = chars.get(i + 1) else {
8438 continue;
8439 };
8440 if !next.is_whitespace() {
8441 continue;
8442 }
8443 let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8444 if after.is_some_and(|c| c.is_ascii_lowercase()) {
8445 continue;
8446 }
8447 let word: String = chars[..i]
8448 .iter()
8449 .rev()
8450 .take_while(|c| !c.is_whitespace())
8451 .collect::<Vec<_>>()
8452 .into_iter()
8453 .rev()
8454 .collect();
8455 let word = word.to_ascii_lowercase();
8456 if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8457 continue;
8458 }
8459 let end = chars[..i]
8460 .iter()
8461 .rposition(|c| !c.is_whitespace())
8462 .map_or(i, |p| p + 1);
8463 return Some(end);
8464 }
8465 None
8466}
8467
8468struct PrMessage {
8473 title: String,
8474 body: String,
8475}
8476
8477impl PrMessage {
8478 fn commit_message(&self) -> String {
8482 format!("{}\n\n{}", self.title, self.body)
8483 }
8484}
8485
8486fn title_marker(line: &str) -> Option<&str> {
8488 let line = line.trim();
8489 let head = line.get(..6)?;
8490 head.eq_ignore_ascii_case("title:")
8491 .then(|| line[6..].trim())
8492}
8493
8494fn summary_title(summary: &str) -> Option<String> {
8499 let first = summary.lines().find(|l| !l.trim().is_empty())?;
8500 let raw = title_marker(first)?;
8501 if raw.is_empty() {
8502 return None;
8503 }
8504 let title = queue::title_from(raw, PR_TITLE_MAX);
8505 let lower = title.to_ascii_lowercase();
8506 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8507 return None;
8508 }
8509 Some(title)
8510}
8511
8512const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8514
8515const EXISTING_BRANCH: &str = "(existing branch)";
8517
8518fn is_review_run(state: &RunState) -> bool {
8522 state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8523}
8524
8525fn review_title(state: &RunState) -> Option<String> {
8530 english_subject(state.reviewed_commits.as_ref()?.first()?)
8531}
8532
8533fn english_subject(raw: &str) -> Option<String> {
8536 let raw = raw.trim();
8537 if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8538 return None;
8539 }
8540 let title = queue::title_from(raw, PR_TITLE_MAX);
8541 let lower = title.to_ascii_lowercase();
8542 if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8543 return None;
8544 }
8545 Some(title)
8546}
8547
8548#[derive(Debug, Clone, PartialEq, Eq)]
8551struct BranchFacts {
8552 commits: Vec<(String, String)>,
8554 stat: String,
8556}
8557
8558const STAT_FILE_LINES: usize = 25;
8560const COMMITS_MAX_CHARS: usize = 20_000;
8562
8563async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8566 let commits = git::commit_log(repo, base, branch).await.ok()?;
8567 if commits.is_empty() {
8568 return None;
8569 }
8570 let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8571 let lines: Vec<&str> = stat.lines().collect();
8572 let stat = if lines.len() > STAT_FILE_LINES + 1 {
8573 let omitted = lines.len() - 1 - STAT_FILE_LINES;
8574 let more = format!(" ... {omitted} more file(s)");
8575 let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8576 kept.push(&more);
8577 kept.push(lines[lines.len() - 1]);
8578 kept.join("\n")
8579 } else {
8580 lines.join("\n")
8581 };
8582 Some(BranchFacts { commits, stat })
8583}
8584
8585fn markdown_safe(text: &str) -> String {
8588 text.replace("</details>", "</details>")
8589 .replace("\x60\x60\x60", "~~~")
8590}
8591
8592fn neutral_title(state: &RunState, winner: char) -> String {
8593 format!(
8594 "chore: land candidate {} of run {}",
8595 winner.to_ascii_uppercase(),
8596 state.id
8597 )
8598}
8599
8600pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8606 if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8607 ""
8608 } else {
8609 pr_title
8610 }
8611}
8612
8613pub fn landing_subject_source(state: &RunState) -> String {
8617 if is_review_run(state) {
8618 let winner = state.candidates.first().map_or('A', |c| c.label);
8619 return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8620 }
8621 state.instruction.clone()
8622}
8623
8624fn summary_without_title(summary: &str) -> String {
8627 let mut lines = summary.trim().lines().peekable();
8628 if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8629 lines.next();
8630 }
8631 lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8632}
8633
8634#[cfg(test)]
8648fn pr_message(state: &RunState, winner: char) -> PrMessage {
8649 pr_message_with(state, winner, None)
8650}
8651
8652#[cfg(test)]
8655fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8656 let raw = pr_message_raw(state, winner, facts);
8657 let id = crate::scrub::Identity::current();
8658 PrMessage {
8659 title: crate::scrub::scrub(&raw.title, &id),
8660 body: crate::scrub::scrub(&raw.body, &id),
8661 }
8662}
8663
8664fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8665 let summary = state
8666 .candidates
8667 .iter()
8668 .find(|c| c.label == winner)
8669 .map(|c| c.summary.as_str())
8670 .unwrap_or_default();
8671 let review = is_review_run(state);
8674 let title = if review {
8675 facts
8676 .and_then(|f| english_subject(&f.commits.first()?.0))
8677 .or_else(|| review_title(state))
8678 .or_else(|| {
8679 state
8680 .candidates
8681 .iter()
8682 .find(|c| c.label == winner)
8683 .filter(|c| !c.branch.starts_with("magi/"))
8684 .and_then(|c| english_subject(&c.branch))
8685 })
8686 .unwrap_or_else(|| neutral_title(state, winner))
8687 } else {
8688 summary_title(summary).unwrap_or_else(|| {
8689 english_title(&state.instruction, PR_TITLE_MAX)
8690 .unwrap_or_else(|| neutral_title(state, winner))
8691 })
8692 };
8693
8694 let mut body = String::new();
8695 let what = summary_without_title(summary);
8696 if !what.is_empty() {
8697 body.push_str("## Summary\n\n");
8698 body.push_str(&what);
8699 body.push_str("\n\n");
8700 }
8701
8702 let notes: Vec<(usize, &str)> = state
8705 .reviews
8706 .iter()
8707 .filter_map(|r| {
8708 let n = r.fix.as_ref()?.notes.trim();
8709 (!n.is_empty()).then_some((r.round, n))
8710 })
8711 .collect();
8712 if !notes.is_empty() {
8713 body.push_str("## Review fixes\n\n");
8714 if let [(_, only)] = notes.as_slice() {
8715 body.push_str(only);
8716 body.push_str("\n\n");
8717 } else {
8718 for (round, n) in ¬es {
8719 body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8720 }
8721 }
8722 }
8723 let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8724
8725 let open = state.open_findings();
8726 if !open.is_empty() {
8727 body.push_str("## Open review findings\n\n");
8728 for f in &open {
8729 body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8730 }
8731 body.push('\n');
8732 }
8733
8734 if let Some(fix) = fix
8735 && !fix.rejected.is_empty()
8736 {
8737 body.push_str("## Declined by the fixer\n\n");
8738 for r in &fix.rejected {
8739 body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8740 }
8741 body.push('\n');
8742 }
8743
8744 if review {
8745 body.push_str("## Commits under review\n\n");
8747 if let Some(facts) = facts {
8748 let mut left = COMMITS_MAX_CHARS;
8749 for (i, (subject, text)) in facts.commits.iter().enumerate() {
8750 let mut entry = format!("- {}\n", markdown_safe(subject));
8751 for l in markdown_safe(text).lines() {
8752 entry.push_str(format!(" {l}\n").trim_end_matches(' '));
8753 }
8754 if left == 0 {
8755 body.push_str(&format!(
8756 "- ... {} more commit(s)\n",
8757 facts.commits.len() - i
8758 ));
8759 break;
8760 }
8761 if entry.len() > left {
8762 let mut end = left;
8765 while !entry.is_char_boundary(end) {
8766 end -= 1;
8767 }
8768 entry.truncate(end);
8769 entry.push_str("\n ... (truncated)\n");
8770 left = 0;
8771 } else {
8772 left -= entry.len();
8773 }
8774 body.push_str(&entry);
8775 }
8776 if !facts.stat.trim().is_empty() {
8777 body.push_str(&format!(
8778 "\n## Diff stat\n\n```\n{}\n```\n",
8779 markdown_safe(facts.stat.trim())
8780 ));
8781 }
8782 } else {
8783 match &state.reviewed_commits {
8784 Some(subjects) => {
8785 for s in subjects {
8786 body.push_str(&format!("- {}\n", s.trim()));
8787 }
8788 }
8789 None => {
8790 let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8793 body.push_str(rest.trim());
8794 body.push('\n');
8795 }
8796 }
8797 }
8798 } else {
8799 let task = state.instruction.trim();
8800 let task = if task.is_empty() {
8801 "(empty task)"
8802 } else {
8803 task
8804 };
8805 body.push_str(&format!(
8806 "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8807 task.replace("</details>", "</details>")
8808 ));
8809 }
8810
8811 body.push_str(&format!(
8812 "\n---\nmagi:run/{} magi:candidate-{}\n",
8813 state.id,
8814 winner.to_ascii_lowercase()
8815 ));
8816
8817 PrMessage { title, body }
8818}
8819
8820fn seeded_instruction(state: &RunState) -> String {
8824 match refs::describe(&state.seeds) {
8825 Some(facts) => format!(
8826 "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8827 Candidates start from the unmerged branch named above, when there \
8828 is one, and carry any unmerged commit named by sha as a \
8829 cherry-pick. Check that this is what the task meant before \
8830 building on it.",
8831 state.instruction
8832 ),
8833 None => state.instruction.clone(),
8834 }
8835}
8836
8837async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8841 let base = &state.base_branch;
8842 let mut against = base.clone();
8843 if mode == MergeMode::Pr {
8844 let remote = &state.config.merge.remote;
8848 let tracking = format!("{remote}/{base}");
8849 let fetched = git::fetch(repo, remote, base).await;
8850 if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8851 against = tracking;
8852 } else {
8853 return false;
8854 }
8855 }
8856 matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8857}
8858
8859fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8862 let mut detail = format!(
8863 "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8864 nothing to open a pull request for"
8865 );
8866 match refs::describe(&state.seeds) {
8867 Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8868 None => detail.push_str(
8869 "\nThe task names no existing branch or commit; if it means to land work \
8870 that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8871 ),
8872 }
8873 detail
8874}
8875
8876#[derive(Debug, PartialEq, Eq)]
8879enum PrPlan {
8880 Create,
8881 Adopt { url: String, title: String },
8882 Stop(String),
8883}
8884
8885fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8888 match found {
8889 Ok(land::OpenPr::None) => PrPlan::Create,
8890 Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8891 Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8892 "several open pull requests exist for this branch, not picking one: {}",
8893 urls.join(" ")
8894 )),
8895 Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8896 }
8897}
8898
8899async fn gh_pr_create(
8901 cwd: &Path,
8902 base: &str,
8903 head: &str,
8904 title: &str,
8905 body: &str,
8906) -> Result<String> {
8907 let out = tokio::process::Command::new("gh")
8908 .args([
8909 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8910 ])
8911 .current_dir(cwd)
8912 .quiet()
8913 .stdin(std::process::Stdio::null())
8914 .output()
8915 .await
8916 .context("spawn gh")?;
8917 if out.status.success() {
8918 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8919 } else {
8920 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8921 }
8922}
8923
8924pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8933 let repo = state.repo.clone();
8934 let root = state.worktree_root();
8935 let winner = state.tally.as_ref().map(|t| t.winner);
8936 let mut removed = Vec::new();
8937
8938 for i in 0..state.candidates.len() {
8939 let c = state.candidates[i].clone();
8940 let is_winner = Some(c.label) == winner;
8941 if is_winner && !drop_winner {
8942 continue;
8943 }
8944 if c.worktree.exists() {
8945 git::worktree_remove(&repo, &c.worktree).await.ok();
8946 removed.push(c.worktree.to_string_lossy().into_owned());
8947 }
8948 let handed_over = state.released_branches.contains(&c.branch);
8951 if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8952 git::branch_delete(&repo, &c.branch).await.ok();
8953 removed.push(c.branch.clone());
8954 }
8955 state.candidates[i].folded = true;
8956 }
8957
8958 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8959 let path = name.path();
8960 let keep = !drop_winner
8961 && winner.is_some_and(|w| {
8962 path.file_name()
8963 .is_some_and(|n| n == format!("cand-{w}").as_str())
8964 });
8965 if keep {
8966 continue;
8967 }
8968 git::worktree_remove(&repo, &path).await.ok();
8969 removed.push(path.to_string_lossy().into_owned());
8970 }
8971
8972 remove_if_empty(&root);
8981
8982 if state.enabled_worktree_config && drop_winner {
8983 git::release_worktree_config(&repo).await.ok();
8987 state.enabled_worktree_config = false;
8988 }
8989 state.save_under(home)?;
8990 Ok(removed)
8991}
8992
8993fn remove_if_empty(dir: &Path) {
9004 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
9005 std::fs::remove_dir(dir).ok();
9006 }
9007}
9008
9009pub fn worst_open(state: &RunState) -> Option<Severity> {
9011 state
9012 .reviews
9013 .last()?
9014 .reviews
9015 .iter()
9016 .flat_map(|r| r.findings.iter())
9017 .map(|f| f.severity)
9018 .max()
9019}
9020
9021async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9025 if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9026 git::acquire_worktree_config(repo).await?;
9027 state.enabled_worktree_config = true;
9028 }
9029 Ok(())
9030}
9031
9032#[cfg(test)]
9033mod tests {
9034 #[test]
9035 fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9036 let leaked = vec!["chore(deps): update a crate".to_owned()];
9037 let own = "fix(daemon): apply a chosen action";
9038 assert!(should_retitle("", own, &leaked));
9039 assert!(should_retitle(
9040 &format!("{REVIEW_PROMPT_OPENING} `x`"),
9041 own,
9042 &leaked
9043 ));
9044 assert!(should_retitle(
9045 "chore: land candidate A of run 1",
9046 own,
9047 &leaked
9048 ));
9049 assert!(should_retitle(
9050 "magi: candidate A (uncommitted work)",
9051 own,
9052 &leaked
9053 ));
9054 assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9055 assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9056 assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9057 }
9058
9059 #[test]
9060 fn pr_merge_plan_creates_adopts_or_stops() {
9061 assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9062 assert_eq!(
9063 pr_merge_plan(Ok(land::OpenPr::One {
9064 url: "u".into(),
9065 title: "t".into()
9066 })),
9067 PrPlan::Adopt {
9068 url: "u".into(),
9069 title: "t".into()
9070 }
9071 );
9072 let PrPlan::Stop(many) =
9073 pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9074 else {
9075 panic!("many must stop");
9076 };
9077 assert!(many.contains('a') && many.contains('b'));
9078 let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9079 panic!("a failed lookup must stop");
9080 };
9081 assert!(err.contains("bad token"));
9082 }
9083
9084 use super::*;
9085 use crate::run::GateStatus;
9086 use std::collections::BTreeMap;
9087 use std::time::Duration;
9088
9089 fn conductor() -> AgentSpec {
9090 AgentSpec {
9091 id: "conductor".to_owned(),
9092 kind: crate::config::AgentKind::Command,
9093 model: None,
9094 command: vec!["true".to_owned()],
9095 extra_args: Vec::new(),
9096 env: BTreeMap::new(),
9097 prompt_delivery: None,
9098 }
9099 }
9100
9101 fn spec(id: &str) -> AgentSpec {
9102 AgentSpec {
9103 id: id.to_owned(),
9104 kind: crate::config::AgentKind::Command,
9105 model: None,
9106 command: vec!["true".to_owned()],
9107 extra_args: Vec::new(),
9108 env: BTreeMap::new(),
9109 prompt_delivery: None,
9110 }
9111 }
9112
9113 fn ids(xs: &[&str]) -> BTreeSet<String> {
9114 xs.iter().map(|s| (*s).to_owned()).collect()
9115 }
9116
9117 #[test]
9118 fn pick_successor_skips_an_agent_another_seat_holds() {
9119 let roster = [spec("a"), spec("b"), spec("c")];
9121 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9122 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9123 }
9124
9125 #[test]
9126 fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9127 let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9129 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9130 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9131 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9132 assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9133 }
9134
9135 #[test]
9136 fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9137 let roster = [spec("a"), spec("b")];
9138 let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9139 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9140 let carried = ids(&["b"]);
9141 let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9142 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9143 }
9144
9145 #[test]
9146 fn pick_successor_returns_none_without_any_untried_successor() {
9147 let roster = [spec("a"), spec("b")];
9148 assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9149 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9150 let carried = ids(&["a"]);
9151 assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9152 }
9153
9154 #[test]
9155 fn pick_successor_rescue_avoids_another_seats_occupant() {
9156 let roster = [spec("a"), spec("b"), spec("c")];
9158 let carried = ids(&["b", "c"]);
9159 let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9160 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9161 }
9162
9163 #[test]
9164 fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9165 let roster = [spec("a"), spec("b"), spec("c")];
9166 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9167 assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9168 }
9169
9170 #[test]
9171 fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9172 let roster = [spec("a"), spec("b"), spec("c")];
9173 let failed = ids(&["a", "b", "c"]);
9174 let mut tried = ids(&["b"]);
9176 let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9177 assert_eq!(first.id, "a");
9178 tried.insert(first.id.clone());
9179 let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9180 assert_eq!(second.id, "c");
9181 tried.insert(second.id.clone());
9182 assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9183 }
9184
9185 #[test]
9186 fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9187 let roster = [spec("a"), spec("b")];
9188 let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9189 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9190 }
9191
9192 #[test]
9193 fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9194 let roster = [spec("a"), spec("b"), spec("c")];
9195 let h = SeatHistory {
9196 failed: ids(&["a"]),
9197 last_ok: Some("b".to_owned()),
9198 last_fail: None,
9199 };
9200 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9201 let gone = SeatHistory {
9203 last_ok: Some("zzz".to_owned()),
9204 ..h.clone()
9205 };
9206 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9207 let failed = SeatHistory {
9208 failed: ids(&["a", "b"]),
9209 last_ok: Some("b".to_owned()),
9210 last_fail: None,
9211 };
9212 assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9213 }
9214
9215 #[test]
9216 fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9217 let first = SeatState::new("review-1", "alpha", 7);
9218 let next = handover_seat("review-1", "gamma", 7);
9219 assert_ne!(first.claude_session, next.claude_session);
9220 }
9221
9222 #[test]
9223 fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9224 let mut state = state_with_summary("x", "y");
9225 let a = handover_seat("review-1", "beta", state.next_seat_seed());
9226 let b = handover_seat("review-1", "beta", state.next_seat_seed());
9227 assert_ne!(a.claude_session, b.claude_session);
9228 }
9229
9230 #[test]
9231 fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9232 let roster = [spec("a"), spec("b")];
9233 let h = SeatHistory {
9234 failed: ids(&["a", "b"]),
9235 ..SeatHistory::default()
9236 };
9237 assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9238 assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9239 assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9240 }
9241
9242 #[test]
9249 fn failure_signature_ignores_numbers_and_paths() {
9250 assert_eq!(
9251 failure_signature("exited with Some(2) and no usable output"),
9252 failure_signature("exited with Some(137) and no usable output")
9253 );
9254 assert_eq!(
9255 failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9256 failure_signature("cannot open /var/x.txt: denied")
9257 );
9258 assert_ne!(failure_signature("boom"), failure_signature("bang"));
9259 }
9260
9261 #[test]
9262 fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9263 let other = FailClass::Other("x".into());
9264 assert!(should_hand_over(None, &FailClass::Quota));
9265 assert!(should_hand_over(Some(&other), &FailClass::Quota));
9266 assert!(should_hand_over(
9267 Some(&FailClass::Timeout),
9268 &FailClass::Timeout
9269 ));
9270 assert!(should_hand_over(None, &other));
9271 assert!(!should_hand_over(Some(&other), &other));
9272 assert!(should_hand_over(
9273 Some(&other),
9274 &FailClass::Other("y".into())
9275 ));
9276 assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9278 assert!(should_hand_over(Some(&FailClass::Quota), &other));
9279 }
9280
9281 #[test]
9282 fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9283 let a = SeatState::new("judge-1", "alpha", 7);
9284 let b = handover_seat("judge-1", "beta", 7);
9285 assert_ne!(a.claude_session, b.claude_session);
9286 assert_eq!(b.turns, 0);
9287 }
9288
9289 #[test]
9290 fn a_timeout_is_classified_apart_from_other_failures() {
9291 assert_eq!(
9292 FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9293 Some(FailClass::Timeout)
9294 );
9295 assert!(matches!(
9296 FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9297 Some(FailClass::Other(_))
9298 ));
9299 }
9300
9301 #[test]
9302 fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9303 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9304 let tried = BTreeSet::from(["beta".to_owned()]);
9305 let next = next_untried_in_roster(&roster, 1, &tried);
9308 assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9309 }
9310
9311 #[test]
9312 fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9313 let roster = vec![spec("alpha"), spec("beta")];
9314 let tried = BTreeSet::from(["beta".to_owned()]);
9315 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9319 }
9320
9321 #[test]
9322 fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9323 let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9324 let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9325 assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9329 }
9330
9331 #[test]
9332 fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9333 let roster = vec![spec("a"), spec("a"), spec("b")];
9334 let tried = BTreeSet::from(["a".to_owned()]);
9335 let next = next_untried_in_roster(&roster, 0, &tried);
9336 assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9337 }
9338
9339 #[test]
9340 fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9341 let roster = vec![spec("a"), spec("b")];
9342 let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9343 assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9344 }
9345
9346 #[test]
9347 fn remove_if_empty_only_ever_takes_a_bare_directory() {
9348 let dir = tempfile::tempdir().unwrap();
9349 let bay = dir.path().join("ffff");
9350
9351 remove_if_empty(&bay);
9353 assert!(!bay.exists());
9354
9355 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9358 remove_if_empty(&bay);
9359 assert!(bay.exists(), "non-empty directory must survive");
9360
9361 std::fs::remove_dir(bay.join("cand-A")).unwrap();
9363 remove_if_empty(&bay);
9364 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9365 }
9366
9367 #[test]
9376 fn a_full_panel_that_found_nothing_is_clean() {
9377 assert!(round_is_clean(
9378 0,
9379 true,
9380 2,
9381 2,
9382 0,
9383 IncompleteReviewPolicy::Block
9384 ));
9385 }
9386
9387 #[test]
9388 fn a_missing_seat_is_never_clean_under_the_default_policy() {
9389 assert!(!round_is_clean(
9390 0,
9391 true,
9392 1,
9393 2,
9394 0,
9395 IncompleteReviewPolicy::Block
9396 ));
9397 }
9398
9399 #[test]
9400 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9401 assert!(!round_is_clean(
9402 1,
9403 true,
9404 1,
9405 2,
9406 0,
9407 IncompleteReviewPolicy::Warn
9408 ));
9409 }
9410
9411 #[test]
9412 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9413 assert!(round_is_clean(
9414 0,
9415 true,
9416 1,
9417 2,
9418 0,
9419 IncompleteReviewPolicy::Warn
9420 ));
9421 }
9422
9423 #[test]
9424 fn a_full_panel_with_an_open_finding_is_not_clean() {
9425 assert!(!round_is_clean(
9426 1,
9427 true,
9428 2,
9429 2,
9430 0,
9431 IncompleteReviewPolicy::Block
9432 ));
9433 }
9434
9435 #[test]
9436 fn a_full_panel_with_a_red_e2e_is_not_clean() {
9437 assert!(!round_is_clean(
9438 0,
9439 false,
9440 2,
9441 2,
9442 0,
9443 IncompleteReviewPolicy::Block
9444 ));
9445 }
9446
9447 #[test]
9454 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9455 assert!(round_is_clean(
9458 0,
9459 true,
9460 1,
9461 2,
9462 1,
9463 IncompleteReviewPolicy::Block
9464 ));
9465 }
9466
9467 #[test]
9468 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9469 assert!(!round_is_clean(
9472 0,
9473 true,
9474 1,
9475 2,
9476 0,
9477 IncompleteReviewPolicy::Block
9478 ));
9479 }
9480
9481 #[test]
9482 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9483 assert!(!round_is_clean(
9484 1,
9485 true,
9486 1,
9487 2,
9488 1,
9489 IncompleteReviewPolicy::Block
9490 ));
9491 assert!(!round_is_clean(
9492 0,
9493 false,
9494 1,
9495 2,
9496 1,
9497 IncompleteReviewPolicy::Block
9498 ));
9499 }
9500
9501 #[test]
9502 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9503 assert!(!round_is_clean(
9507 0,
9508 true,
9509 0,
9510 2,
9511 2,
9512 IncompleteReviewPolicy::Block
9513 ));
9514 }
9515
9516 fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9517 CommandOutcome {
9518 command: "test".to_owned(),
9519 code,
9520 output_tail: String::new(),
9521 duration_ms: 0,
9522 resource_blocked,
9523 }
9524 }
9525
9526 #[test]
9527 fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9528 assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9529 assert!(
9530 !verify_inconclusive(&[outcome(Some(1), false)]),
9531 "an ordinary failure is still evidence about the patch"
9532 );
9533 assert!(verify_inconclusive(&[outcome(None, true)]));
9534 assert!(
9535 verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9536 "one inconclusive outcome taints the whole batch"
9537 );
9538 assert!(!verify_inconclusive(&[]));
9539 }
9540
9541 #[tokio::test]
9542 async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9543 let calls = std::sync::atomic::AtomicUsize::new(0);
9547 let started = Instant::now();
9548 wait_for_pids_with(
9549 &[123],
9550 |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9551 Duration::from_millis(5),
9552 Duration::from_secs(5),
9553 )
9554 .await;
9555 assert!(
9556 calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9557 "must keep checking rather than deciding on the first answer"
9558 );
9559 assert!(
9560 started.elapsed() < Duration::from_secs(1),
9561 "must return the moment it is confirmed dead, not wait out the ceiling"
9562 );
9563 }
9564
9565 #[tokio::test]
9566 async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9567 let started = Instant::now();
9568 wait_for_pids_with(
9569 &[123],
9570 |_| true, Duration::from_millis(5),
9572 Duration::from_millis(30),
9573 )
9574 .await;
9575 let elapsed = started.elapsed();
9576 assert!(
9577 elapsed >= Duration::from_millis(30),
9578 "must not give up before its own ceiling: {elapsed:?}"
9579 );
9580 assert!(
9581 elapsed < Duration::from_secs(1),
9582 "must not wait past its own ceiling either: {elapsed:?}"
9583 );
9584 }
9585
9586 #[tokio::test]
9587 async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9588 let started = Instant::now();
9589 wait_for_pids_with(
9590 &[],
9591 |_| true,
9592 Duration::from_secs(5),
9593 Duration::from_secs(5),
9594 )
9595 .await;
9596 assert!(
9597 started.elapsed() < Duration::from_millis(200),
9598 "an empty pid list has nothing to confirm"
9599 );
9600 }
9601
9602 fn review_round(
9608 clean: bool,
9609 blocking: usize,
9610 answered: usize,
9611 expected: usize,
9612 progressed: bool,
9613 e2e_ok: bool,
9614 ) -> ReviewRound {
9615 ReviewRound {
9616 round: 1,
9617 head: "h".to_owned(),
9618 verified_head: None,
9619 verified_at: None,
9620 reviews: Vec::new(),
9621 e2e: vec![CommandOutcome {
9622 command: "test".to_owned(),
9623 code: Some(if e2e_ok { 0 } else { 1 }),
9624 output_tail: String::new(),
9625 duration_ms: 0,
9626 resource_blocked: false,
9627 }],
9628 verify_retried: false,
9629 e2e_deferred: false,
9630 e2e_defer_reason: None,
9631 fix: None,
9632 blocking,
9633 answered,
9634 expected,
9635 clean,
9636 progressed,
9637 vote_split: false,
9638 reconsideration: Vec::new(),
9639 verdict: None,
9640 }
9641 }
9642
9643 #[test]
9644 fn review_conclusion_is_none_when_nothing_has_run() {
9645 assert_eq!(review_conclusion(&[], 3), None);
9646 }
9647
9648 #[test]
9649 fn review_conclusion_is_none_while_rounds_remain() {
9650 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9651 assert_eq!(review_conclusion(&rounds, 3), None);
9652 }
9653
9654 #[test]
9655 fn review_conclusion_is_gating_once_a_round_is_clean() {
9656 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9657 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9658 }
9659
9660 #[test]
9661 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9662 let rounds = vec![
9663 review_round(false, 1, 2, 2, true, true),
9664 review_round(false, 1, 2, 2, true, true),
9665 ];
9666 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9667 }
9668
9669 #[test]
9670 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9671 let rounds = vec![
9672 review_round(false, 1, 2, 2, true, true),
9673 review_round(false, 1, 2, 2, true, false),
9674 ];
9675 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9676 }
9677
9678 #[test]
9679 fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9680 let mut blocked = review_round(false, 1, 2, 2, true, false);
9687 blocked.e2e[0].resource_blocked = true;
9688 let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9689 assert_eq!(review_conclusion(&rounds, 2), None);
9690 }
9691
9692 #[test]
9693 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9694 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9696 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9697 }
9698
9699 #[test]
9700 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9701 let rounds = vec![
9702 review_round(false, 1, 2, 2, false, true),
9703 review_round(false, 1, 2, 2, false, true),
9704 ];
9705 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9706 }
9707
9708 fn secs(n: u64) -> Duration {
9709 Duration::from_secs(n)
9710 }
9711
9712 fn init_repo(dir: &Path) {
9715 let run = |args: &[&str]| {
9716 let out = std::process::Command::new("git")
9717 .args(args)
9718 .current_dir(dir)
9719 .quiet()
9720 .output()
9721 .expect("spawn git");
9722 assert!(
9723 out.status.success(),
9724 "git {args:?} failed: {}",
9725 String::from_utf8_lossy(&out.stderr)
9726 );
9727 };
9728 run(&["init", "-b", "main"]);
9729 run(&["config", "user.name", "magi test"]);
9730 run(&["config", "user.email", "magi@example.com"]);
9731 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9732 run(&["add", "-A"]);
9733 run(&["commit", "-m", "init"]);
9734 }
9735
9736 fn ask_test_home() {
9744 crate::run::pin_test_home();
9745 }
9746
9747 fn runner_at(status: RunStatus) -> Runner {
9750 let mut state = RunState::new(
9751 PathBuf::from("/nonexistent/repo"),
9752 "main".to_owned(),
9753 "deadbeef".to_owned(),
9754 "task".to_owned(),
9755 Config::default(),
9756 );
9757 state.status = status;
9758 Runner {
9759 state,
9760 roles: ResolvedRoles {
9761 implementers: Vec::new(),
9762 judges: Vec::new(),
9763 reviewers: Vec::new(),
9764 fixer: None,
9765 conductor: conductor(),
9766 implementer_roster: Vec::new(),
9767 judge_roster: Vec::new(),
9768 reviewer_roster: Vec::new(),
9769 },
9770 sem: Arc::new(Semaphore::new(1)),
9771 pause: Pause::new(),
9772 interrupt: Pause::new(),
9773 }
9774 }
9775
9776 #[test]
9780 fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9781 crate::run::pin_test_home();
9782 let mut runner = runner_at(RunStatus::Implementing);
9783 let interrupt = Pause::new();
9784 runner.watch_interrupt(interrupt.clone());
9785
9786 interrupt.park_because("task a1b2 asked to run first");
9787
9788 assert!(runner.park_here().expect("park_here"));
9789 assert!(runner.state.parked);
9790 let last = runner.state.events.last().expect("a park event");
9791 assert_eq!(last.node, "park");
9792 assert!(
9793 last.message.contains("task a1b2 asked to run first"),
9794 "expected the interrupt reason in {:?}",
9795 last.message
9796 );
9797 }
9798
9799 #[test]
9807 fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9808 crate::run::pin_test_home();
9809 let mut runner = runner_at(RunStatus::Implementing);
9810 let shutdown = Pause::new();
9811 runner.on_pause(shutdown.clone());
9812 let interrupt = Pause::new();
9813 runner.watch_interrupt(interrupt.clone());
9814
9815 assert!(!runner.park_here().expect("park_here"));
9817 assert!(!runner.state.parked);
9818
9819 interrupt.park_because("test");
9821 assert!(!shutdown.parked());
9822 assert!(runner.park_here().expect("park_here"));
9823 }
9824
9825 #[tokio::test]
9839 async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9840 crate::run::pin_test_home();
9841 let mut runner = runner_at(RunStatus::Implementing);
9842 let interrupt = Pause::new();
9843 runner.watch_interrupt(interrupt.clone());
9844
9845 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9846 let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9847
9848 let node = async move {
9852 started_tx.send(()).expect("send started");
9853 finish_rx.await.expect("recv finish");
9854 "node finished"
9855 };
9856
9857 let interrupter = async move {
9858 started_rx.await.expect("recv started");
9859 interrupt.park_because("higher-priority task waiting");
9861 tokio::task::yield_now().await;
9865 finish_tx.send(()).expect("send finish");
9866 };
9867
9868 let (node_result, ()) = tokio::join!(node, interrupter);
9869 assert_eq!(
9870 node_result, "node finished",
9871 "the in-flight call ran to completion"
9872 );
9873
9874 assert!(runner.park_here().expect("park_here"));
9877 assert!(runner.state.parked);
9878 }
9879
9880 #[test]
9886 fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9887 crate::run::pin_test_home();
9888 let mut runner = runner_at(RunStatus::Judging);
9889 runner.state.config.agents = vec![conductor()];
9893 runner.state.candidates = vec![Candidate {
9894 index: 0,
9895 label: 'A',
9896 agent: "alpha".to_owned(),
9897 branch: "magi/x/A".to_owned(),
9898 worktree: PathBuf::from("/nonexistent/worktree"),
9899 summary: "did the thing".to_owned(),
9900 stat: "1 file changed".to_owned(),
9901 files: 1,
9902 commits: 1,
9903 empty: false,
9904 failed: None,
9905 verified_noop: None,
9906 duration_ms: 1234,
9907 folded: false,
9908 }];
9909 let run_id = runner.state.id.clone();
9910
9911 let interrupt = Pause::new();
9912 runner.watch_interrupt(interrupt.clone());
9913 interrupt.park_because("task c3d4 asked to run first");
9914 assert!(runner.park_here().expect("park_here"));
9915
9916 let resumed = Runner::resume(&run_id).expect("resume");
9917 assert_eq!(resumed.state.candidates.len(), 1);
9918 assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9919 assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9920 assert_eq!(resumed.state.status, runner.state.status);
9921 assert!(
9922 resumed.state.parked,
9923 "still parked until `execute` actually walks the graph again"
9924 );
9925 assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9926 }
9927
9928 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9930 let mut q = ask::Question::new(
9931 run.to_owned(),
9932 "implement".to_owned(),
9933 "impl-A".to_owned(),
9934 "Which storage backend should the cache use?".to_owned(),
9935 String::new(),
9936 vec!["SQLite".to_owned(), "Redis".to_owned()],
9937 );
9938 store.put(&mut q).unwrap();
9939 q
9940 }
9941
9942 #[test]
9943 fn a_failed_runs_open_question_is_abandoned() {
9944 ask_test_home();
9945 let store = ask::Questions::open();
9946 let mut runner = runner_at(RunStatus::Failed);
9947 let run = runner.state.id.clone();
9948 let q = ask_open_question(&store, &run);
9949
9950 runner.settle_questions();
9951
9952 let back = store.get(&q.id).unwrap();
9953 assert!(
9954 !back.status.open(),
9955 "the seat that asked died with the run; nobody is left to read an answer"
9956 );
9957 assert!(
9958 back.detail.contains(&run) && back.detail.contains("failed"),
9959 "the reason names what the run became, not just that it is gone: {}",
9960 back.detail
9961 );
9962 }
9963
9964 #[test]
9965 fn a_merged_runs_open_question_is_abandoned_too() {
9966 ask_test_home();
9967 let store = ask::Questions::open();
9968 for status in [RunStatus::Merged, RunStatus::Ready] {
9971 let mut runner = runner_at(status);
9972 let run = runner.state.id.clone();
9973 let q = ask_open_question(&store, &run);
9974
9975 runner.settle_questions();
9976
9977 let back = store.get(&q.id).unwrap();
9978 assert!(
9979 !back.status.open(),
9980 "{status:?} run's question must not outlive the run"
9981 );
9982 }
9983 }
9984
9985 #[test]
9986 fn a_still_resumable_runs_open_question_is_left_alone() {
9987 ask_test_home();
9988 let store = ask::Questions::open();
9989 for status in [RunStatus::Blocked, RunStatus::Stalled] {
9995 let mut runner = runner_at(status);
9996 let run = runner.state.id.clone();
9997 let q = ask_open_question(&store, &run);
9998
9999 runner.settle_questions();
10000
10001 let back = store.get(&q.id).unwrap();
10002 assert!(
10003 back.status.open(),
10004 "{status:?} is still alive; the question must still be waiting"
10005 );
10006 }
10007 }
10008
10009 #[test]
10010 fn settle_questions_never_touches_an_already_answered_question() {
10011 ask_test_home();
10012 let store = ask::Questions::open();
10013 let mut runner = runner_at(RunStatus::Failed);
10014 let run = runner.state.id.clone();
10015 let mut q = ask_open_question(&store, &run);
10016 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
10017 .unwrap();
10018 store.put(&mut q).unwrap();
10019
10020 runner.settle_questions();
10025 runner.settle_questions();
10026
10027 let back = store.get(&q.id).unwrap();
10028 assert_eq!(
10029 back.status,
10030 ask::QuestionStatus::Answered,
10031 "a real answer is a decision on record, never overwritten by a sweep"
10032 );
10033 }
10034
10035 #[tokio::test]
10046 async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10047 crate::run::pin_test_home();
10048 let tmp = tempfile::tempdir().expect("tempdir");
10049 let repo = tmp.path().join("repo");
10050 std::fs::create_dir_all(&repo).unwrap();
10051 init_repo(&repo);
10052
10053 let mut config = Config::default();
10054 config.graph.worktree_root = Some(tmp.path().join("wt"));
10055
10056 let mut state = RunState::new(
10057 repo.clone(),
10058 "main".to_owned(),
10059 "deadbeef".to_owned(),
10060 "task".to_owned(),
10061 config,
10062 );
10063 let root = state.worktree_root();
10064 let wt_a = root.join("cand-A");
10065 let wt_b = root.join("cand-B");
10066 git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10067 .await
10068 .expect("worktree A");
10069 git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10070 .await
10071 .expect("worktree B");
10072
10073 state.candidates = vec![
10074 Candidate {
10075 index: 0,
10076 label: 'A',
10077 agent: "alpha".to_owned(),
10078 branch: "magi/x/A".to_owned(),
10079 worktree: wt_a.clone(),
10080 summary: String::new(),
10081 stat: String::new(),
10082 files: 0,
10083 commits: 0,
10084 empty: false,
10085 failed: None,
10086 verified_noop: None,
10087 duration_ms: 0,
10088 folded: false,
10089 },
10090 Candidate {
10091 index: 1,
10092 label: 'B',
10093 agent: "beta".to_owned(),
10094 branch: "magi/x/B".to_owned(),
10095 worktree: wt_b.clone(),
10096 summary: String::new(),
10097 stat: String::new(),
10098 files: 0,
10099 commits: 0,
10100 empty: false,
10101 failed: None,
10102 verified_noop: None,
10103 duration_ms: 0,
10104 folded: false,
10105 },
10106 ];
10107 state.tally = Some(Tally {
10108 first_choice: BTreeMap::from([('A', 1)]),
10109 borda: BTreeMap::new(),
10110 winner: 'A',
10111 rankings: 1,
10112 unanimous_initial: true,
10113 deliberated: false,
10114 changed_votes: 0,
10115 unanimous_final: true,
10116 tie_break: None,
10117 judges: 1,
10118 present: 1,
10119 quorum: 1,
10120 met_quorum: true,
10121 uncontested: None,
10122 });
10123 state.status = RunStatus::Ready;
10124
10125 fold_run(&mut state, false, &crate::run::home())
10126 .await
10127 .expect("fold_run");
10128
10129 assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10130 assert!(
10131 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10132 "the unmerged winner's branch survives"
10133 );
10134 assert!(
10135 !state.candidates[0].folded,
10136 "the winner is not marked folded"
10137 );
10138
10139 assert!(!wt_b.exists(), "the loser's worktree is removed");
10140 assert!(
10141 !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10142 "the loser's branch is removed"
10143 );
10144 assert!(state.candidates[1].folded, "the loser is marked folded");
10145 }
10146
10147 #[tokio::test]
10150 async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10151 let tmp = tempfile::tempdir().expect("tempdir");
10152 let repo = tmp.path().join("repo");
10153 std::fs::create_dir_all(&repo).unwrap();
10154 init_repo(&repo);
10155 let home = tmp.path().join("home");
10156
10157 let mut config = Config::default();
10158 config.graph.worktree_root = Some(tmp.path().join("wt"));
10159 let mut state = RunState::new(
10160 repo.clone(),
10161 "main".to_owned(),
10162 "deadbeef".to_owned(),
10163 "task".to_owned(),
10164 config,
10165 );
10166 git::git(&repo, &["branch", "magi/x/A", "main"])
10168 .await
10169 .expect("branch");
10170 state.candidates = vec![Candidate {
10171 index: 0,
10172 label: 'A',
10173 agent: "alpha".to_owned(),
10174 branch: "magi/x/A".to_owned(),
10175 worktree: state.worktree_root().join("cand-A"),
10176 summary: String::new(),
10177 stat: String::new(),
10178 files: 0,
10179 commits: 0,
10180 empty: false,
10181 failed: None,
10182 verified_noop: None,
10183 duration_ms: 0,
10184 folded: true,
10185 }];
10186 state.released_to = Some("20260901-000000-new1".to_owned());
10187 state.released_branches = vec!["magi/x/A".to_owned()];
10188
10189 fold_run(&mut state, true, &home).await.expect("fold_run");
10190
10191 assert!(
10192 git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10193 "the handed-over branch survives a fold"
10194 );
10195 }
10196
10197 #[tokio::test]
10201 async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10202 let tmp = tempfile::tempdir().expect("tempdir");
10203 let repo = tmp.path().join("repo");
10204 std::fs::create_dir_all(&repo).unwrap();
10205 init_repo(&repo);
10206 let run = |args: &[&str]| {
10207 let out = std::process::Command::new("git")
10208 .quiet()
10209 .args(args)
10210 .current_dir(&repo)
10211 .output()
10212 .expect("spawn git");
10213 assert!(out.status.success(), "git {args:?}");
10214 };
10215 run(&["branch", "magi/x/A"]);
10216 run(&["checkout", "-q", "-b", "magi/x/B"]);
10217 std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10218 run(&["add", "-A"]);
10219 run(&["commit", "-q", "-m", "work"]);
10220 run(&["checkout", "-q", "main"]);
10221
10222 let probe = RunState::new(
10226 repo.clone(),
10227 "main".to_owned(),
10228 "deadbeef".to_owned(),
10229 "task".to_owned(),
10230 Config::default(),
10231 );
10232 assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10233 let bare = tmp.path().join("origin.git");
10234 let out = std::process::Command::new("git")
10235 .quiet()
10236 .args(["init", "-q", "--bare"])
10237 .arg(&bare)
10238 .output()
10239 .expect("spawn git");
10240 assert!(out.status.success(), "git init --bare");
10241 run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10242 run(&["push", "-q", "origin", "main"]);
10243
10244 let mut state = RunState::new(
10245 repo.clone(),
10246 "main".to_owned(),
10247 "deadbeef".to_owned(),
10248 "task".to_owned(),
10249 Config::default(),
10250 );
10251 state.seeds = vec![refs::Seed {
10252 token: "magi/27b2/A".to_owned(),
10253 kind: refs::SeedKind::Unresolved,
10254 sha: String::new(),
10255 branch: true,
10256 detail: "no branch or commit named magi/27b2/A".to_owned(),
10257 }];
10258
10259 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10260 assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10261 assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10262 let detail = empty_candidate_detail(&state, "main");
10263 assert!(detail.starts_with("empty candidate"), "{detail}");
10264 assert!(detail.contains("magi/27b2/A"), "{detail}");
10265 }
10266
10267 #[tokio::test]
10276 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10277 let tmp = tempfile::tempdir().expect("tempdir");
10278 let repo = tmp.path().join("repo");
10279 std::fs::create_dir_all(&repo).unwrap();
10280 init_repo(&repo);
10281
10282 let mut config = Config::default();
10283 config.merge.mode = MergeMode::Local;
10284
10285 let mut state = RunState::new(
10286 repo.clone(),
10287 "main".to_owned(),
10288 "deadbeef".to_owned(),
10289 "task".to_owned(),
10290 config,
10291 );
10292 state.candidates = vec![Candidate {
10293 index: 0,
10294 label: 'A',
10295 agent: "alpha".to_owned(),
10296 branch: "does-not-exist".to_owned(),
10297 worktree: repo.clone(),
10298 summary: String::new(),
10299 stat: String::new(),
10300 files: 0,
10301 commits: 0,
10302 empty: false,
10303 failed: None,
10304 verified_noop: None,
10305 duration_ms: 0,
10306 folded: false,
10307 }];
10308 state.tally = Some(Tally {
10309 first_choice: BTreeMap::from([('A', 1)]),
10310 borda: BTreeMap::new(),
10311 winner: 'A',
10312 rankings: 1,
10313 unanimous_initial: true,
10314 deliberated: false,
10315 changed_votes: 0,
10316 unanimous_final: true,
10317 tie_break: None,
10318 judges: 0,
10319 present: 0,
10320 quorum: 0,
10321 met_quorum: true,
10322 uncontested: Some("only candidate A produced a change".to_owned()),
10323 });
10324 state.reviews = vec![ReviewRound {
10325 round: 1,
10326 head: "deadbeef".to_owned(),
10327 verified_head: None,
10328 verified_at: None,
10329 reviews: Vec::new(),
10330 e2e: Vec::new(),
10331 fix: None,
10332 blocking: 0,
10333 answered: 0,
10334 expected: 0,
10335 clean: true,
10336 verify_retried: false,
10337 e2e_deferred: false,
10338 e2e_defer_reason: None,
10339 progressed: false,
10340 vote_split: false,
10341 reconsideration: Vec::new(),
10342 verdict: None,
10343 }];
10344 state.gate = vec![CommandOutcome {
10345 command: "test".to_owned(),
10346 code: Some(0),
10347 output_tail: String::new(),
10348 duration_ms: 0,
10349 resource_blocked: false,
10350 }];
10351 state.gate_ran = true;
10352 state.status = RunStatus::Ready;
10357 state.merge = Some(MergeOutcome {
10358 mode: MergeMode::Local,
10359 ok: false,
10360 detail: "already concluded".to_owned(),
10361 empty: false,
10362 });
10363
10364 let mut runner = Runner {
10365 state,
10366 roles: ResolvedRoles {
10367 implementers: Vec::new(),
10368 judges: Vec::new(),
10369 reviewers: Vec::new(),
10370 fixer: None,
10371 conductor: conductor(),
10372 implementer_roster: Vec::new(),
10373 judge_roster: Vec::new(),
10374 reviewer_roster: Vec::new(),
10375 },
10376 sem: Arc::new(Semaphore::new(1)),
10377 pause: Pause::new(),
10378 interrupt: Pause::new(),
10379 };
10380
10381 runner.merge().await.expect("merge");
10382
10383 assert_eq!(
10384 runner.state.status,
10385 RunStatus::Ready,
10386 "a concluded run's status must not change on reentry"
10387 );
10388 assert_eq!(
10389 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10390 Some("already concluded"),
10391 "merge must not run again once the node already recorded an outcome"
10392 );
10393 }
10394
10395 #[tokio::test]
10404 async fn merge_refuses_a_gate_that_has_not_actually_run() {
10405 let tmp = tempfile::tempdir().expect("tempdir");
10406 let repo = tmp.path().join("repo");
10407 std::fs::create_dir_all(&repo).unwrap();
10408 init_repo(&repo);
10409
10410 let mut config = Config::default();
10411 config.merge.mode = MergeMode::Local;
10412
10413 let mut state = RunState::new(
10414 repo.clone(),
10415 "main".to_owned(),
10416 "deadbeef".to_owned(),
10417 "task".to_owned(),
10418 config,
10419 );
10420 state.candidates = vec![Candidate {
10421 index: 0,
10422 label: 'A',
10423 agent: "alpha".to_owned(),
10424 branch: "does-not-exist".to_owned(),
10425 worktree: repo.clone(),
10426 summary: String::new(),
10427 stat: String::new(),
10428 files: 0,
10429 commits: 0,
10430 empty: false,
10431 failed: None,
10432 verified_noop: None,
10433 duration_ms: 0,
10434 folded: false,
10435 }];
10436 state.tally = Some(Tally {
10437 first_choice: BTreeMap::from([('A', 1)]),
10438 borda: BTreeMap::new(),
10439 winner: 'A',
10440 rankings: 1,
10441 unanimous_initial: true,
10442 deliberated: false,
10443 changed_votes: 0,
10444 unanimous_final: true,
10445 tie_break: None,
10446 judges: 0,
10447 present: 0,
10448 quorum: 0,
10449 met_quorum: true,
10450 uncontested: Some("only candidate A produced a change".to_owned()),
10451 });
10452 state.reviews = vec![ReviewRound {
10453 round: 1,
10454 head: "deadbeef".to_owned(),
10455 verified_head: None,
10456 verified_at: None,
10457 reviews: Vec::new(),
10458 e2e: Vec::new(),
10459 fix: None,
10460 blocking: 0,
10461 answered: 0,
10462 expected: 0,
10463 clean: true,
10464 verify_retried: false,
10465 e2e_deferred: false,
10466 e2e_defer_reason: None,
10467 progressed: false,
10468 vote_split: false,
10469 reconsideration: Vec::new(),
10470 verdict: None,
10471 }];
10472 state.gate = Vec::new();
10474 state.gate_ran = false;
10475 state.status = RunStatus::Gating;
10476
10477 let mut runner = Runner {
10478 state,
10479 roles: ResolvedRoles {
10480 implementers: Vec::new(),
10481 judges: Vec::new(),
10482 reviewers: Vec::new(),
10483 fixer: None,
10484 conductor: conductor(),
10485 implementer_roster: Vec::new(),
10486 judge_roster: Vec::new(),
10487 reviewer_roster: Vec::new(),
10488 },
10489 sem: Arc::new(Semaphore::new(1)),
10490 pause: Pause::new(),
10491 interrupt: Pause::new(),
10492 };
10493
10494 runner.merge().await.expect("merge");
10495
10496 assert!(
10497 runner.state.merge.is_none(),
10498 "an empty gate must never be read as a passing one: {:?}",
10499 runner.state.merge
10500 );
10501 }
10502
10503 #[tokio::test]
10510 async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10511 let tmp = tempfile::tempdir().expect("tempdir");
10512 let repo = tmp.path().join("repo");
10513 std::fs::create_dir_all(&repo).unwrap();
10514 init_repo(&repo);
10515
10516 let config = Config::default();
10518
10519 let mut state = RunState::new(
10520 repo.clone(),
10521 "main".to_owned(),
10522 "deadbeef".to_owned(),
10523 "task".to_owned(),
10524 config,
10525 );
10526 state.candidates = vec![Candidate {
10527 index: 0,
10528 label: 'A',
10529 agent: "alpha".to_owned(),
10530 branch: "does-not-exist".to_owned(),
10531 worktree: repo.clone(),
10532 summary: String::new(),
10533 stat: String::new(),
10534 files: 0,
10535 commits: 0,
10536 empty: false,
10537 failed: None,
10538 verified_noop: None,
10539 duration_ms: 0,
10540 folded: false,
10541 }];
10542 state.tally = Some(Tally {
10543 first_choice: BTreeMap::from([('A', 1)]),
10544 borda: BTreeMap::new(),
10545 winner: 'A',
10546 rankings: 1,
10547 unanimous_initial: true,
10548 deliberated: false,
10549 changed_votes: 0,
10550 unanimous_final: true,
10551 tie_break: None,
10552 judges: 0,
10553 present: 0,
10554 quorum: 0,
10555 met_quorum: true,
10556 uncontested: Some("only candidate A produced a change".to_owned()),
10557 });
10558 state.reviews = vec![ReviewRound {
10559 round: 1,
10560 head: "deadbeef".to_owned(),
10561 verified_head: None,
10562 verified_at: None,
10563 reviews: Vec::new(),
10564 e2e: Vec::new(),
10565 fix: None,
10566 blocking: 0,
10567 answered: 0,
10568 expected: 0,
10569 clean: true,
10570 verify_retried: false,
10571 e2e_deferred: false,
10572 e2e_defer_reason: None,
10573 progressed: false,
10574 vote_split: false,
10575 reconsideration: Vec::new(),
10576 verdict: None,
10577 }];
10578
10579 let mut runner = Runner {
10580 state,
10581 roles: ResolvedRoles {
10582 implementers: Vec::new(),
10583 judges: Vec::new(),
10584 reviewers: Vec::new(),
10585 fixer: None,
10586 conductor: conductor(),
10587 implementer_roster: Vec::new(),
10588 judge_roster: Vec::new(),
10589 reviewer_roster: Vec::new(),
10590 },
10591 sem: Arc::new(Semaphore::new(1)),
10592 pause: Pause::new(),
10593 interrupt: Pause::new(),
10594 };
10595
10596 runner.gate().await.expect("gate");
10597 assert!(
10598 runner.state.gate_ran,
10599 "zero configured commands is still a real attempt, not an unrun gate"
10600 );
10601 assert!(runner.state.gate.is_empty());
10602 assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10603 assert_ne!(
10604 runner.state.status,
10605 RunStatus::Blocked,
10606 "a gate with nothing to check must not read as failed"
10607 );
10608
10609 runner.merge().await.expect("merge");
10610 assert_eq!(
10611 runner.state.status,
10612 RunStatus::Ready,
10613 "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10614 );
10615 }
10616
10617 #[tokio::test]
10628 async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10629 crate::run::pin_test_home();
10630 let home = crate::run::home();
10631
10632 let tmp = tempfile::tempdir().expect("tempdir");
10633 let repo = tmp.path().join("repo");
10634 std::fs::create_dir_all(&repo).unwrap();
10635 init_repo(&repo);
10636 let cache_dir = tmp.path().join("target");
10639
10640 let mut config = Config::default();
10641 config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10642 config.graph.timeout_verify = Some(2);
10645
10646 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10647 let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10648 .expect("no io error acquiring directly")
10649 {
10650 crate::cache::AcquireOutcome::Acquired(g) => g,
10651 crate::cache::AcquireOutcome::Busy(b) => {
10652 panic!("expected the direct acquire to win the lease first: {b:?}")
10653 }
10654 };
10655
10656 let mut state = RunState::new(
10657 repo.clone(),
10658 "main".to_owned(),
10659 "deadbeef".to_owned(),
10660 "task".to_owned(),
10661 config,
10662 );
10663 state.candidates = vec![Candidate {
10664 index: 0,
10665 label: 'A',
10666 agent: "alpha".to_owned(),
10667 branch: "does-not-exist".to_owned(),
10668 worktree: repo.clone(),
10669 summary: String::new(),
10670 stat: String::new(),
10671 files: 0,
10672 commits: 0,
10673 empty: false,
10674 failed: None,
10675 verified_noop: None,
10676 duration_ms: 0,
10677 folded: false,
10678 }];
10679 state.tally = Some(Tally {
10680 first_choice: BTreeMap::from([('A', 1)]),
10681 borda: BTreeMap::new(),
10682 winner: 'A',
10683 rankings: 1,
10684 unanimous_initial: true,
10685 deliberated: false,
10686 changed_votes: 0,
10687 unanimous_final: true,
10688 tie_break: None,
10689 judges: 0,
10690 present: 0,
10691 quorum: 0,
10692 met_quorum: true,
10693 uncontested: Some("only candidate A produced a change".to_owned()),
10694 });
10695 state.reviews = vec![ReviewRound {
10696 round: 1,
10697 head: "deadbeef".to_owned(),
10698 verified_head: None,
10699 verified_at: None,
10700 reviews: Vec::new(),
10701 e2e: Vec::new(),
10702 fix: None,
10703 blocking: 0,
10704 answered: 0,
10705 expected: 0,
10706 clean: true,
10707 verify_retried: false,
10708 e2e_deferred: false,
10709 e2e_defer_reason: None,
10710 progressed: false,
10711 vote_split: false,
10712 reconsideration: Vec::new(),
10713 verdict: None,
10714 }];
10715
10716 let mut runner = Runner {
10717 state,
10718 roles: ResolvedRoles {
10719 implementers: Vec::new(),
10720 judges: Vec::new(),
10721 reviewers: Vec::new(),
10722 fixer: None,
10723 conductor: conductor(),
10724 implementer_roster: Vec::new(),
10725 judge_roster: Vec::new(),
10726 reviewer_roster: Vec::new(),
10727 },
10728 sem: Arc::new(Semaphore::new(1)),
10729 pause: Pause::new(),
10730 interrupt: Pause::new(),
10731 };
10732
10733 let started = std::time::Instant::now();
10734 runner.gate().await.expect("gate");
10735 assert!(
10736 started.elapsed() < Duration::from_secs(1),
10737 "a gate with nothing to run must never wait on a lease it never needed"
10738 );
10739 assert!(
10740 runner.state.gate_ran,
10741 "zero commands is still a real, immediate attempt"
10742 );
10743 assert!(runner.state.gate.is_empty());
10744 assert_ne!(
10745 runner.state.status,
10746 RunStatus::Blocked,
10747 "must not read as resource-blocked on a lease it never asked for"
10748 );
10749 }
10750
10751 #[tokio::test]
10761 async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10762 crate::run::pin_test_home();
10763
10764 let tmp = tempfile::tempdir().expect("tempdir");
10765 let repo = tmp.path().join("repo");
10766 std::fs::create_dir_all(&repo).unwrap();
10767 init_repo(&repo);
10768
10769 let mut config = Config::default();
10770 config.verify.gate = vec![
10771 "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10772 [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10773 .to_owned(),
10774 ];
10775
10776 let mut state = RunState::new(
10777 repo.clone(),
10778 "main".to_owned(),
10779 "deadbeef".to_owned(),
10780 "task".to_owned(),
10781 config,
10782 );
10783 let run_id = state.id.clone();
10784 state.candidates = vec![Candidate {
10785 index: 0,
10786 label: 'A',
10787 agent: "alpha".to_owned(),
10788 branch: "does-not-exist".to_owned(),
10789 worktree: repo.clone(),
10790 summary: String::new(),
10791 stat: String::new(),
10792 files: 0,
10793 commits: 0,
10794 empty: false,
10795 failed: None,
10796 verified_noop: None,
10797 duration_ms: 0,
10798 folded: false,
10799 }];
10800 state.tally = Some(Tally {
10801 first_choice: BTreeMap::from([('A', 1)]),
10802 borda: BTreeMap::new(),
10803 winner: 'A',
10804 rankings: 1,
10805 unanimous_initial: true,
10806 deliberated: false,
10807 changed_votes: 0,
10808 unanimous_final: true,
10809 tie_break: None,
10810 judges: 0,
10811 present: 0,
10812 quorum: 0,
10813 met_quorum: true,
10814 uncontested: Some("only candidate A produced a change".to_owned()),
10815 });
10816 state.reviews = vec![ReviewRound {
10817 round: 1,
10818 head: "deadbeef".to_owned(),
10819 verified_head: None,
10820 verified_at: None,
10821 reviews: Vec::new(),
10822 e2e: Vec::new(),
10823 fix: None,
10824 blocking: 0,
10825 answered: 0,
10826 expected: 0,
10827 clean: true,
10828 verify_retried: false,
10829 e2e_deferred: false,
10830 e2e_defer_reason: None,
10831 progressed: false,
10832 vote_split: false,
10833 reconsideration: Vec::new(),
10834 verdict: None,
10835 }];
10836
10837 let mut runner = Runner {
10838 state,
10839 roles: ResolvedRoles {
10840 implementers: Vec::new(),
10841 judges: Vec::new(),
10842 reviewers: Vec::new(),
10843 fixer: None,
10844 conductor: conductor(),
10845 implementer_roster: Vec::new(),
10846 judge_roster: Vec::new(),
10847 reviewer_roster: Vec::new(),
10848 },
10849 sem: Arc::new(Semaphore::new(1)),
10850 pause: Pause::new(),
10851 interrupt: Pause::new(),
10852 };
10853
10854 let started_marker = repo.join("started.marker");
10855 let release_marker = repo.join("release.marker");
10856 let poller = tokio::spawn(async move {
10857 for _ in 0..100 {
10862 if started_marker.exists()
10863 && let Ok(s) = crate::run::RunState::load(&run_id)
10864 && let Some(a) = s.active.get("gate")
10865 {
10866 std::fs::write(&release_marker, b"go").expect("release marker");
10867 return Some(a.clone());
10868 }
10869 tokio::time::sleep(Duration::from_millis(50)).await;
10870 }
10871 None
10872 });
10873
10874 runner.gate().await.expect("gate");
10875 let captured = poller.await.expect("poller task");
10876 let captured = captured.expect(
10877 "the poller never saw a `gate` task entry in run.json while the command was \
10878 still blocked on its own release marker",
10879 );
10880
10881 assert_eq!(captured.task.as_deref(), Some("gate"));
10882 assert_eq!(captured.node, "gate");
10883 assert_eq!(captured.index, Some(1));
10884 assert_eq!(captured.total, Some(1));
10885 assert!(
10886 captured
10887 .command
10888 .as_deref()
10889 .is_some_and(|c| c.contains("started.marker")),
10890 "{captured:?}"
10891 );
10892
10893 assert!(
10894 runner.state.active.is_empty(),
10895 "the entry must be cleared once the command actually finished: {:?}",
10896 runner.state.active
10897 );
10898 assert!(runner.state.gate_ran);
10899 assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10900 }
10901
10902 #[tokio::test]
10905 async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10906 use crate::verdict::{Finding, ReviewVote, Severity};
10907 crate::run::pin_test_home();
10908 let tmp = tempfile::tempdir().expect("tempdir");
10909 let repo = tmp.path().join("repo");
10910 std::fs::create_dir_all(&repo).unwrap();
10911 init_repo(&repo);
10912
10913 for (severity, vote, expect) in [
10914 (Severity::Major, ReviewVote::Reject, true),
10915 (Severity::Minor, ReviewVote::Reject, false),
10916 (Severity::Major, ReviewVote::Approve, false),
10917 ] {
10918 let mut round = review_round(false, 1, 1, 1, false, true);
10919 round.reviews = vec![ReviewRecord {
10920 reviewer: 1,
10921 agent: "alpha".to_owned(),
10922 summary: String::new(),
10923 findings: vec![Finding {
10924 id: "R1-1-1".to_owned(),
10925 severity,
10926 file: None,
10927 line: None,
10928 title: "t".to_owned(),
10929 detail: String::new(),
10930 }],
10931 vote: Some(vote),
10932 failed: None,
10933 duration_ms: 0,
10934 attempts: 0,
10935 }];
10936 let mut state = RunState::new(
10937 repo.clone(),
10938 "main".to_owned(),
10939 "deadbeef".to_owned(),
10940 "task".to_owned(),
10941 Config::default(),
10942 );
10943 state.reviews = vec![round];
10944 let mut runner = Runner {
10945 state,
10946 roles: ResolvedRoles {
10947 implementers: Vec::new(),
10948 judges: Vec::new(),
10949 reviewers: Vec::new(),
10950 fixer: None,
10951 conductor: conductor(),
10952 implementer_roster: Vec::new(),
10953 judge_roster: Vec::new(),
10954 reviewer_roster: Vec::new(),
10955 },
10956 sem: Arc::new(Semaphore::new(1)),
10957 pause: Pause::new(),
10958 interrupt: Pause::new(),
10959 };
10960 let shell = runner.state.config.shell();
10961 runner
10962 .stop_reviewing("round budget spent", &shell, &repo)
10963 .await
10964 .expect("stop_reviewing");
10965 assert_eq!(runner.state.status, RunStatus::Gating);
10966 assert_eq!(
10967 runner.state.contested_handoff.is_some(),
10968 expect,
10969 "{severity:?} + {vote:?}"
10970 );
10971 }
10972 }
10973
10974 #[tokio::test]
10987 async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10988 crate::run::pin_test_home();
10989 let home = crate::run::home();
10990
10991 let tmp = tempfile::tempdir().expect("tempdir");
10992 let repo = tmp.path().join("repo");
10993 std::fs::create_dir_all(&repo).unwrap();
10994 init_repo(&repo);
10995 let head = crate::git::rev_parse(&repo, "HEAD")
10996 .await
10997 .expect("rev-parse");
10998 let cache_dir = tmp.path().join("target");
11001
11002 let mut config = Config::default();
11003 config.verify.e2e = vec![format!(
11004 "CARGO_TARGET_DIR='{}' test -f README.md",
11005 cache_dir.display()
11006 )];
11007 config.graph.review_rounds = 1;
11008 config.graph.timeout_verify = Some(2);
11011
11012 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11013 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11014 .expect("no io error acquiring directly")
11015 {
11016 crate::cache::AcquireOutcome::Acquired(g) => g,
11017 crate::cache::AcquireOutcome::Busy(b) => {
11018 panic!("expected the direct acquire to win the lease first: {b:?}")
11019 }
11020 };
11021
11022 let mut state = RunState::new(
11023 repo.clone(),
11024 "main".to_owned(),
11025 head.clone(),
11026 "task".to_owned(),
11027 config,
11028 );
11029 state.candidates = vec![Candidate {
11030 index: 0,
11031 label: 'A',
11032 agent: "alpha".to_owned(),
11033 branch: "does-not-exist".to_owned(),
11034 worktree: repo.clone(),
11035 summary: String::new(),
11036 stat: String::new(),
11037 files: 0,
11038 commits: 0,
11039 empty: false,
11040 failed: None,
11041 verified_noop: None,
11042 duration_ms: 0,
11043 folded: false,
11044 }];
11045 state.tally = Some(Tally {
11046 first_choice: BTreeMap::from([('A', 1)]),
11047 borda: BTreeMap::new(),
11048 winner: 'A',
11049 rankings: 1,
11050 unanimous_initial: true,
11051 deliberated: false,
11052 changed_votes: 0,
11053 unanimous_final: true,
11054 tie_break: None,
11055 judges: 0,
11056 present: 0,
11057 quorum: 0,
11058 met_quorum: true,
11059 uncontested: Some("only candidate A produced a change".to_owned()),
11060 });
11061 state.reviews = vec![ReviewRound {
11065 round: 1,
11066 head: head.clone(),
11067 verified_head: None,
11068 verified_at: None,
11069 reviews: Vec::new(),
11070 e2e: Vec::new(),
11071 fix: None,
11072 blocking: 1,
11073 answered: 1,
11074 expected: 1,
11075 clean: false,
11076 verify_retried: false,
11077 e2e_deferred: true,
11078 e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11079 progressed: false,
11080 vote_split: false,
11081 reconsideration: Vec::new(),
11082 verdict: None,
11083 }];
11084
11085 let mut runner = Runner {
11086 state,
11087 roles: ResolvedRoles {
11088 implementers: Vec::new(),
11089 judges: Vec::new(),
11090 reviewers: Vec::new(),
11091 fixer: None,
11092 conductor: conductor(),
11093 implementer_roster: Vec::new(),
11094 judge_roster: Vec::new(),
11095 reviewer_roster: Vec::new(),
11096 },
11097 sem: Arc::new(Semaphore::new(1)),
11098 pause: Pause::new(),
11099 interrupt: Pause::new(),
11100 };
11101
11102 let shell = runner.state.config.shell();
11103 runner
11104 .stop_reviewing("round budget spent", &shell, &repo)
11105 .await
11106 .expect("stop_reviewing");
11107
11108 let last = runner.state.reviews.last().expect("round record");
11109 assert_eq!(
11110 last.e2e_status(),
11111 E2eStatus::ResourceBlocked,
11112 "the shared cache is still held; the attempt must read as blocked, not deferred or \
11113 failed: {last:?}"
11114 );
11115 assert_eq!(
11116 last.verified_head.as_deref(),
11117 Some(head.as_str()),
11118 "which commit this attempt targeted is known even though nothing finished checking \
11119 it"
11120 );
11121 let first_attempt_at = last
11122 .verified_at
11123 .expect("when this attempt ran is known too");
11124 assert_ne!(
11125 runner.state.status,
11126 RunStatus::Blocked,
11127 "contention is evidence about the machine, not the patch — it must not settle the \
11128 run as blocked: {:?}",
11129 runner.state.status
11130 );
11131 assert!(
11132 !runner
11133 .state
11134 .events
11135 .iter()
11136 .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11137 "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11138 runner.state.events
11139 );
11140
11141 runner
11146 .stop_reviewing("round budget spent", &shell, &repo)
11147 .await
11148 .expect("stop_reviewing retry");
11149 assert_eq!(
11150 runner.state.reviews.len(),
11151 1,
11152 "no new round was started: {:?}",
11153 runner.state.reviews
11154 );
11155 let last = runner.state.reviews.last().expect("round record");
11156 assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11157 assert!(
11158 last.verified_at.expect("still known") > first_attempt_at,
11159 "a second reentry must be a fresh attempt, not a stale copy of the first"
11160 );
11161 assert_ne!(runner.state.status, RunStatus::Blocked);
11162
11163 held.release();
11164 }
11165
11166 #[tokio::test]
11178 async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11179 crate::run::pin_test_home();
11180 let home = crate::run::home();
11181
11182 let tmp = tempfile::tempdir().expect("tempdir");
11183 let repo = tmp.path().join("repo");
11184 std::fs::create_dir_all(&repo).unwrap();
11185 init_repo(&repo);
11186 let head = crate::git::rev_parse(&repo, "HEAD")
11187 .await
11188 .expect("rev-parse");
11189 let cache_dir = tmp.path().join("target");
11190
11191 let mut config = Config::default();
11192 config.verify.e2e = vec![format!(
11193 "CARGO_TARGET_DIR='{}' test -f README.md",
11194 cache_dir.display()
11195 )];
11196 config.graph.review_rounds = 1;
11197 config.graph.timeout_verify = Some(2);
11198
11199 let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11200 let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11201 .expect("no io error acquiring directly")
11202 {
11203 crate::cache::AcquireOutcome::Acquired(g) => g,
11204 crate::cache::AcquireOutcome::Busy(b) => {
11205 panic!("expected the direct acquire to win the lease first: {b:?}")
11206 }
11207 };
11208
11209 let mut state = RunState::new(
11210 repo.clone(),
11211 "main".to_owned(),
11212 head.clone(),
11213 "task".to_owned(),
11214 config,
11215 );
11216 state.candidates = vec![Candidate {
11217 index: 0,
11218 label: 'A',
11219 agent: "alpha".to_owned(),
11220 branch: "does-not-exist".to_owned(),
11221 worktree: repo.clone(),
11222 summary: String::new(),
11223 stat: String::new(),
11224 files: 0,
11225 commits: 0,
11226 empty: false,
11227 failed: None,
11228 verified_noop: None,
11229 duration_ms: 0,
11230 folded: false,
11231 }];
11232 state.tally = Some(Tally {
11233 first_choice: BTreeMap::from([('A', 1)]),
11234 borda: BTreeMap::new(),
11235 winner: 'A',
11236 rankings: 1,
11237 unanimous_initial: true,
11238 deliberated: false,
11239 changed_votes: 0,
11240 unanimous_final: true,
11241 tie_break: None,
11242 judges: 0,
11243 present: 0,
11244 quorum: 0,
11245 met_quorum: true,
11246 uncontested: Some("only candidate A produced a change".to_owned()),
11247 });
11248 state.reviews = vec![ReviewRound {
11252 round: 1,
11253 head: head.clone(),
11254 verified_head: Some(head.clone()),
11255 verified_at: Some(jiff::Timestamp::now()),
11256 reviews: Vec::new(),
11257 e2e: vec![CommandOutcome {
11258 command: format!(
11259 "CARGO_TARGET_DIR='{}' test -f README.md",
11260 cache_dir.display()
11261 ),
11262 code: None,
11263 output_tail: "waiting for the shared build cache".to_owned(),
11264 duration_ms: 0,
11265 resource_blocked: true,
11266 }],
11267 fix: None,
11268 blocking: 1,
11269 answered: 1,
11270 expected: 1,
11271 clean: false,
11272 verify_retried: false,
11273 e2e_deferred: false,
11274 e2e_defer_reason: None,
11275 progressed: false,
11276 vote_split: false,
11277 reconsideration: Vec::new(),
11278 verdict: None,
11279 }];
11280
11281 let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11282 let mut runner = Runner {
11283 state,
11284 roles: ResolvedRoles {
11285 implementers: Vec::new(),
11286 judges: Vec::new(),
11287 reviewers: Vec::new(),
11288 fixer: None,
11289 conductor: conductor(),
11290 implementer_roster: Vec::new(),
11291 judge_roster: Vec::new(),
11292 reviewer_roster: Vec::new(),
11293 },
11294 sem: Arc::new(Semaphore::new(1)),
11295 pause: Pause::new(),
11296 interrupt: Pause::new(),
11297 };
11298
11299 runner.review_loop().await.expect("review_loop");
11304
11305 assert_eq!(
11306 runner.state.reviews.len(),
11307 1,
11308 "no new round was started on top of the unresolved one: {:?}",
11309 runner.state.reviews
11310 );
11311 let last = &runner.state.reviews[0];
11312 assert_eq!(
11313 last.e2e_status(),
11314 E2eStatus::ResourceBlocked,
11315 "still contended: {last:?}"
11316 );
11317 assert!(
11318 last.verified_at.expect("still known") > first_attempt_at,
11319 "review_loop must have actually retried the check, not left it exactly as found"
11320 );
11321 assert_ne!(
11322 runner.state.status,
11323 RunStatus::Blocked,
11324 "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11325 runner.state.status
11326 );
11327
11328 held.release();
11329 }
11330
11331 #[tokio::test]
11332 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11333 crate::run::pin_test_home();
11334 let tmp = tempfile::tempdir().expect("tempdir");
11335 let repo = tmp.path().join("repo");
11336 std::fs::create_dir_all(&repo).unwrap();
11337 init_repo(&repo);
11338
11339 let mut config = Config::default();
11340 config.merge.mode = MergeMode::Pr;
11341 config.graph.land = true;
11342 config.graph.land_approval = false;
11343
11344 let mut state = RunState::new(
11345 repo.clone(),
11346 "main".to_owned(),
11347 "deadbeef".to_owned(),
11348 "task".to_owned(),
11349 config,
11350 );
11351 state.candidates = vec![Candidate {
11352 index: 0,
11353 label: 'A',
11354 agent: "alpha".to_owned(),
11355 branch: "does-not-exist".to_owned(),
11356 worktree: repo.clone(),
11357 summary: String::new(),
11358 stat: String::new(),
11359 files: 0,
11360 commits: 0,
11361 empty: false,
11362 failed: None,
11363 verified_noop: None,
11364 duration_ms: 0,
11365 folded: false,
11366 }];
11367 state.tally = Some(Tally {
11368 first_choice: BTreeMap::from([('A', 1)]),
11369 borda: BTreeMap::new(),
11370 winner: 'A',
11371 rankings: 1,
11372 unanimous_initial: true,
11373 deliberated: false,
11374 changed_votes: 0,
11375 unanimous_final: true,
11376 tie_break: None,
11377 judges: 0,
11378 present: 0,
11379 quorum: 0,
11380 met_quorum: true,
11381 uncontested: Some("only candidate A produced a change".to_owned()),
11382 });
11383 state.reviews = vec![ReviewRound {
11384 round: 1,
11385 head: "deadbeef".to_owned(),
11386 verified_head: None,
11387 verified_at: None,
11388 reviews: Vec::new(),
11389 e2e: Vec::new(),
11390 fix: None,
11391 blocking: 0,
11392 answered: 0,
11393 expected: 0,
11394 clean: true,
11395 verify_retried: false,
11396 e2e_deferred: false,
11397 e2e_defer_reason: None,
11398 progressed: false,
11399 vote_split: false,
11400 reconsideration: Vec::new(),
11401 verdict: None,
11402 }];
11403 state.gate = vec![CommandOutcome {
11404 command: "test".to_owned(),
11405 code: Some(0),
11406 output_tail: String::new(),
11407 duration_ms: 0,
11408 resource_blocked: false,
11409 }];
11410 state.gate_ran = true;
11411 state.status = RunStatus::Landing;
11415 state.merge = Some(MergeOutcome {
11416 mode: MergeMode::Pr,
11417 ok: true,
11418 detail: "https://example.invalid/x/y/pull/1".to_owned(),
11419 empty: false,
11420 });
11421
11422 ask_test_home();
11426 let store = ask::Questions::open();
11427 let q = ask_open_question(&store, &state.id);
11428
11429 let mut runner = Runner {
11430 state,
11431 roles: ResolvedRoles {
11432 implementers: Vec::new(),
11433 judges: Vec::new(),
11434 reviewers: Vec::new(),
11435 fixer: None,
11436 conductor: conductor(),
11437 implementer_roster: Vec::new(),
11438 judge_roster: Vec::new(),
11439 reviewer_roster: Vec::new(),
11440 },
11441 sem: Arc::new(Semaphore::new(1)),
11442 pause: Pause::new(),
11443 interrupt: Pause::new(),
11444 };
11445
11446 runner.execute().await.expect("execute");
11451
11452 assert_eq!(
11453 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11454 Some("https://example.invalid/x/y/pull/1"),
11455 "reentry must not push again or open a second pull request over the \
11456 one `land` is already watching"
11457 );
11458 assert_ne!(
11459 runner.state.status,
11460 RunStatus::Landing,
11461 "land could not actually reach the fake pull request, so it must \
11462 have given up rather than left the run silently parked forever"
11463 );
11464 assert_eq!(runner.state.status, RunStatus::Blocked);
11468 assert!(
11469 store.get(&q.id).unwrap().status.open(),
11470 "Blocked is still alive; settle_questions must have been a no-op here"
11471 );
11472 }
11473
11474 fn state_with_round(round: ReviewRound) -> RunState {
11475 let mut s = RunState::new(
11476 PathBuf::from("/repo"),
11477 "main".to_owned(),
11478 "abc1234".to_owned(),
11479 "add retries".to_owned(),
11480 Config::default(),
11481 );
11482 s.reviews = vec![round];
11483 s
11484 }
11485
11486 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11487 crate::verdict::Finding {
11488 id: id.to_owned(),
11489 severity,
11490 file: None,
11491 line: None,
11492 title: title.to_owned(),
11493 detail: String::new(),
11494 }
11495 }
11496
11497 #[test]
11498 fn pr_body_names_open_findings_and_declined_ones() {
11499 let round = ReviewRound {
11500 round: 2,
11501 head: "deadbee".to_owned(),
11502 verified_head: None,
11503 verified_at: None,
11504 reviews: vec![ReviewRecord {
11505 attempts: 0,
11506 reviewer: 1,
11507 agent: "alpha".to_owned(),
11508 summary: String::new(),
11509 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11510 vote: None,
11511 failed: None,
11512 duration_ms: 0,
11513 }],
11514 e2e: vec![CommandOutcome {
11515 command: "cargo test".to_owned(),
11516 code: Some(0),
11517 output_tail: String::new(),
11518 duration_ms: 0,
11519 resource_blocked: false,
11520 }],
11521 verify_retried: false,
11522 e2e_deferred: false,
11523 e2e_defer_reason: None,
11524 fix: Some(FixRecord {
11525 agent: "alpha".to_owned(),
11526 addressed: Vec::new(),
11527 rejected: vec![crate::verdict::Rejection {
11528 id: "R1-1-1".to_owned(),
11529 why: "not reachable from any caller".to_owned(),
11530 }],
11531 notes: String::new(),
11532 committed: true,
11533 failed: None,
11534 duration_ms: 0,
11535 continuation: None,
11536 }),
11537 blocking: 0,
11538 answered: 1,
11539 expected: 1,
11540 clean: false,
11541 progressed: true,
11542 vote_split: false,
11543 reconsideration: Vec::new(),
11544 verdict: None,
11545 };
11546 let state = state_with_round(round);
11547 let body = pr_message(&state, 'A').body;
11548
11549 assert!(body.contains("add retries"), "the task must still be there");
11550 assert!(body.contains("R2-1-1"), "{body}");
11551 assert!(body.contains("unused import"), "{body}");
11552 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11553 assert!(
11554 body.contains("not reachable from any caller"),
11555 "the reason it was declined: {body}"
11556 );
11557 }
11558
11559 #[test]
11560 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11561 let round = ReviewRound {
11562 round: 1,
11563 head: "deadbee".to_owned(),
11564 verified_head: None,
11565 verified_at: None,
11566 reviews: vec![ReviewRecord {
11567 attempts: 0,
11568 reviewer: 1,
11569 agent: "alpha".to_owned(),
11570 summary: String::new(),
11571 findings: Vec::new(),
11572 vote: None,
11573 failed: None,
11574 duration_ms: 0,
11575 }],
11576 e2e: Vec::new(),
11577 verify_retried: false,
11578 e2e_deferred: false,
11579 e2e_defer_reason: None,
11580 fix: None,
11581 blocking: 0,
11582 answered: 1,
11583 expected: 1,
11584 clean: true,
11585 progressed: false,
11586 vote_split: false,
11587 reconsideration: Vec::new(),
11588 verdict: None,
11589 };
11590 let state = state_with_round(round);
11591 let body = pr_message(&state, 'A').body;
11592 assert!(!body.contains("Open review findings"), "{body}");
11593 assert!(!body.contains("Declined"), "{body}");
11594 }
11595
11596 fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11597 let mut state = RunState::new(
11598 PathBuf::from("/repo"),
11599 "main".to_owned(),
11600 "abc1234".to_owned(),
11601 instruction.to_owned(),
11602 Config::default(),
11603 );
11604 state.candidates.push(Candidate {
11605 index: 0,
11606 label: 'A',
11607 agent: "alpha".to_owned(),
11608 branch: "magi/x/A".to_owned(),
11609 worktree: PathBuf::from("/wt"),
11610 summary: summary.to_owned(),
11611 stat: String::new(),
11612 files: 1,
11613 commits: 1,
11614 empty: false,
11615 failed: None,
11616 verified_noop: None,
11617 folded: false,
11618 duration_ms: 0,
11619 });
11620 state
11621 }
11622
11623 fn review_state(subjects: &[&str]) -> RunState {
11624 let mut state = state_with_summary(
11625 "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",
11626 "",
11627 );
11628 state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11629 state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11630 state
11631 }
11632
11633 #[tokio::test]
11636 async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11637 ask_test_home();
11638 let tmp = tempfile::tempdir().unwrap();
11639 let repo = tmp.path().join("repo");
11640 std::fs::create_dir_all(&repo).unwrap();
11641 init_repo(&repo);
11642 let origin = tmp.path().join("origin.git");
11643 let g = |dir: &Path, args: &[&str]| {
11644 let out = std::process::Command::new("git")
11645 .args(args)
11646 .current_dir(dir)
11647 .quiet()
11648 .output()
11649 .expect("spawn git");
11650 assert!(
11651 out.status.success(),
11652 "git {args:?}: {}",
11653 String::from_utf8_lossy(&out.stderr)
11654 );
11655 };
11656 g(
11657 tmp.path(),
11658 &[
11659 "clone",
11660 "--bare",
11661 "-q",
11662 repo.to_str().unwrap(),
11663 origin.to_str().unwrap(),
11664 ],
11665 );
11666 g(
11667 &repo,
11668 &["remote", "add", "origin", origin.to_str().unwrap()],
11669 );
11670 let c1 = git::rev_parse(&repo, "main").await.unwrap();
11671
11672 std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11674 g(&repo, &["add", "-A"]);
11675 g(
11676 &repo,
11677 &["commit", "-q", "-m", "chore(deps): update a crate"],
11678 );
11679 g(&repo, &["push", "-q", "origin", "main"]);
11680 g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11681 std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11682 g(&repo, &["add", "-A"]);
11683 g(
11684 &repo,
11685 &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11686 );
11687 g(&repo, &["checkout", "-q", "main"]);
11688
11689 let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11690 assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11691 let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11693 assert_eq!(fallback, c1);
11694
11695 let mut state = review_state(&[
11696 "chore(deps): update a crate",
11697 "fix(daemon): apply a chosen action",
11698 ]);
11699 state.repo = repo.clone();
11700 state.base_branch = "main".to_owned();
11701 state.base_commit = c1;
11702 refresh_reviewed_commits(&mut state, "feat/own").await;
11703 assert_eq!(
11704 state.reviewed_commits,
11705 Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11706 );
11707 assert_eq!(
11708 review_title(&state).as_deref(),
11709 Some("fix(daemon): apply a chosen action")
11710 );
11711 assert_eq!(
11712 leaked_subjects(&state, "feat/own").await,
11713 Some(vec!["chore(deps): update a crate".to_owned()])
11714 );
11715 state.config.merge.remote = "nowhere".to_owned();
11718 assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11719 }
11720
11721 #[test]
11722 fn pr_message_review_single_commit_uses_its_subject() {
11723 let state = review_state(&["feat(nats): per-role user"]);
11724 let m = pr_message(&state, 'A');
11725 assert_eq!(m.title, "feat(nats): per-role user");
11726 assert!(!m.body.contains("Review the work already"), "{}", m.body);
11727 assert!(m.body.contains("## Commits under review"), "{}", m.body);
11728 }
11729
11730 #[test]
11731 fn pr_message_review_multi_commit_takes_the_oldest() {
11732 let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11733 let m = pr_message(&state, 'A');
11734 assert_eq!(m.title, "feat: the change");
11735 for s in ["feat: the change", "fix: typo", "fix: again"] {
11736 assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11737 }
11738 }
11739
11740 #[test]
11741 fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11742 for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11743 let mut state = review_state(&[first, "fix: later fixup"]);
11744 state.candidates[0].branch = "機能/ログイン".to_owned();
11745 let m = pr_message(&state, 'A');
11746 assert!(
11747 m.title.starts_with("chore: land candidate A of run"),
11748 "{}",
11749 m.title
11750 );
11751 }
11752 }
11753
11754 fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11755 BranchFacts {
11756 commits: commits
11757 .iter()
11758 .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11759 .collect(),
11760 stat: stat.to_owned(),
11761 }
11762 }
11763
11764 fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11765 let mut r = review_round(true, 0, 1, 1, true, true);
11766 r.round = round;
11767 r.fix = notes.map(|n| FixRecord {
11768 agent: "fixer".to_owned(),
11769 addressed: Vec::new(),
11770 rejected: Vec::new(),
11771 notes: n.to_owned(),
11772 committed: true,
11773 failed: None,
11774 duration_ms: 0,
11775 continuation: None,
11776 });
11777 r
11778 }
11779
11780 #[test]
11781 fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11782 let state = review_state(&["ignored"]);
11783 let f = facts(
11784 &[
11785 (
11786 "fix(login): resolve PATH on macOS",
11787 "Login shells skip rc files.",
11788 ),
11789 ("fix: address review", ""),
11790 ],
11791 " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11792 );
11793 let m = pr_message_with(&state, 'A', Some(&f));
11794 assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11795 assert!(
11796 m.body
11797 .contains("- fix(login): resolve PATH on macOS\n Login shells skip rc files.\n"),
11798 "{}",
11799 m.body
11800 );
11801 assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11802 assert!(m.body.contains("## Diff stat"), "{}", m.body);
11803 assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11804 for banned in [
11805 "Review the work already",
11806 "no task statement",
11807 "Original task",
11808 ] {
11809 assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11810 }
11811 assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11812 }
11813
11814 #[test]
11815 fn pr_message_review_truncates_a_huge_first_commit_body() {
11816 let state = review_state(&["ignored"]);
11817 let f = facts(
11818 &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11819 "s",
11820 );
11821 let m = pr_message_with(&state, 'A', Some(&f));
11822 assert!(m.body.len() < 30_000, "{}", m.body.len());
11823 assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11824 assert!(
11825 m.body.contains("- ... 1 more commit(s)"),
11826 "{}",
11827 m.body.len()
11828 );
11829 assert!(m.body.ends_with("magi:candidate-a\n"));
11830 }
11831
11832 #[test]
11833 fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11834 let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11835 assert_eq!(m.title, "feat: x");
11836 assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11837 assert!(!m.body.contains("Diff stat"), "{}", m.body);
11838 assert!(!m.body.contains("no task statement"), "{}", m.body);
11839 }
11840
11841 #[test]
11842 fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11843 let mut state = review_state(&["日本語の件名"]);
11844 state.candidates[0].branch = "fix/macos-login-path".to_owned();
11845 assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11846 state.candidates[0].branch = "機能/ログイン".to_owned();
11847 assert!(
11848 pr_message(&state, 'A')
11849 .title
11850 .starts_with("chore: land candidate A")
11851 );
11852 }
11853
11854 #[test]
11855 fn pr_message_review_fixes_survive_a_clean_final_round() {
11856 let mut state = review_state(&["feat: x"]);
11857 state.reviews = vec![
11858 round_with_notes(1, Some("handled the PATH case")),
11859 round_with_notes(2, None),
11860 ];
11861 let body = pr_message(&state, 'A').body;
11862 assert!(
11863 body.contains("## Review fixes\n\nhandled the PATH case\n"),
11864 "{body}"
11865 );
11866 assert!(!body.contains("### Round"), "{body}");
11867
11868 state.reviews = vec![
11869 round_with_notes(1, Some("first fix")),
11870 round_with_notes(2, Some("")),
11871 round_with_notes(3, Some("second fix")),
11872 round_with_notes(4, None),
11873 ];
11874 let body = pr_message(&state, 'A').body;
11875 assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11876 assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11877 assert!(!body.contains("### Round 2"), "{body}");
11878 }
11879
11880 #[test]
11881 fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11882 let state = state_with_summary(
11883 "add retries to the client",
11884 "TITLE: feat: retries\n\nDid it.",
11885 );
11886 let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11887 assert_eq!(m.title, "feat: retries");
11888 assert!(
11889 m.body.contains("<summary>Original task</summary>"),
11890 "{}",
11891 m.body
11892 );
11893 assert!(!m.body.contains("Commits under review"), "{}", m.body);
11894 assert!(
11895 m.body
11896 .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11897 "{}",
11898 m.body
11899 );
11900 }
11901
11902 #[test]
11903 fn pr_message_review_bounds_a_long_english_subject() {
11904 let long = format!("feat: {}", "word ".repeat(100));
11905 let m = pr_message(&review_state(&[&long]), 'A');
11906 assert!(m.title.starts_with("feat: word"), "{}", m.title);
11907 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11908 }
11909
11910 #[test]
11911 fn pr_message_implementation_run_is_unchanged_by_review_support() {
11912 let state = state_with_summary("add retries\n\ndetails", "- did some things");
11913 let m = pr_message(&state, 'A');
11914 assert_eq!(m.title, "add retries");
11915 assert!(m.body.contains("<summary>Original task</summary>"));
11916 assert!(!m.body.contains("Commits under review"));
11917 assert_eq!(landing_subject_source(&state), state.instruction);
11918 }
11919
11920 #[test]
11921 fn review_run_squash_subject_is_the_change_not_the_prompt() {
11922 let state = review_state(&["feat: the change", "fix: typo"]);
11923 let source = landing_subject_source(&state);
11924 assert_eq!(land::merge_subject("", &source), "feat: the change");
11925 assert_eq!(
11926 land::merge_subject("magi: candidate A (uncommitted work)", &source),
11927 "feat: the change"
11928 );
11929 assert_eq!(
11931 land::merge_subject("feat: renamed by hand", &source),
11932 "feat: renamed by hand"
11933 );
11934 let blank = review_state(&["日本語"]);
11935 assert!(
11936 land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11937 );
11938 }
11939
11940 #[test]
11941 fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11942 let state = review_state(&["feat: the change"]);
11943 let source = landing_subject_source(&state);
11944 let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11945 assert_eq!(
11946 land::merge_subject(landing_title(&state, old), &source),
11947 "feat: the change"
11948 );
11949 assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11950 let task = state_with_summary("add retries", "");
11951 assert_eq!(landing_title(&task, old), old);
11952 }
11953
11954 #[test]
11955 fn pr_message_describes_the_change_not_the_task() {
11956 let state = state_with_summary(
11957 "今回やってほしいこと: results projector を直す",
11958 "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11959 );
11960 let m = pr_message(&state, 'A');
11961 assert_eq!(m.title, "fix(web): batch the runs list reads");
11962 assert!(
11963 m.body.starts_with("## Summary\n\n- reads run.json once"),
11964 "{}",
11965 m.body
11966 );
11967 assert!(!m.body.contains("TITLE:"), "{}", m.body);
11968 let task_at = m.body.find("今回やってほしいこと").unwrap();
11969 let details_at = m.body.find("<details>").unwrap();
11970 assert!(
11971 details_at < task_at,
11972 "the task lives inside <details>: {}",
11973 m.body
11974 );
11975 assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11976 assert!(m.body.contains("magi:candidate-a"));
11977 }
11978
11979 #[test]
11980 fn pr_message_falls_back_to_the_task_without_a_title_line() {
11981 let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11982 let m = pr_message(&state, 'A');
11983 assert_eq!(m.title, "add retries");
11984 assert!(
11985 m.body.contains("## Summary\n\n- did some things"),
11986 "{}",
11987 m.body
11988 );
11989
11990 let none = RunState::new(
11991 PathBuf::from("/repo"),
11992 "main".to_owned(),
11993 "abc1234".to_owned(),
11994 "add retries".to_owned(),
11995 Config::default(),
11996 );
11997 let m = pr_message(&none, 'A');
11998 assert_eq!(m.title, "add retries");
11999 assert!(!m.body.contains("## Summary"), "{}", m.body);
12000 }
12001
12002 #[test]
12003 fn pr_message_refuses_the_candidate_commit_subject() {
12004 for bad in [
12005 "TITLE: magi: candidate A (uncommitted work)",
12006 "TITLE: chore: stuff (uncommitted work)",
12007 "TITLE: ",
12008 ] {
12009 let state = state_with_summary("add retries", bad);
12010 assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
12011 }
12012 }
12013
12014 #[test]
12015 fn pr_message_bounds_a_very_long_task_and_title() {
12016 let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
12017 let state = state_with_summary(&long, "- nothing");
12018 let m = pr_message(&state, 'A');
12019 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12020 assert!(!m.title.contains('\n'));
12021
12022 let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12023 let m = pr_message(&state, 'A');
12024 assert!(m.title.starts_with("feat: "));
12025 assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12026 assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12027 }
12028
12029 fn long_title_of(instruction: &str) -> String {
12030 pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12031 }
12032
12033 #[test]
12034 fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12035 let first = "Make the landing path keep a readable title for long tasks";
12036 let line = format!(
12037 "{first}. {}",
12038 "And then keep going with more words ".repeat(20)
12039 );
12040 let t = long_title_of(&line);
12041 assert_eq!(t, first);
12042 assert!(!t.starts_with("chore: land"));
12043 }
12044
12045 #[test]
12046 fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12047 let line = "word ".repeat(200);
12048 let t = long_title_of(&line);
12049 assert!(t.ends_with("word..."), "{t}");
12050 assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12051 }
12052
12053 #[test]
12054 fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12055 for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12056 assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12057 }
12058 }
12059
12060 #[test]
12061 fn pr_message_title_limit_is_exact() {
12062 let at = "a".repeat(PR_TITLE_MAX);
12063 assert_eq!(long_title_of(&at), at);
12064 let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12065 assert!(over.ends_with("..."), "{over}");
12066 assert_eq!(over.chars().count(), PR_TITLE_MAX);
12067 }
12068
12069 #[test]
12070 fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12071 let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12072 let t = long_title_of(&line);
12073 assert!(t.ends_with("..."), "{t}");
12074 assert!(t.is_ascii(), "{t}");
12075 }
12076
12077 #[test]
12078 fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12079 let line = format!(
12080 "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12081 "plus more filler words ".repeat(20)
12082 );
12083 let t = long_title_of(&line);
12084 assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12085 }
12086
12087 #[test]
12088 fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12089 let line = format!("\n\n# {}", "title words ".repeat(40));
12090 let state = state_with_summary(&line, "- nothing");
12091 let m = pr_message(&state, 'A');
12092 assert!(m.title.starts_with("title words"), "{}", m.title);
12093 assert_eq!(
12094 land::merge_subject(&m.title, &landing_subject_source(&state)),
12095 m.title
12096 );
12097 assert_eq!(
12099 land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12100 "feat: renamed by hand"
12101 );
12102 }
12103
12104 #[tokio::test]
12105 async fn github_text_rewrite_is_bounded_and_falls_back() {
12106 crate::run::pin_test_home();
12107 for (reply, accepted) in [
12108 (
12109 "TITLE: fix: retries\nAdd retries for failed requests.",
12110 true,
12111 ),
12112 (
12113 "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12114 false,
12115 ),
12116 ("TITLE: fix: retries\nUse token=secret", false),
12117 ] {
12118 let dir = tempfile::tempdir().unwrap();
12119 let mut runner = runner_at(RunStatus::Gating);
12120 runner.state = state_with_summary(
12121 "add retries",
12122 "TITLE: fix: retries\n日本語の説明を書きます。",
12123 );
12124 runner.state.repo = dir.path().to_owned();
12125 runner.state.candidates[0].worktree = dir.path().to_owned();
12126 let mut author = spec("alpha");
12127 author.command = vec![
12128 "sh".into(),
12129 "-c".into(),
12130 "printf '%s' \"$REWRITE_REPLY\"".into(),
12131 ];
12132 author.env.insert("REWRITE_REPLY".into(), reply.into());
12133 runner.state.config.agents = vec![author];
12134 let winner = runner.state.candidates[0].clone();
12135 let first = runner
12136 .guarded_pr_message(&winner, None, true)
12137 .await
12138 .unwrap();
12139 let message = if accepted {
12140 first.expect("a passing rewrite never asks")
12141 } else {
12142 assert!(first.is_none());
12144 assert!(runner.state.parked);
12145 let store = ask::Questions::open();
12146 let asked: Vec<_> = store
12147 .list()
12148 .into_iter()
12149 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12150 .collect();
12151 assert_eq!(asked.len(), 1);
12152 assert!(!asked[0].detail.contains("secret"));
12153 store
12156 .update(&asked[0].id, |q| {
12157 q.abandon("test");
12158 Ok(())
12159 })
12160 .unwrap();
12161 let again = runner
12162 .guarded_pr_message(&winner, None, true)
12163 .await
12164 .unwrap()
12165 .expect("an abandoned question falls back");
12166 let n = store
12167 .list()
12168 .into_iter()
12169 .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12170 .count();
12171 assert_eq!(n, 1, "asked once per rejected text");
12172 again
12173 };
12174 assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12175 assert_eq!(
12176 message.body.contains("Add retries for failed requests."),
12177 accepted
12178 );
12179 assert_eq!(
12180 runner.state.seats["impl-A"].turns, 1,
12181 "only one rewrite invocation"
12182 );
12183 assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12184 if !accepted {
12185 assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12186 assert!(
12187 message
12188 .body
12189 .contains(&format!("magi:run/{}", runner.state.id))
12190 );
12191 }
12192 }
12193 }
12194
12195 #[tokio::test]
12196 async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12197 crate::run::pin_test_home();
12198 let dir = tempfile::tempdir().unwrap();
12199 let mut runner = runner_at(RunStatus::Gating);
12200 runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12201 runner.state.repo = dir.path().to_owned();
12202 runner.state.candidates[0].worktree = dir.path().to_owned();
12203 let winner = runner.state.candidates[0].clone();
12204 assert!(
12205 runner
12206 .guarded_pr_message(&winner, None, true)
12207 .await
12208 .unwrap()
12209 .is_none()
12210 );
12211 let store = ask::Questions::open();
12212 let find = |run: &str| {
12213 store
12214 .list()
12215 .into_iter()
12216 .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12217 .unwrap()
12218 };
12219 let run = runner.state.id.clone();
12220 let q = find(&run);
12221 let fp = runner
12222 .state
12223 .github_text
12224 .as_ref()
12225 .unwrap()
12226 .fingerprint
12227 .clone();
12228 let name = crate::github_text::artifact_name(&fp);
12229 let path = crate::run::artifact_path(&runner.state, &name);
12230 assert!(q.detail.contains(&path.display().to_string()));
12231 assert!(q.detail.contains("title-language: title"));
12232 let saved = std::fs::read_to_string(&path).expect("artifact written");
12233 assert!(saved.contains("日本語のタイトル"));
12234 assert!(q.cwd.is_none());
12235 assert_eq!(q.choices, ["use fallback", "use my text"]);
12236 let say = |id: &str, text: &str, choice: &str| {
12238 store
12239 .update(id, |q| {
12240 q.thread.push(ask::Turn {
12241 who: ask::Who::Operator,
12242 body: text.to_owned(),
12243 at: jiff::Timestamp::now(),
12244 note: None,
12245 });
12246 q.answer(ask::Answer::Choice(choice.to_owned()))
12247 })
12248 .unwrap();
12249 };
12250 say(&q.id, "まだ日本語", "use my text");
12251 assert!(
12252 runner
12253 .guarded_pr_message(&winner, None, true)
12254 .await
12255 .unwrap()
12256 .is_none()
12257 );
12258 let second = find(&run);
12259 assert_ne!(second.id, q.id);
12260 assert!(second.detail.contains("did not pass"));
12261 assert!(second.detail.contains("まだ日本語"));
12262 let retry_path = crate::run::artifact_path(
12263 &runner.state,
12264 &crate::github_text::artifact_name(&crate::github_text::fingerprint("まだ日本語", "")),
12265 );
12266 assert!(second.detail.contains(&retry_path.display().to_string()));
12267 assert!(
12268 std::fs::read_to_string(&retry_path)
12269 .expect("retry artifact written")
12270 .contains("まだ日本語")
12271 );
12272 say(&second.id, "fix: retry failed requests", "use my text");
12273 let message = runner
12274 .guarded_pr_message(&winner, None, true)
12275 .await
12276 .unwrap()
12277 .expect("a vetted replacement is posted");
12278 assert_eq!(message.title, "fix: retry failed requests");
12279 assert_eq!(
12280 runner
12281 .state
12282 .github_text
12283 .as_ref()
12284 .unwrap()
12285 .chosen_title
12286 .as_deref(),
12287 Some("fix: retry failed requests")
12288 );
12289 assert!(runner.state.github_text.as_ref().unwrap().resolved);
12290 let again = runner
12292 .guarded_pr_message(&winner, None, true)
12293 .await
12294 .unwrap()
12295 .unwrap();
12296 assert_eq!(again.title, message.title);
12297 assert_eq!(
12298 store
12299 .list()
12300 .into_iter()
12301 .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12302 .count(),
12303 2
12304 );
12305 }
12306
12307 #[test]
12308 fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12309 let mut state = state_with_summary(
12313 "add retries",
12314 "TITLE: fix(web): batch reads\n- reads run.json once",
12315 );
12316 state.config.graph.language = "ja".to_owned();
12317 let m = pr_message(&state, 'A');
12318 assert!(crate::github_text::check(&m.title, &m.body).is_empty());
12319 assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
12320
12321 let task = "今回やってほしいこと: results projector を直す";
12324 let mut state = state_with_summary(task, "- no title line");
12325 state.config.graph.language = "ja".to_owned();
12326 let m = pr_message(&state, 'A');
12327 assert_eq!(
12328 m.title,
12329 format!("chore: land candidate A of run {}", state.id)
12330 );
12331 assert!(
12332 m.body.contains(&format!(
12333 "<summary>Original task</summary>\n\n{task}\n\n</details>"
12334 )),
12335 "{}",
12336 m.body
12337 );
12338 }
12339
12340 #[test]
12341 fn pr_message_scrubs_home_paths_and_addresses() {
12342 let state = state_with_summary(
12343 "fix it in /Users/someone/src/x",
12344 "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
12345 );
12346 let m = pr_message(&state, 'A');
12347 for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
12348 assert!(!m.body.contains(leak), "{}", m.body);
12349 }
12350 assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
12351 }
12352
12353 #[test]
12354 fn pr_message_survives_a_task_that_closes_details() {
12355 let state = state_with_summary("a </details> b", "TITLE: fix: x");
12356 let m = pr_message(&state, 'A');
12357 assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
12358 }
12359
12360 #[test]
12361 fn manual_squash_subject_cannot_break_out_of_its_quotes() {
12362 let cmd = manual_merge_command(
12363 MergeStyle::Squash,
12364 Path::new("/repo"),
12365 "b",
12366 "fix: \"quoted\" $(x) `y`\n\nbody",
12367 );
12368 assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
12369 }
12370
12371 #[test]
12372 fn manual_merge_command_matches_the_configured_style() {
12373 let repo = Path::new("/repo");
12374 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
12375
12376 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
12377 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
12378
12379 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
12380 assert_eq!(
12381 squash,
12382 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
12383 \"Merge magi run 0832 (candidate A)\""
12384 );
12385
12386 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
12387 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
12388 }
12389
12390 #[test]
12391 fn a_nudge_gets_a_quarter_of_the_budget() {
12392 assert_eq!(retry_budget(secs(1200), true), secs(300));
12394 assert_eq!(retry_budget(secs(3600), true), secs(900));
12395 }
12396
12397 #[test]
12398 fn a_resent_prompt_keeps_the_whole_budget() {
12399 assert_eq!(retry_budget(secs(1200), false), secs(1200));
12402 assert_eq!(retry_budget(secs(60), false), secs(60));
12403 }
12404
12405 #[test]
12406 fn the_floor_never_exceeds_the_original_budget() {
12407 assert_eq!(retry_budget(secs(60), true), secs(60));
12411 assert_eq!(retry_budget(secs(480), true), secs(120));
12412 assert_eq!(retry_budget(secs(0), true), secs(0));
12413 }
12414
12415 fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12416 agent::CommandEvidence {
12417 id: "item1".to_owned(),
12418 description: "cargo test".to_owned(),
12419 exit_code,
12420 result_summary: String::new(),
12421 source: "codex".to_owned(),
12422 }
12423 }
12424
12425 #[test]
12426 fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12427 assert!(!has_unconfirmed_command(&[]));
12431 }
12432
12433 #[test]
12434 fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12435 assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12439 assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12440 assert!(!has_unconfirmed_command(&[
12441 evidence(Some(0)),
12442 evidence(Some(101))
12443 ]));
12444 }
12445
12446 #[test]
12447 fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12448 assert!(has_unconfirmed_command(&[
12449 evidence(Some(0)),
12450 evidence(None)
12451 ]));
12452 }
12453
12454 #[test]
12455 fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12456 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12457 assert_eq!(
12458 verified_noop_claim(true, &[], text).as_deref(),
12459 Some("already fixed by b32cfc4, on main.")
12460 );
12461 }
12462
12463 #[test]
12464 fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12465 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12468 assert!(verified_noop_claim(false, &[], text).is_none());
12469 }
12470
12471 #[test]
12472 fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12473 let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12474 assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12475 assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12477 }
12478
12479 #[test]
12480 fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12481 assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12482 }
12483
12484 fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12487 runner.state.candidates = shape
12488 .iter()
12489 .enumerate()
12490 .map(|(i, &(empty, verified))| Candidate {
12491 index: i,
12492 label: (b'A' + i as u8) as char,
12493 agent: "sonnet".to_owned(),
12494 branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12495 worktree: PathBuf::from(format!("/wt/{i}")),
12496 summary: String::new(),
12497 stat: String::new(),
12498 files: 0,
12499 commits: 0,
12500 empty,
12501 failed: None,
12502 verified_noop: verified.map(str::to_owned),
12503 duration_ms: 0,
12504 folded: false,
12505 })
12506 .collect();
12507 }
12508
12509 #[test]
12510 fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12511 ask_test_home();
12512 let mut runner = runner_at(RunStatus::Implementing);
12513 set_candidates(
12514 &mut runner,
12515 &[
12516 (true, Some("already on main at b32cfc4")),
12517 (true, Some("same fix, see the existing test")),
12518 ],
12519 );
12520
12521 runner
12522 .after_implement()
12523 .expect("a verified no-op is not an error");
12524
12525 assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12526 }
12527
12528 #[test]
12529 fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12530 ask_test_home();
12531 let mut runner = runner_at(RunStatus::Implementing);
12532 set_candidates(
12536 &mut runner,
12537 &[(true, Some("already on main at b32cfc4")), (true, None)],
12538 );
12539
12540 let err = runner
12541 .after_implement()
12542 .expect_err("an unverified empty candidate must still fail the run");
12543
12544 assert!(
12545 err.to_string().contains("no candidate produced a change"),
12546 "{err}"
12547 );
12548 assert_eq!(runner.state.status, RunStatus::Failed);
12549 }
12550
12551 #[test]
12552 fn after_implement_still_fails_an_ordinary_all_empty_run() {
12553 ask_test_home();
12554 let mut runner = runner_at(RunStatus::Implementing);
12555 set_candidates(&mut runner, &[(true, None), (true, None)]);
12556
12557 let err = runner
12558 .after_implement()
12559 .expect_err("no candidate declared anything; this is an ordinary failure");
12560
12561 assert!(
12562 err.to_string().contains("no candidate produced a change"),
12563 "{err}"
12564 );
12565 assert_eq!(runner.state.status, RunStatus::Failed);
12566 }
12567}