Skip to main content

magi/
handover.rs

1//! Taking a branch over from an earlier attempt at the same task.
2//!
3//! A retried task can reopen a branch (`magi/f82f/A`) that the *previous*
4//! run still has checked out in its own worktree. Git holds a branch in one
5//! worktree at a time, so the new run's review cannot start, and the only way
6//! forward used to be an agent asking the operator whether the old worktree
7//! may be deleted. Auto-fold cannot help: it only folds terminal runs, and an
8//! unfinished run keeps its worktrees on purpose so it can be resumed.
9//!
10//! When the run holding the branch is an earlier attempt at the same task
11//! ([`crate::queue::Task::earlier_attempts`]) - superseded or not: blocked,
12//! failed, stale or parked alike - and nothing is driving it, its worktree
13//! (and only its worktree) is released before the review starts. The
14//! branch, every commit and any pull request stay exactly where they were.
15//!
16//! A branch held by a run of *another* task is released on the same terms when
17//! magi can prove the worktree is its own: the path is a candidate worktree
18//! one run's record names, laid under that record's own worktree root, and the
19//! run is not being driven. Foreign worktrees are never released: [`preflight`]
20//! accepts clean holders for isolated review and refuses dirty or locked ones.
21//! Live magi holders retain the existing refusal policy.
22//!
23//! [`decide`] is pure; [`release`] performs owned-worktree handover. A hand-run
24//! `magi review` has no task, so it carries a [`Takeover`] with no earlier
25//! attempts and can only release a worktree of a run magi itself recorded.
26
27use std::path::{Path, PathBuf};
28
29use anyhow::Result;
30
31use crate::git;
32use crate::run::{Liveness, RunState, RunStatus};
33
34/// The earlier attempts at a task a new run may take a branch over from.
35#[derive(Debug, Clone)]
36pub struct Takeover {
37    /// Ids of runs the new attempt supersedes, from
38    /// [`crate::queue::Task::earlier_attempts`].
39    pub earlier: Vec<String>,
40    /// The magi home their records live under.
41    pub home: PathBuf,
42    /// The owner's answer to an earlier divergence question about the branch,
43    /// applied once the earlier worktree is released (git refuses to move a
44    /// branch that is checked out).
45    pub choice: Option<crate::reconcile::Choice>,
46    /// The task's own statement. Used instead of the commit-log stand-in when
47    /// the earlier attempt's evidence is inherited, so the completion
48    /// criteria survive the retry.
49    pub task_instruction: Option<String>,
50}
51
52/// The operator has to decide: the takeover was refused, and the text says why.
53///
54/// A distinct type so the queue loop can hold the task for a person instead
55/// of spending an attempt on it (see `daemon::attempt`).
56///
57/// Structured so the operator-facing hold reason can be rendered in the run's
58/// language (`daemon::refused_text`); `Display` is the English text, unchanged.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Refused {
61    /// The branch is held by a path magi cannot prove it made.
62    Foreign {
63        /// The contested branch.
64        branch: String,
65        /// The worktree that holds it.
66        path: String,
67        /// Machine detail of why it was refused (English).
68        why: String,
69    },
70    /// A magi worktree holds the branch, and releasing it is not safe.
71    Unsafe {
72        /// The contested branch.
73        branch: String,
74        /// The worktree that holds it.
75        path: String,
76        /// Machine detail of why it was refused (English).
77        why: String,
78    },
79    /// Releasing the earlier run's worktree failed and was undone.
80    ReleaseFailed {
81        /// The contested branch.
82        branch: String,
83        /// The worktree that holds it.
84        path: String,
85        /// Short id of the run that owned the worktree.
86        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/// A worktree that was released, with what is needed to put it back if the
120/// review that took the branch over never starts.
121#[derive(Debug, Clone)]
122pub struct Released {
123    /// The run whose worktree was released.
124    pub old_id: String,
125    index: usize,
126    path: PathBuf,
127    home: PathBuf,
128    successor: String,
129    /// The branch tip when the worktree was released; the branch sync may
130    /// have moved it since.
131    tip: String,
132    /// What was released and why it was safe, for the new run's events.
133    pub audit: String,
134}
135
136impl Released {
137    /// Undo the release after the new run failed to start: check the branch
138    /// out again at the old path and unmark the old run. Best-effort - if the
139    /// worktree cannot be re-added the old run stays marked, which is true.
140    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        // Nothing holds the branch now, so it can be put back where the old
164        // run left it before its worktree is recreated at that commit.
165        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/// What is known about the run and worktree holding the branch.
193#[derive(Debug, Clone)]
194pub struct Holder {
195    /// The run's id.
196    pub run: String,
197    /// Where the run got to.
198    pub status: RunStatus,
199    /// Whether anything is driving it.
200    pub liveness: Liveness,
201    /// Driver ownership could not be shown dead, including legacy records
202    /// without a pid: "unknown" must not read as "gone".
203    pub driver_unproven: bool,
204    /// Any uncommitted change, untracked files included.
205    pub dirty: bool,
206    /// The first few changed paths, for the refusal text.
207    pub dirty_files: Vec<String>,
208    /// The recorded driver pid, for the refusal text.
209    pub driver_pid: Option<u32>,
210    /// HEAD of the worktree.
211    pub head: String,
212    /// The branch tip in the repository.
213    pub tip: String,
214}
215
216/// Whose worktree holds the branch, as far as magi can prove.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum Owner {
219    /// A candidate worktree of an earlier attempt at the same task.
220    EarlierAttempt,
221    /// A candidate worktree some run's record names, laid under that run's
222    /// own worktree root: magi made it.
223    MagiRun,
224    /// Not provably magi's; the text says why.
225    Foreign(String),
226}
227
228/// The verdict of [`decide`].
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum Decision {
231    /// Safe to release the worktree.
232    Release,
233    /// Not safe to release; the text says exactly why, for the operator.
234    Refuse(String),
235}
236
237fn short(sha: &str) -> String {
238    sha.chars().take(7).collect()
239}
240
241/// Whether `holder`'s worktree may be released to a new run.
242///
243/// [`Liveness::Unknown`] is refused like a live driver: an owner's answer
244/// cannot substitute for execution-lease or process-identity evidence. A run
245/// in `landing` (possibly waiting on the owner's approval) is refused as well.
246pub 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
292/// Read what [`decide`] needs from disk and git.
293async 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    // Spelled out: `status.showUntrackedFiles=no` in a user's config would
303    // otherwise hide new files from `git status` and let them be deleted.
304    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
336/// Check foreign holders before filing a review and again when it starts.
337/// Recorded magi worktrees retain the existing handover policy.
338pub 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
357/// Preflight for a review request, including foreign-branch remote divergence.
358/// This runs before queue writes; execution repeats it to catch later changes.
359pub 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
385/// The run and candidate whose recorded worktree is `path`, when magi
386/// provably made it: the record names the path, the path sits directly in the
387/// record's own worktree root (`<root>/<short>/<dir>`), and exactly one run
388/// says so. `Err` is the reason a path under a root could not be proven.
389fn 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
425/// A structured PR record survives both stop messages replacing `merge.detail`
426/// and older resumes accidentally resetting the graph status to Gating.
427pub(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/// Why a stopped execution cannot be resumed, in admission-check order.
438#[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    /// The recorded new owner, when admission was refused by an ownership move.
452    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
507/// Shared admission policy for queue retries, typed actions and control resume.
508/// Review exhaustion does not spend publication or landing checkpoints, or
509/// prevent pending verification or gate work from retrying infrastructure waits.
510pub(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
518/// Explicit stores keep validation and question checkpoints in the same home.
519pub(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
589/// A resumable publication, landing or owner decision must keep its existing
590/// graph and worktree. Call under the owner lock before releasing or retiring
591/// a predecessor; completed runs may retain these fields as historical evidence.
592pub(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
601/// The scan only finds a possible holder. Driver claims share this lock, so
602/// reload and validate the candidate while holding it through removal/rollback.
603fn 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
627/// Release the worktree holding `branch` to `new_run`, when an earlier attempt
628/// at the same task, or a run magi itself recorded, holds it and it is safe. Returns the run whose worktree
629/// was released (see [`Released`]), or `None` when nothing needed (or was allowed) to happen.
630///
631/// An `Err` is a refusal or a failed removal; the message is what the
632/// operator reads. Nothing is left half-done: the old run's record is written
633/// before the removal and rolled back if git refuses it.
634pub 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    // The holder has to be an unfolded candidate worktree of a run on record.
644    // One nobody recorded (made by hand, say) is not ours to touch.
645    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            // Naming the path is not enough: magi must have laid it too.
656            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    // Re-read right before the removal for the driver and the tip, and let git
726    // itself refuse a dirty worktree: the removal is *not* `--force`, so an
727    // edit made after the first look is refused rather than thrown away.
728    // Ignored files such as `target/` go with the worktree by design.
729    // Against the record as it is on disk now, not the copy read earlier: a
730    // driver cannot claim between this check and removal: _owner remains
731    // held through the save, removal and any rollback.
732    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        // A resume won the race after the holder scan, before the owner lock.
842        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}