1use std::path::{Path, PathBuf};
28
29use anyhow::Result;
30
31use crate::git;
32use crate::run::{Liveness, RunState, RunStatus};
33
34#[derive(Debug, Clone)]
36pub struct Takeover {
37 pub earlier: Vec<String>,
40 pub home: PathBuf,
42 pub choice: Option<crate::reconcile::Choice>,
46 pub task_instruction: Option<String>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Refused {
61 Foreign {
63 branch: String,
65 path: String,
67 why: String,
69 },
70 Unsafe {
72 branch: String,
74 path: String,
76 why: String,
78 },
79 ReleaseFailed {
81 branch: String,
83 path: String,
85 run: String,
87 },
88}
89
90impl std::fmt::Display for Refused {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match self {
93 Refused::Foreign { branch, path, why } => write!(
94 f,
95 "branch `{branch}` is checked out in {path}, which magi will not remove by \
96 itself: {why}. Remove that worktree (`git worktree remove`) if it is not \
97 needed, and try again."
98 ),
99 Refused::Unsafe { branch, path, why } => write!(
100 f,
101 "branch `{branch}` is checked out in {path}: {why}. Commit or discard the work \
102 there and remove that worktree (`git worktree remove`), or say the run may be \
103 discarded, and try again."
104 ),
105 Refused::ReleaseFailed { branch, path, run } => write!(
106 f,
107 "branch `{branch}` is checked out in {path} by run {run}, and releasing that \
108 worktree failed or found it changed (git refuses to remove a worktree with \
109 uncommitted changes); it was left as it was"
110 ),
111 }
112 }
113}
114
115impl std::error::Error for Refused {}
116
117#[derive(Debug, Clone)]
120pub struct Released {
121 pub old_id: String,
123 index: usize,
124 path: PathBuf,
125 home: PathBuf,
126 successor: String,
127 tip: String,
130 pub audit: String,
132}
133
134impl Released {
135 pub async fn restore(&self, repo: &Path, branch: &str) {
139 let Ok(_owner) = crate::pr_head::owner_lock(&self.home, &self.old_id) else {
140 tracing::warn!("could not lock run {} for handover rollback", self.old_id);
141 return;
142 };
143 let Ok(mut state) = RunState::load_under(&self.old_id, &self.home) else {
144 return;
145 };
146 let claimed =
147 crate::daemon::is_working_on(&self.home, &self.old_id, jiff::Timestamp::now());
148 if state.released_to.as_deref() != Some(self.successor.as_str())
149 || state.status == RunStatus::Superseded
150 || state.pr_taken_over_by.is_some()
151 || state.liveness_under(&self.home, claimed) != Liveness::Dead
152 {
153 tracing::warn!(
154 "run {} changed ownership; handover rollback refused",
155 self.old_id
156 );
157 return;
158 }
159 let path = self.path.to_string_lossy().to_string();
160 let refname = format!("refs/heads/{branch}");
163 if git::rev_parse(repo, &refname).await.ok().as_deref() != Some(self.tip.as_str())
164 && let Err(e) = git::git(repo, &["branch", "-f", branch, &self.tip]).await
165 {
166 tracing::warn!("could not put `{branch}` back at {}: {e:#}", self.tip);
167 return;
168 }
169 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
170 tracing::warn!(
171 "could not put run {}'s worktree back at {path}: {e:#}",
172 self.old_id
173 );
174 return;
175 }
176 if let Some(c) = state.candidates.get_mut(self.index) {
177 c.folded = false;
178 }
179 state.released_to = None;
180 state.released_branches.retain(|b| b != branch);
181 state.events.pop();
182 let put_back = state.save_under(&self.home);
183 if let Err(e) = put_back {
184 tracing::warn!("could not unmark run {}: {e:#}", self.old_id);
185 }
186 }
187}
188
189#[derive(Debug, Clone)]
191pub struct Holder {
192 pub run: String,
194 pub status: RunStatus,
196 pub liveness: Liveness,
198 pub driver_unproven: bool,
201 pub dirty: bool,
203 pub dirty_files: Vec<String>,
205 pub driver_pid: Option<u32>,
207 pub head: String,
209 pub tip: String,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum Owner {
216 EarlierAttempt,
218 MagiRun,
221 Foreign(String),
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum Decision {
228 Release,
230 Refuse(String),
232}
233
234fn short(sha: &str) -> String {
235 sha.chars().take(7).collect()
236}
237
238pub fn decide(owner: &Owner, holder: &Holder) -> Decision {
244 if let Owner::Foreign(why) = owner {
245 return Decision::Refuse(why.clone());
246 }
247 let mut why = Vec::new();
248 if holder.liveness == Liveness::Live {
249 why.push(format!(
250 "run {} is being worked on right now{}",
251 crate::run::short_of(&holder.run),
252 holder
253 .driver_pid
254 .map(|p| format!(" (driver pid {p})"))
255 .unwrap_or_default()
256 ));
257 } else if holder.liveness == Liveness::Unknown || holder.driver_unproven {
258 why.push("its driver process could not be shown to be gone".to_owned());
259 }
260 if holder.status == RunStatus::Landing {
261 why.push("that run is in `landing`, possibly waiting on an approval".to_owned());
262 }
263 if holder.dirty {
264 let files = holder.dirty_files.join(", ");
265 why.push(format!("its worktree has uncommitted changes ({files})"));
266 }
267 if holder.head != holder.tip {
268 why.push("its HEAD is not at the branch tip".to_owned());
269 }
270 if why.is_empty() {
271 return Decision::Release;
272 }
273 let kind = match owner {
274 Owner::EarlierAttempt => "an earlier attempt at this task",
275 _ => "a magi run of another task",
276 };
277 Decision::Refuse(format!(
278 "run {} (status `{}`, worktree {}, HEAD {}, branch tip {}) is {kind} and still has the \
279 branch checked out, so it was not released automatically: {}",
280 crate::run::short_of(&holder.run),
281 holder.status.as_str(),
282 if holder.dirty { "dirty" } else { "clean" },
283 short(&holder.head),
284 short(&holder.tip),
285 why.join("; ")
286 ))
287}
288
289async fn inspect(
291 repo: &Path,
292 branch: &str,
293 path: &Path,
294 state: &RunState,
295 home: &Path,
296) -> Result<Holder> {
297 let claimed = crate::daemon::is_working_on(home, &state.id, jiff::Timestamp::now());
298 let liveness = state.liveness_under(home, claimed);
299 let porcelain = git::git(path, &["status", "--porcelain", "--untracked-files=normal"]).await?;
302 Ok(Holder {
303 run: state.id.clone(),
304 status: state.status,
305 liveness,
306 driver_unproven: liveness == Liveness::Unknown,
307 dirty: !porcelain.trim().is_empty(),
308 dirty_files: porcelain
309 .lines()
310 .take(5)
311 .map(|l| l.get(3..).unwrap_or(l).trim().to_owned())
312 .collect(),
313 driver_pid: state.driver_pid,
314 head: git::rev_parse(path, "HEAD").await?,
315 tip: git::rev_parse(repo, &format!("refs/heads/{branch}")).await?,
316 })
317}
318
319fn same_path(a: &Path, b: &Path) -> bool {
320 let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
321 canon(a) == canon(b)
322}
323
324fn foreign(branch: &str, path: &Path, why: &str) -> anyhow::Error {
325 Refused::Foreign {
326 branch: branch.to_owned(),
327 path: path.display().to_string(),
328 why: why.to_owned(),
329 }
330 .into()
331}
332
333fn find_magi_owner(
338 path: &Path,
339 home: &Path,
340) -> std::result::Result<Option<(RunState, usize)>, String> {
341 let Some(bay) = path.parent() else {
342 return Ok(None);
343 };
344 let Some(bay_name) = bay.file_name().and_then(|n| n.to_str()) else {
345 return Ok(None);
346 };
347 let mut found = Vec::new();
348 for id in crate::run::list_ids_in(&home.join("runs")) {
349 if crate::run::short_of(&id) != bay_name {
350 continue;
351 }
352 let Ok(state) = RunState::load_under(&id, home) else {
353 return Err(format!("run {bay_name}'s record could not be read"));
354 };
355 if !same_path(&state.worktree_root(), bay) {
356 continue;
357 }
358 if let Some(i) = state
359 .candidates
360 .iter()
361 .position(|c| !c.folded && same_path(&c.worktree, path))
362 {
363 found.push((state, i));
364 }
365 }
366 match found.len() {
367 0 => Ok(None),
368 1 => Ok(found.pop()),
369 _ => Err(format!("more than one run record claims it ({bay_name})")),
370 }
371}
372
373pub(crate) fn landing_checkpoint(state: &RunState) -> bool {
376 state.status.resumable()
377 && state.config.graph.land
378 && state.pr.as_ref().is_some_and(|pr| pr.state == "open")
379 && state
380 .merge
381 .as_ref()
382 .is_some_and(|m| m.mode == crate::config::MergeMode::Pr && !m.empty)
383}
384
385pub(crate) fn can_resume(state: &RunState) -> bool {
390 let review = crate::graph::review_conclusion(&state.reviews, state.config.graph.review_rounds);
391 state.status.resumable()
392 && !state.released()
393 && state.retired_to.is_none()
394 && state.pr_taken_over_by.is_none()
395 && (resume_checkpoint(state)
396 || crate::run::try_home().is_some_and(|home| has_resume_checkpoint(state, &home))
397 || ((state.config.graph.review_rounds == 0
398 || state.reviews.iter().any(|round| round.clean))
399 && (!state.gate_ran || state.resource_block.is_some()))
400 || (state.resource_block.is_some() && review == Some(RunStatus::Gating))
401 || (review.is_none()
402 && state.reviews.last().is_some_and(|round| {
403 round.e2e_status() == crate::run::E2eStatus::ResourceBlocked
404 }))
405 || !(state.status == RunStatus::Blocked
406 && state.reviews.len() >= state.config.graph.review_rounds))
407}
408
409fn resume_checkpoint(state: &RunState) -> bool {
410 state.status == RunStatus::Landing
411 || landing_checkpoint(state)
412 || state.pr_publication_pending
413 || state.land_approval.is_some()
414 || state.merge_decision.is_some()
415 || state.land_armed_head.is_some()
416 || !state.merge_repairs.is_empty()
417}
418
419pub(crate) fn has_resume_checkpoint(state: &RunState, home: &Path) -> bool {
423 state.status.resumable()
424 && (resume_checkpoint(state)
425 || crate::ask::Questions::at(home.join("questions"))
426 .list()
427 .iter()
428 .any(|q| q.run == state.id && q.status.open()))
429}
430
431fn locked_holder(
434 home: &Path,
435 id: &str,
436 path: &Path,
437) -> Result<(RunState, usize, crate::pr_head::OwnerLock)> {
438 let owner = crate::pr_head::owner_lock(home, id)?;
439 let state = RunState::load_under(id, home)?;
440 let index = state
441 .candidates
442 .iter()
443 .position(|c| !c.folded && same_path(&c.worktree, path))
444 .ok_or_else(|| anyhow::anyhow!("worktree holder changed; retry takeover"))?;
445 anyhow::ensure!(
446 state.released_to.is_none(),
447 "run already handed over its worktree; retry takeover"
448 );
449 anyhow::ensure!(
450 path.parent()
451 .is_some_and(|p| same_path(&state.worktree_root(), p)),
452 "worktree holder root changed; retry takeover"
453 );
454 Ok((state, index, owner))
455}
456
457pub async fn release(
465 repo: &Path,
466 branch: &str,
467 new_run: &str,
468 takeover: &Takeover,
469) -> Result<Option<Released>> {
470 let Some(path) = git::worktree_holding(repo, branch).await? else {
471 return Ok(None);
472 };
473 let mut owner = None;
476 for id in &takeover.earlier {
477 let Ok(state) = RunState::load_under(id, &takeover.home) else {
478 continue;
479 };
480 if let Some(i) = state
481 .candidates
482 .iter()
483 .position(|c| !c.folded && same_path(&c.worktree, &path))
484 {
485 if !path
487 .parent()
488 .is_some_and(|b| same_path(&state.worktree_root(), b))
489 {
490 return Err(foreign(
491 branch,
492 &path,
493 &format!(
494 "run {} records it, but it is outside that run's worktree root, so \
495 magi did not make it",
496 crate::run::short_of(id)
497 ),
498 ));
499 }
500 owner = Some((state, i, Owner::EarlierAttempt));
501 break;
502 }
503 }
504 if owner.is_none() {
505 owner = match find_magi_owner(&path, &takeover.home) {
506 Ok(found) => found.map(|(s, i)| (s, i, Owner::MagiRun)),
507 Err(why) => return Err(foreign(branch, &path, &why)),
508 };
509 }
510 let Some((state, _, kind)) = owner else {
511 return Err(foreign(
512 branch,
513 &path,
514 &format!(
515 "{} is not a candidate worktree any magi run recorded (made by hand, or by \
516 something other than magi)",
517 path.display()
518 ),
519 ));
520 };
521
522 let (mut state, index, _owner) = locked_holder(&takeover.home, &state.id, &path)?;
523 if has_resume_checkpoint(&state, &takeover.home) {
524 return Err(Refused::Unsafe {
525 branch: branch.to_owned(),
526 path: path.display().to_string(),
527 why: format!("run {} has a resumable publication, landing or decision checkpoint; resume that run instead of releasing its worktree", state.short()),
528 }.into());
529 }
530 let holder = inspect(repo, branch, &path, &state, &takeover.home).await?;
531 if let Decision::Refuse(why) = decide(&kind, &holder) {
532 return Err(Refused::Unsafe {
533 branch: branch.to_owned(),
534 path: path.display().to_string(),
535 why,
536 }
537 .into());
538 }
539 let audit = format!(
540 "run {} (status `{}`, no driver, clean, HEAD {} = branch tip, branch `{branch}` kept)",
541 crate::run::short_of(&holder.run),
542 holder.status.as_str(),
543 short(&holder.head)
544 );
545
546 let old_id = state.id.clone();
547 state.candidates[index].folded = true;
548 state.released_to = Some(new_run.to_owned());
549 if !state.released_branches.iter().any(|b| b == branch) {
550 state.released_branches.push(branch.to_owned());
551 }
552 state.event(
553 "release",
554 format!(
555 "worktree of `{branch}` released to run {}: {audit}; this run can no longer be \
556 resumed from here",
557 crate::run::short_of(new_run)
558 ),
559 );
560 state.save_under(&takeover.home)?;
561
562 let again = match RunState::load_under(&old_id, &takeover.home) {
570 Ok(fresh) => inspect(repo, branch, &path, &fresh, &takeover.home).await,
571 Err(e) => Err(e),
572 };
573 let safe = matches!(&again, Ok(h) if decide(&kind, h) == Decision::Release);
574 let removed = safe
575 && git::worktree_remove_clean(repo, &path)
576 .await
577 .unwrap_or(false);
578 if !removed || path.exists() {
579 state = RunState::load_under(&old_id, &takeover.home)?;
580 state.candidates[index].folded = false;
581 state.released_to = None;
582 state.released_branches.retain(|b| b != branch);
583 state.events.pop();
584 state.event(
585 "release",
586 format!(
587 "release of `{branch}` to run {} was undone: {audit}",
588 crate::run::short_of(new_run)
589 ),
590 );
591 state.save_under(&takeover.home)?;
592 return Err(Refused::ReleaseFailed {
593 branch: branch.to_owned(),
594 path: path.display().to_string(),
595 run: crate::run::short_of(&old_id).to_owned(),
596 }
597 .into());
598 }
599 Ok(Some(Released {
600 old_id,
601 index,
602 path,
603 home: takeover.home.clone(),
604 successor: new_run.to_owned(),
605 tip: holder.tip,
606 audit,
607 }))
608}
609
610#[cfg(test)]
611mod tests {
612 #[test]
613 fn refused_display_is_the_english_text() {
614 let r = Refused::Foreign {
615 branch: "b".into(),
616 path: "/w".into(),
617 why: "w".into(),
618 };
619 assert_eq!(
620 r.to_string(),
621 "branch `b` is checked out in /w, which magi will not remove by itself: w. Remove \
622 that worktree (`git worktree remove`) if it is not needed, and try again."
623 );
624 }
625
626 use super::*;
627
628 fn holder() -> Holder {
629 Holder {
630 run: "20260901-000000-f82f".to_owned(),
631 status: RunStatus::Gating,
632 liveness: Liveness::Dead,
633 driver_unproven: false,
634 dirty: false,
635 dirty_files: Vec::new(),
636 driver_pid: None,
637 head: "a".repeat(40),
638 tip: "a".repeat(40),
639 }
640 }
641
642 fn recorded_holder(home: &Path) -> (RunState, PathBuf) {
643 let mut state = RunState::new(
644 "repo".into(),
645 "main".into(),
646 "base".into(),
647 "task".into(),
648 crate::config::Config::default(),
649 );
650 state.config.graph.worktree_root = Some(home.join("worktrees"));
651 let path = state.worktree_root().join("cand-A");
652 std::fs::create_dir_all(&path).unwrap();
653 state.driver_exited = true;
654 state.candidates.push(crate::run::Candidate {
655 index: 0,
656 label: 'A',
657 agent: "existing".into(),
658 branch: "branch".into(),
659 worktree: path.clone(),
660 summary: String::new(),
661 stat: String::new(),
662 files: 1,
663 commits: 1,
664 empty: false,
665 failed: None,
666 verified_noop: None,
667 duration_ms: 0,
668 folded: false,
669 });
670 state.save_under(home).unwrap();
671 (state, path)
672 }
673
674 #[test]
675 fn handover_reloads_a_driver_claim_after_the_scan_and_excludes_new_claims() {
676 let home = tempfile::tempdir().unwrap();
677 let (mut state, path) = recorded_holder(home.path());
678 let scanned = state.clone();
679 state.driver_exited = false;
681 state.driver_pid = Some(std::process::id());
682 state.driver_started_at = crate::proc::process_started_at(std::process::id());
683 state.begin_pass();
684 state.save_under(home.path()).unwrap();
685 let mut driver = crate::driver::DriverGuard::acquire(home.path(), &state).unwrap();
686 driver.record(&state).unwrap();
687 let (fresh, _, owner) = locked_holder(home.path(), &scanned.id, &path).unwrap();
688 assert!(!fresh.driver_exited);
689 assert_eq!(fresh.passes, state.passes);
690 assert_ne!(fresh.liveness_under(home.path(), false), Liveness::Dead);
691 assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_err());
692 assert!(path.exists());
693 assert!(
694 RunState::load_under(&state.id, home.path())
695 .unwrap()
696 .released_to
697 .is_none()
698 );
699 drop(owner);
700 assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_ok());
701 }
702
703 #[test]
704 fn handover_revalidates_candidate_ownership_under_the_claim_lock() {
705 let home = tempfile::tempdir().unwrap();
706 let (mut state, path) = recorded_holder(home.path());
707 state.candidates[0].folded = true;
708 state.released_to = Some("next".into());
709 state.save_under(home.path()).unwrap();
710 assert!(locked_holder(home.path(), &state.id, &path).is_err());
711 assert!(path.exists());
712 }
713
714 #[tokio::test]
715 async fn a_stale_restore_does_not_undo_a_later_handover() {
716 let home = tempfile::tempdir().unwrap();
717 let (mut state, path) = recorded_holder(home.path());
718 state.candidates[0].folded = true;
719 state.released_to = Some("later".into());
720 state.save_under(home.path()).unwrap();
721 let released = Released {
722 old_id: state.id.clone(),
723 index: 0,
724 path,
725 home: home.path().to_owned(),
726 successor: "original".into(),
727 tip: "tip".into(),
728 audit: String::new(),
729 };
730 released.restore(Path::new("nonexistent"), "branch").await;
731 let disk = RunState::load_under(&state.id, home.path()).unwrap();
732 assert_eq!(disk.released_to.as_deref(), Some("later"));
733 assert!(disk.candidates[0].folded);
734 }
735
736 #[test]
737 fn a_clean_superseded_stale_run_is_released() {
738 assert_eq!(decide(&Owner::EarlierAttempt, &holder()), Decision::Release);
739 let dead = Holder {
740 liveness: Liveness::Dead,
741 ..holder()
742 };
743 assert_eq!(decide(&Owner::EarlierAttempt, &dead), Decision::Release);
744 }
745
746 #[test]
747 fn a_foreign_worktree_is_refused_with_its_reason() {
748 let owner = Owner::Foreign("it is a foreign path".to_owned());
749 assert_eq!(
750 decide(&owner, &holder()),
751 Decision::Refuse("it is a foreign path".to_owned())
752 );
753 }
754
755 #[test]
756 fn landing_is_protected_even_for_an_earlier_attempt() {
757 let landing = Holder {
758 status: RunStatus::Landing,
759 ..holder()
760 };
761 assert!(matches!(
762 decide(&Owner::MagiRun, &landing),
763 Decision::Refuse(_)
764 ));
765 assert!(matches!(
766 decide(&Owner::EarlierAttempt, &landing),
767 Decision::Refuse(_)
768 ));
769 }
770
771 #[test]
772 fn a_dirty_worktree_is_refused_and_says_why() {
773 let dirty = Holder {
774 dirty: true,
775 dirty_files: vec!["scratch.txt".to_owned()],
776 ..holder()
777 };
778 let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &dirty) else {
779 panic!("dirty must be refused");
780 };
781 assert!(
782 why.contains("uncommitted") && why.contains("scratch.txt"),
783 "{why}"
784 );
785 assert!(why.contains("f82f") && why.contains("gating") && why.contains("dirty"));
786 }
787
788 #[test]
789 fn a_live_run_is_refused() {
790 let live = Holder {
791 liveness: Liveness::Live,
792 driver_pid: Some(4242),
793 ..holder()
794 };
795 let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &live) else {
796 panic!("live must be refused");
797 };
798 assert!(
799 why.contains("right now") && why.contains("f82f") && why.contains("4242"),
800 "{why}"
801 );
802 }
803
804 #[test]
805 fn unknown_without_a_recorded_pid_is_not_proof_of_a_stopped_driver() {
806 let holder = Holder {
807 liveness: Liveness::Unknown,
808 ..holder()
809 };
810 assert!(matches!(
811 decide(&Owner::EarlierAttempt, &holder),
812 Decision::Refuse(_)
813 ));
814 }
815
816 #[test]
817 fn a_driver_that_could_not_be_shown_dead_is_refused() {
818 let unproven = Holder {
819 driver_unproven: true,
820 ..holder()
821 };
822 let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &unproven) else {
823 panic!("an unproven driver must be refused");
824 };
825 assert!(why.contains("driver"), "{why}");
826 }
827
828 #[test]
829 fn a_head_off_the_tip_is_refused() {
830 let off = Holder {
831 head: "b".repeat(40),
832 ..holder()
833 };
834 assert!(matches!(
835 decide(&Owner::EarlierAttempt, &off),
836 Decision::Refuse(_)
837 ));
838 }
839}