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_other(&self.home, &self.old_id, jiff::Timestamp::now())
148 .unwrap_or(true);
149 if state.released_to.as_deref() != Some(self.successor.as_str())
150 || state.status == RunStatus::Superseded
151 || state.pr_taken_over_by.is_some()
152 || state.liveness_under(&self.home, claimed) != Liveness::Dead
153 {
154 tracing::warn!(
155 "run {} changed ownership; handover rollback refused",
156 self.old_id
157 );
158 return;
159 }
160 let path = self.path.to_string_lossy().to_string();
161 let refname = format!("refs/heads/{branch}");
164 if git::rev_parse(repo, &refname).await.ok().as_deref() != Some(self.tip.as_str())
165 && let Err(e) = git::git(repo, &["branch", "-f", branch, &self.tip]).await
166 {
167 tracing::warn!("could not put `{branch}` back at {}: {e:#}", self.tip);
168 return;
169 }
170 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
171 tracing::warn!(
172 "could not put run {}'s worktree back at {path}: {e:#}",
173 self.old_id
174 );
175 return;
176 }
177 if let Some(c) = state.candidates.get_mut(self.index) {
178 c.folded = false;
179 }
180 state.released_to = None;
181 state.released_branches.retain(|b| b != branch);
182 state.events.pop();
183 let put_back = state.save_under(&self.home);
184 if let Err(e) = put_back {
185 tracing::warn!("could not unmark run {}: {e:#}", self.old_id);
186 }
187 }
188}
189
190#[derive(Debug, Clone)]
192pub struct Holder {
193 pub run: String,
195 pub status: RunStatus,
197 pub liveness: Liveness,
199 pub driver_unproven: bool,
202 pub dirty: bool,
204 pub dirty_files: Vec<String>,
206 pub driver_pid: Option<u32>,
208 pub head: String,
210 pub tip: String,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum Owner {
217 EarlierAttempt,
219 MagiRun,
222 Foreign(String),
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum Decision {
229 Release,
231 Refuse(String),
233}
234
235fn short(sha: &str) -> String {
236 sha.chars().take(7).collect()
237}
238
239pub fn decide(owner: &Owner, holder: &Holder) -> Decision {
245 if let Owner::Foreign(why) = owner {
246 return Decision::Refuse(why.clone());
247 }
248 let mut why = Vec::new();
249 if holder.liveness == Liveness::Live {
250 why.push(format!(
251 "run {} is being worked on right now{}",
252 crate::run::short_of(&holder.run),
253 holder
254 .driver_pid
255 .map(|p| format!(" (driver pid {p})"))
256 .unwrap_or_default()
257 ));
258 } else if holder.liveness == Liveness::Unknown || holder.driver_unproven {
259 why.push("its driver process could not be shown to be gone".to_owned());
260 }
261 if holder.status == RunStatus::Landing {
262 why.push("that run is in `landing`, possibly waiting on an approval".to_owned());
263 }
264 if holder.dirty {
265 let files = holder.dirty_files.join(", ");
266 why.push(format!("its worktree has uncommitted changes ({files})"));
267 }
268 if holder.head != holder.tip {
269 why.push("its HEAD is not at the branch tip".to_owned());
270 }
271 if why.is_empty() {
272 return Decision::Release;
273 }
274 let kind = match owner {
275 Owner::EarlierAttempt => "an earlier attempt at this task",
276 _ => "a magi run of another task",
277 };
278 Decision::Refuse(format!(
279 "run {} (status `{}`, worktree {}, HEAD {}, branch tip {}) is {kind} and still has the \
280 branch checked out, so it was not released automatically: {}",
281 crate::run::short_of(&holder.run),
282 holder.status.as_str(),
283 if holder.dirty { "dirty" } else { "clean" },
284 short(&holder.head),
285 short(&holder.tip),
286 why.join("; ")
287 ))
288}
289
290async fn inspect(
292 repo: &Path,
293 branch: &str,
294 path: &Path,
295 state: &RunState,
296 home: &Path,
297) -> Result<Holder> {
298 let claimed = crate::daemon::is_working_on_other(home, &state.id, jiff::Timestamp::now())?;
299 let liveness = state.liveness_under(home, claimed);
300 let porcelain = git::git(path, &["status", "--porcelain", "--untracked-files=normal"]).await?;
303 Ok(Holder {
304 run: state.id.clone(),
305 status: state.status,
306 liveness,
307 driver_unproven: liveness == Liveness::Unknown,
308 dirty: !porcelain.trim().is_empty(),
309 dirty_files: porcelain
310 .lines()
311 .take(5)
312 .map(|l| l.get(3..).unwrap_or(l).trim().to_owned())
313 .collect(),
314 driver_pid: state.driver_pid,
315 head: git::rev_parse(path, "HEAD").await?,
316 tip: git::rev_parse(repo, &format!("refs/heads/{branch}")).await?,
317 })
318}
319
320fn same_path(a: &Path, b: &Path) -> bool {
321 let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
322 canon(a) == canon(b)
323}
324
325fn foreign(branch: &str, path: &Path, why: &str) -> anyhow::Error {
326 Refused::Foreign {
327 branch: branch.to_owned(),
328 path: path.display().to_string(),
329 why: why.to_owned(),
330 }
331 .into()
332}
333
334fn find_magi_owner(
339 path: &Path,
340 home: &Path,
341) -> std::result::Result<Option<(RunState, usize)>, String> {
342 let Some(bay) = path.parent() else {
343 return Ok(None);
344 };
345 let Some(bay_name) = bay.file_name().and_then(|n| n.to_str()) else {
346 return Ok(None);
347 };
348 let mut found = Vec::new();
349 for id in crate::run::list_ids_in(&home.join("runs")) {
350 if crate::run::short_of(&id) != bay_name {
351 continue;
352 }
353 let Ok(state) = RunState::load_under(&id, home) else {
354 return Err(format!("run {bay_name}'s record could not be read"));
355 };
356 if !same_path(&state.worktree_root(), bay) {
357 continue;
358 }
359 if let Some(i) = state
360 .candidates
361 .iter()
362 .position(|c| !c.folded && same_path(&c.worktree, path))
363 {
364 found.push((state, i));
365 }
366 }
367 match found.len() {
368 0 => Ok(None),
369 1 => Ok(found.pop()),
370 _ => Err(format!("more than one run record claims it ({bay_name})")),
371 }
372}
373
374pub(crate) fn landing_checkpoint(state: &RunState) -> bool {
377 state.status.resumable()
378 && state.config.graph.land
379 && state.pr.as_ref().is_some_and(|pr| pr.state == "open")
380 && state
381 .merge
382 .as_ref()
383 .is_some_and(|m| m.mode == crate::config::MergeMode::Pr && !m.empty)
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
388pub(crate) enum ResumeRefusal {
389 Status(RunStatus),
390 Released(String),
391 Retired(String),
392 OwnershipTransferred(String),
393 ReviewBudget { used: usize, limit: usize },
394 ReviewDisabled,
395 CleanReviewGateRan,
396 BaseSyncStop,
397}
398
399impl ResumeRefusal {
400 pub(crate) fn successor(&self) -> Option<&str> {
402 match self {
403 Self::Released(to) | Self::Retired(to) | Self::OwnershipTransferred(to) => Some(to),
404 _ => None,
405 }
406 }
407
408 pub(crate) fn diagnostic(&self, state: &RunState) -> String {
409 let reason = match self {
410 Self::BaseSyncStop => "base synchronization is stopped; inspect the retained work. For movement exhaustion only, use `magi run --resume <run> --recover-base` to authorize one additional rebase and fresh verification".into(),
411 Self::Status(status) => format!("status `{}` is final; there is nothing left to resume", status.as_str()),
412 Self::Released(_) => "execution was released".into(),
413 Self::Retired(_) => "execution was retired".into(),
414 Self::OwnershipTransferred(_) => "PR ownership was transferred".into(),
415 Self::ReviewBudget { used, limit } => format!("review budget exhausted ({used}/{limit} rounds used)"),
416 Self::CleanReviewGateRan => "review already has a clean round and the gate already ran; no retryable checkpoint remains".into(),
417 Self::ReviewDisabled => "review is disabled (review_rounds = 0) and no retryable checkpoint or pending gate remains".into(),
418 };
419 let mut detail = format!("run {} cannot resume: {reason}.", state.short());
420 if let Some(target) = self.successor() {
421 let target = if matches!(self, Self::Retired(_)) {
422 target
423 } else {
424 crate::run::short_of(target)
425 };
426 detail.push_str(&format!(" Successor: run {target}; inspect `magi show {target}` before choosing any further action."));
427 }
428 for (label, target) in [
429 ("released to", &state.released_to),
430 ("retired to", &state.retired_to),
431 ("PR ownership moved to", &state.pr_taken_over_by),
432 ] {
433 if let Some(target) = target {
434 if self.successor() == Some(target.as_str()) {
435 continue;
436 }
437 let target = if label == "retired to" {
438 target.as_str()
439 } else {
440 crate::run::short_of(target)
441 };
442 detail.push_str(&format!(" {label} run {target}; inspect `magi show {target}` before choosing any further action."));
443 }
444 }
445 detail.push_str(&format!(
446 " Inspect `magi show {}` for retained work and ownership.",
447 state.id
448 ));
449 if matches!(self, Self::ReviewBudget { .. } | Self::ReviewDisabled) {
450 detail.push_str(" To continue the retained implementation, first confirm its saved candidate branch and owning task with `magi task show <task-id>`. When the task is eligible and no driver is live, explicitly use `magi review <saved-branch> --task <task-id> --merge none --review-rounds <positive-rounds>`. This creates a review-only successor with a new review budget; it does not resume this run or start a new implementation competition. Existing ownership and checkpoint checks still apply.");
451 }
452 detail
453 }
454}
455
456pub(crate) fn can_resume(state: &RunState) -> bool {
460 resume_refusal(state).is_none()
461}
462
463pub(crate) fn resume_refusal(state: &RunState) -> Option<ResumeRefusal> {
464 resume_refusal_under(state, crate::run::try_home().as_deref())
465}
466
467pub(crate) fn resume_refusal_under(state: &RunState, home: Option<&Path>) -> Option<ResumeRefusal> {
469 if !state.status.resumable() {
470 return Some(ResumeRefusal::Status(state.status));
471 }
472 if let Some(to) = &state.released_to {
473 return Some(ResumeRefusal::Released(to.clone()));
474 }
475 if let Some(to) = &state.retired_to {
476 return Some(ResumeRefusal::Retired(to.clone()));
477 }
478 if let Some(to) = &state.pr_taken_over_by {
479 return Some(ResumeRefusal::OwnershipTransferred(to.clone()));
480 }
481 if state.base_sync_blocked() {
482 return if state.base_recovery_pending() || state.base_sync_closure_pending() {
483 None
484 } else {
485 Some(ResumeRefusal::BaseSyncStop)
486 };
487 }
488 let review = crate::graph::review_conclusion(&state.reviews, state.config.graph.review_rounds);
489 let allowed = resume_checkpoint(state)
490 || home.is_some_and(|home| has_resume_checkpoint(state, home))
491 || ((state.config.graph.review_rounds == 0
492 || state.reviews.iter().any(|round| round.clean))
493 && (!state.gate_ran || state.resource_block.is_some()))
494 || (state.resource_block.is_some() && review == Some(RunStatus::Gating))
495 || (review.is_none()
496 && state
497 .reviews
498 .last()
499 .is_some_and(|round| round.e2e_status() == crate::run::E2eStatus::ResourceBlocked))
500 || !(state.status == RunStatus::Blocked
501 && state.reviews.len() >= state.config.graph.review_rounds);
502 if allowed {
503 None
504 } else if state.config.graph.review_rounds == 0 {
505 Some(ResumeRefusal::ReviewDisabled)
506 } else if state.reviews.iter().any(|round| round.clean) {
507 Some(ResumeRefusal::CleanReviewGateRan)
508 } else {
509 Some(ResumeRefusal::ReviewBudget {
510 used: state.reviews.len(),
511 limit: state.config.graph.review_rounds,
512 })
513 }
514}
515
516fn resume_checkpoint(state: &RunState) -> bool {
517 state.status == RunStatus::Landing
518 || landing_checkpoint(state)
519 || state.pr_publication_pending
520 || state.land_approval.is_some()
521 || state.merge_decision.is_some()
522 || state.land_armed_head.is_some()
523 || !state.merge_repairs.is_empty()
524}
525
526pub(crate) fn has_resume_checkpoint(state: &RunState, home: &Path) -> bool {
530 state.status.resumable()
531 && (resume_checkpoint(state)
532 || crate::ask::Questions::at(home.join("questions"))
533 .list()
534 .iter()
535 .any(|q| q.run == state.id && q.status.open()))
536}
537
538fn locked_holder(
541 home: &Path,
542 id: &str,
543 path: &Path,
544) -> Result<(RunState, usize, crate::pr_head::OwnerLock)> {
545 let owner = crate::pr_head::owner_lock(home, id)?;
546 let state = RunState::load_under(id, home)?;
547 let index = state
548 .candidates
549 .iter()
550 .position(|c| !c.folded && same_path(&c.worktree, path))
551 .ok_or_else(|| anyhow::anyhow!("worktree holder changed; retry takeover"))?;
552 anyhow::ensure!(
553 state.released_to.is_none(),
554 "run already handed over its worktree; retry takeover"
555 );
556 anyhow::ensure!(
557 path.parent()
558 .is_some_and(|p| same_path(&state.worktree_root(), p)),
559 "worktree holder root changed; retry takeover"
560 );
561 Ok((state, index, owner))
562}
563
564pub async fn release(
572 repo: &Path,
573 branch: &str,
574 new_run: &str,
575 takeover: &Takeover,
576) -> Result<Option<Released>> {
577 let Some(path) = git::worktree_holding(repo, branch).await? else {
578 return Ok(None);
579 };
580 let mut owner = None;
583 for id in &takeover.earlier {
584 let Ok(state) = RunState::load_under(id, &takeover.home) else {
585 continue;
586 };
587 if let Some(i) = state
588 .candidates
589 .iter()
590 .position(|c| !c.folded && same_path(&c.worktree, &path))
591 {
592 if !path
594 .parent()
595 .is_some_and(|b| same_path(&state.worktree_root(), b))
596 {
597 return Err(foreign(
598 branch,
599 &path,
600 &format!(
601 "run {} records it, but it is outside that run's worktree root, so \
602 magi did not make it",
603 crate::run::short_of(id)
604 ),
605 ));
606 }
607 owner = Some((state, i, Owner::EarlierAttempt));
608 break;
609 }
610 }
611 if owner.is_none() {
612 owner = match find_magi_owner(&path, &takeover.home) {
613 Ok(found) => found.map(|(s, i)| (s, i, Owner::MagiRun)),
614 Err(why) => return Err(foreign(branch, &path, &why)),
615 };
616 }
617 let Some((state, _, kind)) = owner else {
618 return Err(foreign(
619 branch,
620 &path,
621 &format!(
622 "{} is not a candidate worktree any magi run recorded (made by hand, or by \
623 something other than magi)",
624 path.display()
625 ),
626 ));
627 };
628
629 let (mut state, index, _owner) = locked_holder(&takeover.home, &state.id, &path)?;
630 if has_resume_checkpoint(&state, &takeover.home) {
631 return Err(Refused::Unsafe {
632 branch: branch.to_owned(),
633 path: path.display().to_string(),
634 why: format!("run {} has a resumable publication, landing or decision checkpoint; resume that run instead of releasing its worktree", state.short()),
635 }.into());
636 }
637 let holder = inspect(repo, branch, &path, &state, &takeover.home).await?;
638 if let Decision::Refuse(why) = decide(&kind, &holder) {
639 return Err(Refused::Unsafe {
640 branch: branch.to_owned(),
641 path: path.display().to_string(),
642 why,
643 }
644 .into());
645 }
646 let audit = format!(
647 "run {} (status `{}`, no driver, clean, HEAD {} = branch tip, branch `{branch}` kept)",
648 crate::run::short_of(&holder.run),
649 holder.status.as_str(),
650 short(&holder.head)
651 );
652
653 let old_id = state.id.clone();
654 state.candidates[index].folded = true;
655 state.released_to = Some(new_run.to_owned());
656 if !state.released_branches.iter().any(|b| b == branch) {
657 state.released_branches.push(branch.to_owned());
658 }
659 state.event(
660 "release",
661 format!(
662 "worktree of `{branch}` released to run {}: {audit}; this run can no longer be \
663 resumed from here",
664 crate::run::short_of(new_run)
665 ),
666 );
667 state.save_under(&takeover.home)?;
668
669 let again = match RunState::load_under(&old_id, &takeover.home) {
677 Ok(fresh) => inspect(repo, branch, &path, &fresh, &takeover.home).await,
678 Err(e) => Err(e),
679 };
680 let safe = matches!(&again, Ok(h) if decide(&kind, h) == Decision::Release);
681 let removed = safe
682 && git::worktree_remove_clean(repo, &path)
683 .await
684 .unwrap_or(false);
685 if !removed || path.exists() {
686 state = RunState::load_under(&old_id, &takeover.home)?;
687 state.candidates[index].folded = false;
688 state.released_to = None;
689 state.released_branches.retain(|b| b != branch);
690 state.events.pop();
691 state.event(
692 "release",
693 format!(
694 "release of `{branch}` to run {} was undone: {audit}",
695 crate::run::short_of(new_run)
696 ),
697 );
698 state.save_under(&takeover.home)?;
699 return Err(Refused::ReleaseFailed {
700 branch: branch.to_owned(),
701 path: path.display().to_string(),
702 run: crate::run::short_of(&old_id).to_owned(),
703 }
704 .into());
705 }
706 Ok(Some(Released {
707 old_id,
708 index,
709 path,
710 home: takeover.home.clone(),
711 successor: new_run.to_owned(),
712 tip: holder.tip,
713 audit,
714 }))
715}
716
717#[cfg(test)]
718mod tests {
719 #[test]
720 fn refused_display_is_the_english_text() {
721 let r = Refused::Foreign {
722 branch: "b".into(),
723 path: "/w".into(),
724 why: "w".into(),
725 };
726 assert_eq!(
727 r.to_string(),
728 "branch `b` is checked out in /w, which magi will not remove by itself: w. Remove \
729 that worktree (`git worktree remove`) if it is not needed, and try again."
730 );
731 }
732
733 use super::*;
734
735 fn holder() -> Holder {
736 Holder {
737 run: "20260901-000000-f82f".to_owned(),
738 status: RunStatus::Gating,
739 liveness: Liveness::Dead,
740 driver_unproven: false,
741 dirty: false,
742 dirty_files: Vec::new(),
743 driver_pid: None,
744 head: "a".repeat(40),
745 tip: "a".repeat(40),
746 }
747 }
748
749 fn recorded_holder(home: &Path) -> (RunState, PathBuf) {
750 let mut state = RunState::new(
751 "repo".into(),
752 "main".into(),
753 "base".into(),
754 "task".into(),
755 crate::config::Config::default(),
756 );
757 state.config.graph.worktree_root = Some(home.join("worktrees"));
758 let path = state.worktree_root().join("cand-A");
759 std::fs::create_dir_all(&path).unwrap();
760 state.driver_exited = true;
761 state.candidates.push(crate::run::Candidate {
762 index: 0,
763 label: 'A',
764 agent: "existing".into(),
765 branch: "branch".into(),
766 worktree: path.clone(),
767 summary: String::new(),
768 stat: String::new(),
769 files: 1,
770 commits: 1,
771 empty: false,
772 failed: None,
773 verified_noop: None,
774 duration_ms: 0,
775 folded: false,
776 });
777 state.save_under(home).unwrap();
778 (state, path)
779 }
780
781 #[test]
782 fn handover_reloads_a_driver_claim_after_the_scan_and_excludes_new_claims() {
783 let home = tempfile::tempdir().unwrap();
784 let (mut state, path) = recorded_holder(home.path());
785 let scanned = state.clone();
786 state.driver_exited = false;
788 state.driver_pid = Some(std::process::id());
789 state.driver_started_at = crate::proc::process_started_at(std::process::id());
790 state.begin_pass();
791 state.save_under(home.path()).unwrap();
792 let mut driver = crate::driver::DriverGuard::acquire(home.path(), &state).unwrap();
793 driver.record(&state).unwrap();
794 let (fresh, _, owner) = locked_holder(home.path(), &scanned.id, &path).unwrap();
795 assert!(!fresh.driver_exited);
796 assert_eq!(fresh.passes, state.passes);
797 assert_ne!(fresh.liveness_under(home.path(), false), Liveness::Dead);
798 assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_err());
799 assert!(path.exists());
800 assert!(
801 RunState::load_under(&state.id, home.path())
802 .unwrap()
803 .released_to
804 .is_none()
805 );
806 drop(owner);
807 assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_ok());
808 }
809
810 #[test]
811 fn handover_revalidates_candidate_ownership_under_the_claim_lock() {
812 let home = tempfile::tempdir().unwrap();
813 let (mut state, path) = recorded_holder(home.path());
814 state.candidates[0].folded = true;
815 state.released_to = Some("next".into());
816 state.save_under(home.path()).unwrap();
817 assert!(locked_holder(home.path(), &state.id, &path).is_err());
818 assert!(path.exists());
819 }
820
821 #[tokio::test]
822 async fn a_stale_restore_does_not_undo_a_later_handover() {
823 let home = tempfile::tempdir().unwrap();
824 let (mut state, path) = recorded_holder(home.path());
825 state.candidates[0].folded = true;
826 state.released_to = Some("later".into());
827 state.save_under(home.path()).unwrap();
828 let released = Released {
829 old_id: state.id.clone(),
830 index: 0,
831 path,
832 home: home.path().to_owned(),
833 successor: "original".into(),
834 tip: "tip".into(),
835 audit: String::new(),
836 };
837 released.restore(Path::new("nonexistent"), "branch").await;
838 let disk = RunState::load_under(&state.id, home.path()).unwrap();
839 assert_eq!(disk.released_to.as_deref(), Some("later"));
840 assert!(disk.candidates[0].folded);
841 }
842
843 #[test]
844 fn a_clean_superseded_stale_run_is_released() {
845 assert_eq!(decide(&Owner::EarlierAttempt, &holder()), Decision::Release);
846 let dead = Holder {
847 liveness: Liveness::Dead,
848 ..holder()
849 };
850 assert_eq!(decide(&Owner::EarlierAttempt, &dead), Decision::Release);
851 }
852
853 #[test]
854 fn a_foreign_worktree_is_refused_with_its_reason() {
855 let owner = Owner::Foreign("it is a foreign path".to_owned());
856 assert_eq!(
857 decide(&owner, &holder()),
858 Decision::Refuse("it is a foreign path".to_owned())
859 );
860 }
861
862 #[test]
863 fn landing_is_protected_even_for_an_earlier_attempt() {
864 let landing = Holder {
865 status: RunStatus::Landing,
866 ..holder()
867 };
868 assert!(matches!(
869 decide(&Owner::MagiRun, &landing),
870 Decision::Refuse(_)
871 ));
872 assert!(matches!(
873 decide(&Owner::EarlierAttempt, &landing),
874 Decision::Refuse(_)
875 ));
876 }
877
878 #[test]
879 fn a_dirty_worktree_is_refused_and_says_why() {
880 let dirty = Holder {
881 dirty: true,
882 dirty_files: vec!["scratch.txt".to_owned()],
883 ..holder()
884 };
885 let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &dirty) else {
886 panic!("dirty must be refused");
887 };
888 assert!(
889 why.contains("uncommitted") && why.contains("scratch.txt"),
890 "{why}"
891 );
892 assert!(why.contains("f82f") && why.contains("gating") && why.contains("dirty"));
893 }
894
895 #[test]
896 fn a_live_run_is_refused() {
897 let live = Holder {
898 liveness: Liveness::Live,
899 driver_pid: Some(4242),
900 ..holder()
901 };
902 let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &live) else {
903 panic!("live must be refused");
904 };
905 assert!(
906 why.contains("right now") && why.contains("f82f") && why.contains("4242"),
907 "{why}"
908 );
909 }
910
911 #[test]
912 fn unknown_without_a_recorded_pid_is_not_proof_of_a_stopped_driver() {
913 let holder = Holder {
914 liveness: Liveness::Unknown,
915 ..holder()
916 };
917 assert!(matches!(
918 decide(&Owner::EarlierAttempt, &holder),
919 Decision::Refuse(_)
920 ));
921 }
922
923 #[test]
924 fn a_driver_that_could_not_be_shown_dead_is_refused() {
925 let unproven = Holder {
926 driver_unproven: true,
927 ..holder()
928 };
929 let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &unproven) else {
930 panic!("an unproven driver must be refused");
931 };
932 assert!(why.contains("driver"), "{why}");
933 }
934
935 #[test]
936 fn a_head_off_the_tip_is_refused() {
937 let off = Holder {
938 head: "b".repeat(40),
939 ..holder()
940 };
941 assert!(matches!(
942 decide(&Owner::EarlierAttempt, &off),
943 Decision::Refuse(_)
944 ));
945 }
946}