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