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. Anything else - a path magi did not make, a live
20//! run, uncommitted files - is left alone and the refusal names which.
21//!
22//! [`decide`] is pure; [`release`] is the only function here that touches git
23//! or disk. A hand-run `magi review` has no task, so it carries a [`Takeover`]
24//! with no earlier attempts and can only release a worktree of a run magi
25//! 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}. 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/// A worktree that was released, with what is needed to put it back if the
118/// review that took the branch over never starts.
119#[derive(Debug, Clone)]
120pub struct Released {
121    /// The run whose worktree was released.
122    pub old_id: String,
123    index: usize,
124    path: PathBuf,
125    home: PathBuf,
126    successor: String,
127    /// The branch tip when the worktree was released; the branch sync may
128    /// have moved it since.
129    tip: String,
130    /// What was released and why it was safe, for the new run's events.
131    pub audit: String,
132}
133
134impl Released {
135    /// Undo the release after the new run failed to start: check the branch
136    /// out again at the old path and unmark the old run. Best-effort - if the
137    /// worktree cannot be re-added the old run stays marked, which is true.
138    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        // Nothing holds the branch now, so it can be put back where the old
162        // run left it before its worktree is recreated at that commit.
163        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/// What is known about the run and worktree holding the branch.
191#[derive(Debug, Clone)]
192pub struct Holder {
193    /// The run's id.
194    pub run: String,
195    /// Where the run got to.
196    pub status: RunStatus,
197    /// Whether anything is driving it.
198    pub liveness: Liveness,
199    /// Driver ownership could not be shown dead, including legacy records
200    /// without a pid: "unknown" must not read as "gone".
201    pub driver_unproven: bool,
202    /// Any uncommitted change, untracked files included.
203    pub dirty: bool,
204    /// The first few changed paths, for the refusal text.
205    pub dirty_files: Vec<String>,
206    /// The recorded driver pid, for the refusal text.
207    pub driver_pid: Option<u32>,
208    /// HEAD of the worktree.
209    pub head: String,
210    /// The branch tip in the repository.
211    pub tip: String,
212}
213
214/// Whose worktree holds the branch, as far as magi can prove.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum Owner {
217    /// A candidate worktree of an earlier attempt at the same task.
218    EarlierAttempt,
219    /// A candidate worktree some run's record names, laid under that run's
220    /// own worktree root: magi made it.
221    MagiRun,
222    /// Not provably magi's; the text says why.
223    Foreign(String),
224}
225
226/// The verdict of [`decide`].
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum Decision {
229    /// Safe to release the worktree.
230    Release,
231    /// Not safe to release; the text says exactly why, for the operator.
232    Refuse(String),
233}
234
235fn short(sha: &str) -> String {
236    sha.chars().take(7).collect()
237}
238
239/// Whether `holder`'s worktree may be released to a new run.
240///
241/// [`Liveness::Unknown`] is refused like a live driver: an owner's answer
242/// cannot substitute for execution-lease or process-identity evidence. A run
243/// in `landing` (possibly waiting on the owner's approval) is refused as well.
244pub 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
290/// Read what [`decide`] needs from disk and git.
291async 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    // Spelled out: `status.showUntrackedFiles=no` in a user's config would
301    // otherwise hide new files from `git status` and let them be deleted.
302    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
334/// The run and candidate whose recorded worktree is `path`, when magi
335/// provably made it: the record names the path, the path sits directly in the
336/// record's own worktree root (`<root>/<short>/<dir>`), and exactly one run
337/// says so. `Err` is the reason a path under a root could not be proven.
338fn 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
374/// A structured PR record survives both stop messages replacing `merge.detail`
375/// and older resumes accidentally resetting the graph status to Gating.
376pub(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/// Why a stopped execution cannot be resumed, in admission-check order.
387#[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}
397
398impl ResumeRefusal {
399    /// The recorded new owner, when admission was refused by an ownership move.
400    pub(crate) fn successor(&self) -> Option<&str> {
401        match self {
402            Self::Released(to) | Self::Retired(to) | Self::OwnershipTransferred(to) => Some(to),
403            _ => None,
404        }
405    }
406
407    pub(crate) fn diagnostic(&self, state: &RunState) -> String {
408        let reason = match self {
409            Self::Status(status) => format!("status `{}` is final; there is nothing left to resume", status.as_str()),
410            Self::Released(_) => "execution was released".into(),
411            Self::Retired(_) => "execution was retired".into(),
412            Self::OwnershipTransferred(_) => "PR ownership was transferred".into(),
413            Self::ReviewBudget { used, limit } => format!("review budget exhausted ({used}/{limit} rounds used)"),
414            Self::CleanReviewGateRan => "review already has a clean round and the gate already ran; no retryable checkpoint remains".into(),
415            Self::ReviewDisabled => "review is disabled (review_rounds = 0) and no retryable checkpoint or pending gate remains".into(),
416        };
417        let mut detail = format!("run {} cannot resume: {reason}.", state.short());
418        if let Some(target) = self.successor() {
419            let target = if matches!(self, Self::Retired(_)) {
420                target
421            } else {
422                crate::run::short_of(target)
423            };
424            detail.push_str(&format!(" Successor: run {target}; inspect `magi show {target}` before choosing any further action."));
425        }
426        for (label, target) in [
427            ("released to", &state.released_to),
428            ("retired to", &state.retired_to),
429            ("PR ownership moved to", &state.pr_taken_over_by),
430        ] {
431            if let Some(target) = target {
432                if self.successor() == Some(target.as_str()) {
433                    continue;
434                }
435                let target = if label == "retired to" {
436                    target.as_str()
437                } else {
438                    crate::run::short_of(target)
439                };
440                detail.push_str(&format!(" {label} run {target}; inspect `magi show {target}` before choosing any further action."));
441            }
442        }
443        detail.push_str(&format!(
444            " Inspect `magi show {}` for retained work and ownership.",
445            state.id
446        ));
447        if matches!(self, Self::ReviewBudget { .. } | Self::ReviewDisabled) {
448            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.");
449        }
450        detail
451    }
452}
453
454/// Shared admission policy for queue retries, typed actions and control resume.
455/// Review exhaustion does not spend publication or landing checkpoints, or
456/// prevent pending verification or gate work from retrying infrastructure waits.
457pub(crate) fn can_resume(state: &RunState) -> bool {
458    resume_refusal(state).is_none()
459}
460
461pub(crate) fn resume_refusal(state: &RunState) -> Option<ResumeRefusal> {
462    resume_refusal_under(state, crate::run::try_home().as_deref())
463}
464
465/// Explicit stores keep validation and question checkpoints in the same home.
466pub(crate) fn resume_refusal_under(state: &RunState, home: Option<&Path>) -> Option<ResumeRefusal> {
467    if !state.status.resumable() {
468        return Some(ResumeRefusal::Status(state.status));
469    }
470    if let Some(to) = &state.released_to {
471        return Some(ResumeRefusal::Released(to.clone()));
472    }
473    if let Some(to) = &state.retired_to {
474        return Some(ResumeRefusal::Retired(to.clone()));
475    }
476    if let Some(to) = &state.pr_taken_over_by {
477        return Some(ResumeRefusal::OwnershipTransferred(to.clone()));
478    }
479    let review = crate::graph::review_conclusion(&state.reviews, state.config.graph.review_rounds);
480    let allowed = resume_checkpoint(state)
481        || home.is_some_and(|home| has_resume_checkpoint(state, home))
482        || ((state.config.graph.review_rounds == 0
483            || state.reviews.iter().any(|round| round.clean))
484            && (!state.gate_ran || state.resource_block.is_some()))
485        || (state.resource_block.is_some() && review == Some(RunStatus::Gating))
486        || (review.is_none()
487            && state
488                .reviews
489                .last()
490                .is_some_and(|round| round.e2e_status() == crate::run::E2eStatus::ResourceBlocked))
491        || !(state.status == RunStatus::Blocked
492            && state.reviews.len() >= state.config.graph.review_rounds);
493    if allowed {
494        None
495    } else if state.config.graph.review_rounds == 0 {
496        Some(ResumeRefusal::ReviewDisabled)
497    } else if state.reviews.iter().any(|round| round.clean) {
498        Some(ResumeRefusal::CleanReviewGateRan)
499    } else {
500        Some(ResumeRefusal::ReviewBudget {
501            used: state.reviews.len(),
502            limit: state.config.graph.review_rounds,
503        })
504    }
505}
506
507fn resume_checkpoint(state: &RunState) -> bool {
508    state.status == RunStatus::Landing
509        || landing_checkpoint(state)
510        || state.pr_publication_pending
511        || state.land_approval.is_some()
512        || state.merge_decision.is_some()
513        || state.land_armed_head.is_some()
514        || !state.merge_repairs.is_empty()
515}
516
517/// A resumable publication, landing or owner decision must keep its existing
518/// graph and worktree. Call under the owner lock before releasing or retiring
519/// a predecessor; completed runs may retain these fields as historical evidence.
520pub(crate) fn has_resume_checkpoint(state: &RunState, home: &Path) -> bool {
521    state.status.resumable()
522        && (resume_checkpoint(state)
523            || crate::ask::Questions::at(home.join("questions"))
524                .list()
525                .iter()
526                .any(|q| q.run == state.id && q.status.open()))
527}
528
529/// The scan only finds a possible holder. Driver claims share this lock, so
530/// reload and validate the candidate while holding it through removal/rollback.
531fn locked_holder(
532    home: &Path,
533    id: &str,
534    path: &Path,
535) -> Result<(RunState, usize, crate::pr_head::OwnerLock)> {
536    let owner = crate::pr_head::owner_lock(home, id)?;
537    let state = RunState::load_under(id, home)?;
538    let index = state
539        .candidates
540        .iter()
541        .position(|c| !c.folded && same_path(&c.worktree, path))
542        .ok_or_else(|| anyhow::anyhow!("worktree holder changed; retry takeover"))?;
543    anyhow::ensure!(
544        state.released_to.is_none(),
545        "run already handed over its worktree; retry takeover"
546    );
547    anyhow::ensure!(
548        path.parent()
549            .is_some_and(|p| same_path(&state.worktree_root(), p)),
550        "worktree holder root changed; retry takeover"
551    );
552    Ok((state, index, owner))
553}
554
555/// Release the worktree holding `branch` to `new_run`, when an earlier attempt
556/// at the same task, or a run magi itself recorded, holds it and it is safe. Returns the run whose worktree
557/// was released (see [`Released`]), or `None` when nothing needed (or was allowed) to happen.
558///
559/// An `Err` is a refusal or a failed removal; the message is what the
560/// operator reads. Nothing is left half-done: the old run's record is written
561/// before the removal and rolled back if git refuses it.
562pub async fn release(
563    repo: &Path,
564    branch: &str,
565    new_run: &str,
566    takeover: &Takeover,
567) -> Result<Option<Released>> {
568    let Some(path) = git::worktree_holding(repo, branch).await? else {
569        return Ok(None);
570    };
571    // The holder has to be an unfolded candidate worktree of a run on record.
572    // One nobody recorded (made by hand, say) is not ours to touch.
573    let mut owner = None;
574    for id in &takeover.earlier {
575        let Ok(state) = RunState::load_under(id, &takeover.home) else {
576            continue;
577        };
578        if let Some(i) = state
579            .candidates
580            .iter()
581            .position(|c| !c.folded && same_path(&c.worktree, &path))
582        {
583            // Naming the path is not enough: magi must have laid it too.
584            if !path
585                .parent()
586                .is_some_and(|b| same_path(&state.worktree_root(), b))
587            {
588                return Err(foreign(
589                    branch,
590                    &path,
591                    &format!(
592                        "run {} records it, but it is outside that run's worktree root, so \
593                         magi did not make it",
594                        crate::run::short_of(id)
595                    ),
596                ));
597            }
598            owner = Some((state, i, Owner::EarlierAttempt));
599            break;
600        }
601    }
602    if owner.is_none() {
603        owner = match find_magi_owner(&path, &takeover.home) {
604            Ok(found) => found.map(|(s, i)| (s, i, Owner::MagiRun)),
605            Err(why) => return Err(foreign(branch, &path, &why)),
606        };
607    }
608    let Some((state, _, kind)) = owner else {
609        return Err(foreign(
610            branch,
611            &path,
612            &format!(
613                "{} is not a candidate worktree any magi run recorded (made by hand, or by \
614                 something other than magi)",
615                path.display()
616            ),
617        ));
618    };
619
620    let (mut state, index, _owner) = locked_holder(&takeover.home, &state.id, &path)?;
621    if has_resume_checkpoint(&state, &takeover.home) {
622        return Err(Refused::Unsafe {
623            branch: branch.to_owned(),
624            path: path.display().to_string(),
625            why: format!("run {} has a resumable publication, landing or decision checkpoint; resume that run instead of releasing its worktree", state.short()),
626        }.into());
627    }
628    let holder = inspect(repo, branch, &path, &state, &takeover.home).await?;
629    if let Decision::Refuse(why) = decide(&kind, &holder) {
630        return Err(Refused::Unsafe {
631            branch: branch.to_owned(),
632            path: path.display().to_string(),
633            why,
634        }
635        .into());
636    }
637    let audit = format!(
638        "run {} (status `{}`, no driver, clean, HEAD {} = branch tip, branch `{branch}` kept)",
639        crate::run::short_of(&holder.run),
640        holder.status.as_str(),
641        short(&holder.head)
642    );
643
644    let old_id = state.id.clone();
645    state.candidates[index].folded = true;
646    state.released_to = Some(new_run.to_owned());
647    if !state.released_branches.iter().any(|b| b == branch) {
648        state.released_branches.push(branch.to_owned());
649    }
650    state.event(
651        "release",
652        format!(
653            "worktree of `{branch}` released to run {}: {audit}; this run can no longer be \
654             resumed from here",
655            crate::run::short_of(new_run)
656        ),
657    );
658    state.save_under(&takeover.home)?;
659
660    // Re-read right before the removal for the driver and the tip, and let git
661    // itself refuse a dirty worktree: the removal is *not* `--force`, so an
662    // edit made after the first look is refused rather than thrown away.
663    // Ignored files such as `target/` go with the worktree by design.
664    // Against the record as it is on disk now, not the copy read earlier: a
665    // driver cannot claim between this check and removal: _owner remains
666    // held through the save, removal and any rollback.
667    let again = match RunState::load_under(&old_id, &takeover.home) {
668        Ok(fresh) => inspect(repo, branch, &path, &fresh, &takeover.home).await,
669        Err(e) => Err(e),
670    };
671    let safe = matches!(&again, Ok(h) if decide(&kind, h) == Decision::Release);
672    let removed = safe
673        && git::worktree_remove_clean(repo, &path)
674            .await
675            .unwrap_or(false);
676    if !removed || path.exists() {
677        state = RunState::load_under(&old_id, &takeover.home)?;
678        state.candidates[index].folded = false;
679        state.released_to = None;
680        state.released_branches.retain(|b| b != branch);
681        state.events.pop();
682        state.event(
683            "release",
684            format!(
685                "release of `{branch}` to run {} was undone: {audit}",
686                crate::run::short_of(new_run)
687            ),
688        );
689        state.save_under(&takeover.home)?;
690        return Err(Refused::ReleaseFailed {
691            branch: branch.to_owned(),
692            path: path.display().to_string(),
693            run: crate::run::short_of(&old_id).to_owned(),
694        }
695        .into());
696    }
697    Ok(Some(Released {
698        old_id,
699        index,
700        path,
701        home: takeover.home.clone(),
702        successor: new_run.to_owned(),
703        tip: holder.tip,
704        audit,
705    }))
706}
707
708#[cfg(test)]
709mod tests {
710    #[test]
711    fn refused_display_is_the_english_text() {
712        let r = Refused::Foreign {
713            branch: "b".into(),
714            path: "/w".into(),
715            why: "w".into(),
716        };
717        assert_eq!(
718            r.to_string(),
719            "branch `b` is checked out in /w, which magi will not remove by itself: w. Remove \
720             that worktree (`git worktree remove`) if it is not needed, and try again."
721        );
722    }
723
724    use super::*;
725
726    fn holder() -> Holder {
727        Holder {
728            run: "20260901-000000-f82f".to_owned(),
729            status: RunStatus::Gating,
730            liveness: Liveness::Dead,
731            driver_unproven: false,
732            dirty: false,
733            dirty_files: Vec::new(),
734            driver_pid: None,
735            head: "a".repeat(40),
736            tip: "a".repeat(40),
737        }
738    }
739
740    fn recorded_holder(home: &Path) -> (RunState, PathBuf) {
741        let mut state = RunState::new(
742            "repo".into(),
743            "main".into(),
744            "base".into(),
745            "task".into(),
746            crate::config::Config::default(),
747        );
748        state.config.graph.worktree_root = Some(home.join("worktrees"));
749        let path = state.worktree_root().join("cand-A");
750        std::fs::create_dir_all(&path).unwrap();
751        state.driver_exited = true;
752        state.candidates.push(crate::run::Candidate {
753            index: 0,
754            label: 'A',
755            agent: "existing".into(),
756            branch: "branch".into(),
757            worktree: path.clone(),
758            summary: String::new(),
759            stat: String::new(),
760            files: 1,
761            commits: 1,
762            empty: false,
763            failed: None,
764            verified_noop: None,
765            duration_ms: 0,
766            folded: false,
767        });
768        state.save_under(home).unwrap();
769        (state, path)
770    }
771
772    #[test]
773    fn handover_reloads_a_driver_claim_after_the_scan_and_excludes_new_claims() {
774        let home = tempfile::tempdir().unwrap();
775        let (mut state, path) = recorded_holder(home.path());
776        let scanned = state.clone();
777        // A resume won the race after the holder scan, before the owner lock.
778        state.driver_exited = false;
779        state.driver_pid = Some(std::process::id());
780        state.driver_started_at = crate::proc::process_started_at(std::process::id());
781        state.begin_pass();
782        state.save_under(home.path()).unwrap();
783        let mut driver = crate::driver::DriverGuard::acquire(home.path(), &state).unwrap();
784        driver.record(&state).unwrap();
785        let (fresh, _, owner) = locked_holder(home.path(), &scanned.id, &path).unwrap();
786        assert!(!fresh.driver_exited);
787        assert_eq!(fresh.passes, state.passes);
788        assert_ne!(fresh.liveness_under(home.path(), false), Liveness::Dead);
789        assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_err());
790        assert!(path.exists());
791        assert!(
792            RunState::load_under(&state.id, home.path())
793                .unwrap()
794                .released_to
795                .is_none()
796        );
797        drop(owner);
798        assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_ok());
799    }
800
801    #[test]
802    fn handover_revalidates_candidate_ownership_under_the_claim_lock() {
803        let home = tempfile::tempdir().unwrap();
804        let (mut state, path) = recorded_holder(home.path());
805        state.candidates[0].folded = true;
806        state.released_to = Some("next".into());
807        state.save_under(home.path()).unwrap();
808        assert!(locked_holder(home.path(), &state.id, &path).is_err());
809        assert!(path.exists());
810    }
811
812    #[tokio::test]
813    async fn a_stale_restore_does_not_undo_a_later_handover() {
814        let home = tempfile::tempdir().unwrap();
815        let (mut state, path) = recorded_holder(home.path());
816        state.candidates[0].folded = true;
817        state.released_to = Some("later".into());
818        state.save_under(home.path()).unwrap();
819        let released = Released {
820            old_id: state.id.clone(),
821            index: 0,
822            path,
823            home: home.path().to_owned(),
824            successor: "original".into(),
825            tip: "tip".into(),
826            audit: String::new(),
827        };
828        released.restore(Path::new("nonexistent"), "branch").await;
829        let disk = RunState::load_under(&state.id, home.path()).unwrap();
830        assert_eq!(disk.released_to.as_deref(), Some("later"));
831        assert!(disk.candidates[0].folded);
832    }
833
834    #[test]
835    fn a_clean_superseded_stale_run_is_released() {
836        assert_eq!(decide(&Owner::EarlierAttempt, &holder()), Decision::Release);
837        let dead = Holder {
838            liveness: Liveness::Dead,
839            ..holder()
840        };
841        assert_eq!(decide(&Owner::EarlierAttempt, &dead), Decision::Release);
842    }
843
844    #[test]
845    fn a_foreign_worktree_is_refused_with_its_reason() {
846        let owner = Owner::Foreign("it is a foreign path".to_owned());
847        assert_eq!(
848            decide(&owner, &holder()),
849            Decision::Refuse("it is a foreign path".to_owned())
850        );
851    }
852
853    #[test]
854    fn landing_is_protected_even_for_an_earlier_attempt() {
855        let landing = Holder {
856            status: RunStatus::Landing,
857            ..holder()
858        };
859        assert!(matches!(
860            decide(&Owner::MagiRun, &landing),
861            Decision::Refuse(_)
862        ));
863        assert!(matches!(
864            decide(&Owner::EarlierAttempt, &landing),
865            Decision::Refuse(_)
866        ));
867    }
868
869    #[test]
870    fn a_dirty_worktree_is_refused_and_says_why() {
871        let dirty = Holder {
872            dirty: true,
873            dirty_files: vec!["scratch.txt".to_owned()],
874            ..holder()
875        };
876        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &dirty) else {
877            panic!("dirty must be refused");
878        };
879        assert!(
880            why.contains("uncommitted") && why.contains("scratch.txt"),
881            "{why}"
882        );
883        assert!(why.contains("f82f") && why.contains("gating") && why.contains("dirty"));
884    }
885
886    #[test]
887    fn a_live_run_is_refused() {
888        let live = Holder {
889            liveness: Liveness::Live,
890            driver_pid: Some(4242),
891            ..holder()
892        };
893        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &live) else {
894            panic!("live must be refused");
895        };
896        assert!(
897            why.contains("right now") && why.contains("f82f") && why.contains("4242"),
898            "{why}"
899        );
900    }
901
902    #[test]
903    fn unknown_without_a_recorded_pid_is_not_proof_of_a_stopped_driver() {
904        let holder = Holder {
905            liveness: Liveness::Unknown,
906            ..holder()
907        };
908        assert!(matches!(
909            decide(&Owner::EarlierAttempt, &holder),
910            Decision::Refuse(_)
911        ));
912    }
913
914    #[test]
915    fn a_driver_that_could_not_be_shown_dead_is_refused() {
916        let unproven = Holder {
917            driver_unproven: true,
918            ..holder()
919        };
920        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &unproven) else {
921            panic!("an unproven driver must be refused");
922        };
923        assert!(why.contains("driver"), "{why}");
924    }
925
926    #[test]
927    fn a_head_off_the_tip_is_refused() {
928        let off = Holder {
929            head: "b".repeat(40),
930            ..holder()
931        };
932        assert!(matches!(
933            decide(&Owner::EarlierAttempt, &off),
934            Decision::Refuse(_)
935        ));
936    }
937}