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(&self.home, &self.old_id, jiff::Timestamp::now());
148        if state.released_to.as_deref() != Some(self.successor.as_str())
149            || state.status == RunStatus::Superseded
150            || state.pr_taken_over_by.is_some()
151            || state.liveness_under(&self.home, claimed) != Liveness::Dead
152        {
153            tracing::warn!(
154                "run {} changed ownership; handover rollback refused",
155                self.old_id
156            );
157            return;
158        }
159        let path = self.path.to_string_lossy().to_string();
160        // Nothing holds the branch now, so it can be put back where the old
161        // run left it before its worktree is recreated at that commit.
162        let refname = format!("refs/heads/{branch}");
163        if git::rev_parse(repo, &refname).await.ok().as_deref() != Some(self.tip.as_str())
164            && let Err(e) = git::git(repo, &["branch", "-f", branch, &self.tip]).await
165        {
166            tracing::warn!("could not put `{branch}` back at {}: {e:#}", self.tip);
167            return;
168        }
169        if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
170            tracing::warn!(
171                "could not put run {}'s worktree back at {path}: {e:#}",
172                self.old_id
173            );
174            return;
175        }
176        if let Some(c) = state.candidates.get_mut(self.index) {
177            c.folded = false;
178        }
179        state.released_to = None;
180        state.released_branches.retain(|b| b != branch);
181        state.events.pop();
182        let put_back = state.save_under(&self.home);
183        if let Err(e) = put_back {
184            tracing::warn!("could not unmark run {}: {e:#}", self.old_id);
185        }
186    }
187}
188
189/// What is known about the run and worktree holding the branch.
190#[derive(Debug, Clone)]
191pub struct Holder {
192    /// The run's id.
193    pub run: String,
194    /// Where the run got to.
195    pub status: RunStatus,
196    /// Whether anything is driving it.
197    pub liveness: Liveness,
198    /// Driver ownership could not be shown dead, including legacy records
199    /// without a pid: "unknown" must not read as "gone".
200    pub driver_unproven: bool,
201    /// Any uncommitted change, untracked files included.
202    pub dirty: bool,
203    /// The first few changed paths, for the refusal text.
204    pub dirty_files: Vec<String>,
205    /// The recorded driver pid, for the refusal text.
206    pub driver_pid: Option<u32>,
207    /// HEAD of the worktree.
208    pub head: String,
209    /// The branch tip in the repository.
210    pub tip: String,
211}
212
213/// Whose worktree holds the branch, as far as magi can prove.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum Owner {
216    /// A candidate worktree of an earlier attempt at the same task.
217    EarlierAttempt,
218    /// A candidate worktree some run's record names, laid under that run's
219    /// own worktree root: magi made it.
220    MagiRun,
221    /// Not provably magi's; the text says why.
222    Foreign(String),
223}
224
225/// The verdict of [`decide`].
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum Decision {
228    /// Safe to release the worktree.
229    Release,
230    /// Not safe to release; the text says exactly why, for the operator.
231    Refuse(String),
232}
233
234fn short(sha: &str) -> String {
235    sha.chars().take(7).collect()
236}
237
238/// Whether `holder`'s worktree may be released to a new run.
239///
240/// [`Liveness::Unknown`] is refused like a live driver: an owner's answer
241/// cannot substitute for execution-lease or process-identity evidence. A run
242/// in `landing` (possibly waiting on the owner's approval) is refused as well.
243pub fn decide(owner: &Owner, holder: &Holder) -> Decision {
244    if let Owner::Foreign(why) = owner {
245        return Decision::Refuse(why.clone());
246    }
247    let mut why = Vec::new();
248    if holder.liveness == Liveness::Live {
249        why.push(format!(
250            "run {} is being worked on right now{}",
251            crate::run::short_of(&holder.run),
252            holder
253                .driver_pid
254                .map(|p| format!(" (driver pid {p})"))
255                .unwrap_or_default()
256        ));
257    } else if holder.liveness == Liveness::Unknown || holder.driver_unproven {
258        why.push("its driver process could not be shown to be gone".to_owned());
259    }
260    if holder.status == RunStatus::Landing {
261        why.push("that run is in `landing`, possibly waiting on an approval".to_owned());
262    }
263    if holder.dirty {
264        let files = holder.dirty_files.join(", ");
265        why.push(format!("its worktree has uncommitted changes ({files})"));
266    }
267    if holder.head != holder.tip {
268        why.push("its HEAD is not at the branch tip".to_owned());
269    }
270    if why.is_empty() {
271        return Decision::Release;
272    }
273    let kind = match owner {
274        Owner::EarlierAttempt => "an earlier attempt at this task",
275        _ => "a magi run of another task",
276    };
277    Decision::Refuse(format!(
278        "run {} (status `{}`, worktree {}, HEAD {}, branch tip {}) is {kind} and still has the \
279         branch checked out, so it was not released automatically: {}",
280        crate::run::short_of(&holder.run),
281        holder.status.as_str(),
282        if holder.dirty { "dirty" } else { "clean" },
283        short(&holder.head),
284        short(&holder.tip),
285        why.join("; ")
286    ))
287}
288
289/// Read what [`decide`] needs from disk and git.
290async fn inspect(
291    repo: &Path,
292    branch: &str,
293    path: &Path,
294    state: &RunState,
295    home: &Path,
296) -> Result<Holder> {
297    let claimed = crate::daemon::is_working_on(home, &state.id, jiff::Timestamp::now());
298    let liveness = state.liveness_under(home, claimed);
299    // Spelled out: `status.showUntrackedFiles=no` in a user's config would
300    // otherwise hide new files from `git status` and let them be deleted.
301    let porcelain = git::git(path, &["status", "--porcelain", "--untracked-files=normal"]).await?;
302    Ok(Holder {
303        run: state.id.clone(),
304        status: state.status,
305        liveness,
306        driver_unproven: liveness == Liveness::Unknown,
307        dirty: !porcelain.trim().is_empty(),
308        dirty_files: porcelain
309            .lines()
310            .take(5)
311            .map(|l| l.get(3..).unwrap_or(l).trim().to_owned())
312            .collect(),
313        driver_pid: state.driver_pid,
314        head: git::rev_parse(path, "HEAD").await?,
315        tip: git::rev_parse(repo, &format!("refs/heads/{branch}")).await?,
316    })
317}
318
319fn same_path(a: &Path, b: &Path) -> bool {
320    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
321    canon(a) == canon(b)
322}
323
324fn foreign(branch: &str, path: &Path, why: &str) -> anyhow::Error {
325    Refused::Foreign {
326        branch: branch.to_owned(),
327        path: path.display().to_string(),
328        why: why.to_owned(),
329    }
330    .into()
331}
332
333/// The run and candidate whose recorded worktree is `path`, when magi
334/// provably made it: the record names the path, the path sits directly in the
335/// record's own worktree root (`<root>/<short>/<dir>`), and exactly one run
336/// says so. `Err` is the reason a path under a root could not be proven.
337fn find_magi_owner(
338    path: &Path,
339    home: &Path,
340) -> std::result::Result<Option<(RunState, usize)>, String> {
341    let Some(bay) = path.parent() else {
342        return Ok(None);
343    };
344    let Some(bay_name) = bay.file_name().and_then(|n| n.to_str()) else {
345        return Ok(None);
346    };
347    let mut found = Vec::new();
348    for id in crate::run::list_ids_in(&home.join("runs")) {
349        if crate::run::short_of(&id) != bay_name {
350            continue;
351        }
352        let Ok(state) = RunState::load_under(&id, home) else {
353            return Err(format!("run {bay_name}'s record could not be read"));
354        };
355        if !same_path(&state.worktree_root(), bay) {
356            continue;
357        }
358        if let Some(i) = state
359            .candidates
360            .iter()
361            .position(|c| !c.folded && same_path(&c.worktree, path))
362        {
363            found.push((state, i));
364        }
365    }
366    match found.len() {
367        0 => Ok(None),
368        1 => Ok(found.pop()),
369        _ => Err(format!("more than one run record claims it ({bay_name})")),
370    }
371}
372
373/// A resumable publication, landing or owner decision must keep its existing
374/// graph and worktree. Call under the owner lock before releasing or retiring
375/// a predecessor; completed runs may retain these fields as historical evidence.
376pub(crate) fn has_resume_checkpoint(state: &RunState, home: &Path) -> bool {
377    state.status.resumable()
378        && (state.status == RunStatus::Landing
379            || state.pr_publication_pending
380            || state.land_approval.is_some()
381            || state.merge_decision.is_some()
382            || state.land_armed_head.is_some()
383            || !state.merge_repairs.is_empty()
384            || crate::ask::Questions::at(home.join("questions"))
385                .list()
386                .iter()
387                .any(|q| q.run == state.id && q.status.open()))
388}
389
390/// The scan only finds a possible holder. Driver claims share this lock, so
391/// reload and validate the candidate while holding it through removal/rollback.
392fn locked_holder(
393    home: &Path,
394    id: &str,
395    path: &Path,
396) -> Result<(RunState, usize, crate::pr_head::OwnerLock)> {
397    let owner = crate::pr_head::owner_lock(home, id)?;
398    let state = RunState::load_under(id, home)?;
399    let index = state
400        .candidates
401        .iter()
402        .position(|c| !c.folded && same_path(&c.worktree, path))
403        .ok_or_else(|| anyhow::anyhow!("worktree holder changed; retry takeover"))?;
404    anyhow::ensure!(
405        state.released_to.is_none(),
406        "run already handed over its worktree; retry takeover"
407    );
408    anyhow::ensure!(
409        path.parent()
410            .is_some_and(|p| same_path(&state.worktree_root(), p)),
411        "worktree holder root changed; retry takeover"
412    );
413    Ok((state, index, owner))
414}
415
416/// Release the worktree holding `branch` to `new_run`, when an earlier attempt
417/// at the same task, or a run magi itself recorded, holds it and it is safe. Returns the run whose worktree
418/// was released (see [`Released`]), or `None` when nothing needed (or was allowed) to happen.
419///
420/// An `Err` is a refusal or a failed removal; the message is what the
421/// operator reads. Nothing is left half-done: the old run's record is written
422/// before the removal and rolled back if git refuses it.
423pub async fn release(
424    repo: &Path,
425    branch: &str,
426    new_run: &str,
427    takeover: &Takeover,
428) -> Result<Option<Released>> {
429    let Some(path) = git::worktree_holding(repo, branch).await? else {
430        return Ok(None);
431    };
432    // The holder has to be an unfolded candidate worktree of a run on record.
433    // One nobody recorded (made by hand, say) is not ours to touch.
434    let mut owner = None;
435    for id in &takeover.earlier {
436        let Ok(state) = RunState::load_under(id, &takeover.home) else {
437            continue;
438        };
439        if let Some(i) = state
440            .candidates
441            .iter()
442            .position(|c| !c.folded && same_path(&c.worktree, &path))
443        {
444            // Naming the path is not enough: magi must have laid it too.
445            if !path
446                .parent()
447                .is_some_and(|b| same_path(&state.worktree_root(), b))
448            {
449                return Err(foreign(
450                    branch,
451                    &path,
452                    &format!(
453                        "run {} records it, but it is outside that run's worktree root, so \
454                         magi did not make it",
455                        crate::run::short_of(id)
456                    ),
457                ));
458            }
459            owner = Some((state, i, Owner::EarlierAttempt));
460            break;
461        }
462    }
463    if owner.is_none() {
464        owner = match find_magi_owner(&path, &takeover.home) {
465            Ok(found) => found.map(|(s, i)| (s, i, Owner::MagiRun)),
466            Err(why) => return Err(foreign(branch, &path, &why)),
467        };
468    }
469    let Some((state, _, kind)) = owner else {
470        return Err(foreign(
471            branch,
472            &path,
473            &format!(
474                "{} is not a candidate worktree any magi run recorded (made by hand, or by \
475                 something other than magi)",
476                path.display()
477            ),
478        ));
479    };
480
481    let (mut state, index, _owner) = locked_holder(&takeover.home, &state.id, &path)?;
482    if has_resume_checkpoint(&state, &takeover.home) {
483        return Err(Refused::Unsafe {
484            branch: branch.to_owned(),
485            path: path.display().to_string(),
486            why: format!("run {} has a resumable publication, landing or decision checkpoint; resume that run instead of releasing its worktree", state.short()),
487        }.into());
488    }
489    let holder = inspect(repo, branch, &path, &state, &takeover.home).await?;
490    if let Decision::Refuse(why) = decide(&kind, &holder) {
491        return Err(Refused::Unsafe {
492            branch: branch.to_owned(),
493            path: path.display().to_string(),
494            why,
495        }
496        .into());
497    }
498    let audit = format!(
499        "run {} (status `{}`, no driver, clean, HEAD {} = branch tip, branch `{branch}` kept)",
500        crate::run::short_of(&holder.run),
501        holder.status.as_str(),
502        short(&holder.head)
503    );
504
505    let old_id = state.id.clone();
506    state.candidates[index].folded = true;
507    state.released_to = Some(new_run.to_owned());
508    if !state.released_branches.iter().any(|b| b == branch) {
509        state.released_branches.push(branch.to_owned());
510    }
511    state.event(
512        "release",
513        format!(
514            "worktree of `{branch}` released to run {}: {audit}; this run can no longer be \
515             resumed from here",
516            crate::run::short_of(new_run)
517        ),
518    );
519    state.save_under(&takeover.home)?;
520
521    // Re-read right before the removal for the driver and the tip, and let git
522    // itself refuse a dirty worktree: the removal is *not* `--force`, so an
523    // edit made after the first look is refused rather than thrown away.
524    // Ignored files such as `target/` go with the worktree by design.
525    // Against the record as it is on disk now, not the copy read earlier: a
526    // driver cannot claim between this check and removal: _owner remains
527    // held through the save, removal and any rollback.
528    let again = match RunState::load_under(&old_id, &takeover.home) {
529        Ok(fresh) => inspect(repo, branch, &path, &fresh, &takeover.home).await,
530        Err(e) => Err(e),
531    };
532    let safe = matches!(&again, Ok(h) if decide(&kind, h) == Decision::Release);
533    let removed = safe
534        && git::worktree_remove_clean(repo, &path)
535            .await
536            .unwrap_or(false);
537    if !removed || path.exists() {
538        state = RunState::load_under(&old_id, &takeover.home)?;
539        state.candidates[index].folded = false;
540        state.released_to = None;
541        state.released_branches.retain(|b| b != branch);
542        state.events.pop();
543        state.event(
544            "release",
545            format!(
546                "release of `{branch}` to run {} was undone: {audit}",
547                crate::run::short_of(new_run)
548            ),
549        );
550        state.save_under(&takeover.home)?;
551        return Err(Refused::ReleaseFailed {
552            branch: branch.to_owned(),
553            path: path.display().to_string(),
554            run: crate::run::short_of(&old_id).to_owned(),
555        }
556        .into());
557    }
558    Ok(Some(Released {
559        old_id,
560        index,
561        path,
562        home: takeover.home.clone(),
563        successor: new_run.to_owned(),
564        tip: holder.tip,
565        audit,
566    }))
567}
568
569#[cfg(test)]
570mod tests {
571    #[test]
572    fn refused_display_is_the_english_text() {
573        let r = Refused::Foreign {
574            branch: "b".into(),
575            path: "/w".into(),
576            why: "w".into(),
577        };
578        assert_eq!(
579            r.to_string(),
580            "branch `b` is checked out in /w, which magi will not remove by itself: w. Remove \
581             that worktree (`git worktree remove`) if it is not needed, and try again."
582        );
583    }
584
585    use super::*;
586
587    fn holder() -> Holder {
588        Holder {
589            run: "20260901-000000-f82f".to_owned(),
590            status: RunStatus::Gating,
591            liveness: Liveness::Dead,
592            driver_unproven: false,
593            dirty: false,
594            dirty_files: Vec::new(),
595            driver_pid: None,
596            head: "a".repeat(40),
597            tip: "a".repeat(40),
598        }
599    }
600
601    fn recorded_holder(home: &Path) -> (RunState, PathBuf) {
602        let mut state = RunState::new(
603            "repo".into(),
604            "main".into(),
605            "base".into(),
606            "task".into(),
607            crate::config::Config::default(),
608        );
609        state.config.graph.worktree_root = Some(home.join("worktrees"));
610        let path = state.worktree_root().join("cand-A");
611        std::fs::create_dir_all(&path).unwrap();
612        state.driver_exited = true;
613        state.candidates.push(crate::run::Candidate {
614            index: 0,
615            label: 'A',
616            agent: "existing".into(),
617            branch: "branch".into(),
618            worktree: path.clone(),
619            summary: String::new(),
620            stat: String::new(),
621            files: 1,
622            commits: 1,
623            empty: false,
624            failed: None,
625            verified_noop: None,
626            duration_ms: 0,
627            folded: false,
628        });
629        state.save_under(home).unwrap();
630        (state, path)
631    }
632
633    #[test]
634    fn handover_reloads_a_driver_claim_after_the_scan_and_excludes_new_claims() {
635        let home = tempfile::tempdir().unwrap();
636        let (mut state, path) = recorded_holder(home.path());
637        let scanned = state.clone();
638        // A resume won the race after the holder scan, before the owner lock.
639        state.driver_exited = false;
640        state.driver_pid = Some(std::process::id());
641        state.driver_started_at = crate::proc::process_started_at(std::process::id());
642        state.begin_pass();
643        state.save_under(home.path()).unwrap();
644        let mut driver = crate::driver::DriverGuard::acquire(home.path(), &state).unwrap();
645        driver.record(&state).unwrap();
646        let (fresh, _, owner) = locked_holder(home.path(), &scanned.id, &path).unwrap();
647        assert!(!fresh.driver_exited);
648        assert_eq!(fresh.passes, state.passes);
649        assert_ne!(fresh.liveness_under(home.path(), false), Liveness::Dead);
650        assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_err());
651        assert!(path.exists());
652        assert!(
653            RunState::load_under(&state.id, home.path())
654                .unwrap()
655                .released_to
656                .is_none()
657        );
658        drop(owner);
659        assert!(crate::pr_head::owner_lock(home.path(), &state.id).is_ok());
660    }
661
662    #[test]
663    fn handover_revalidates_candidate_ownership_under_the_claim_lock() {
664        let home = tempfile::tempdir().unwrap();
665        let (mut state, path) = recorded_holder(home.path());
666        state.candidates[0].folded = true;
667        state.released_to = Some("next".into());
668        state.save_under(home.path()).unwrap();
669        assert!(locked_holder(home.path(), &state.id, &path).is_err());
670        assert!(path.exists());
671    }
672
673    #[tokio::test]
674    async fn a_stale_restore_does_not_undo_a_later_handover() {
675        let home = tempfile::tempdir().unwrap();
676        let (mut state, path) = recorded_holder(home.path());
677        state.candidates[0].folded = true;
678        state.released_to = Some("later".into());
679        state.save_under(home.path()).unwrap();
680        let released = Released {
681            old_id: state.id.clone(),
682            index: 0,
683            path,
684            home: home.path().to_owned(),
685            successor: "original".into(),
686            tip: "tip".into(),
687            audit: String::new(),
688        };
689        released.restore(Path::new("nonexistent"), "branch").await;
690        let disk = RunState::load_under(&state.id, home.path()).unwrap();
691        assert_eq!(disk.released_to.as_deref(), Some("later"));
692        assert!(disk.candidates[0].folded);
693    }
694
695    #[test]
696    fn a_clean_superseded_stale_run_is_released() {
697        assert_eq!(decide(&Owner::EarlierAttempt, &holder()), Decision::Release);
698        let dead = Holder {
699            liveness: Liveness::Dead,
700            ..holder()
701        };
702        assert_eq!(decide(&Owner::EarlierAttempt, &dead), Decision::Release);
703    }
704
705    #[test]
706    fn a_foreign_worktree_is_refused_with_its_reason() {
707        let owner = Owner::Foreign("it is a foreign path".to_owned());
708        assert_eq!(
709            decide(&owner, &holder()),
710            Decision::Refuse("it is a foreign path".to_owned())
711        );
712    }
713
714    #[test]
715    fn landing_is_protected_even_for_an_earlier_attempt() {
716        let landing = Holder {
717            status: RunStatus::Landing,
718            ..holder()
719        };
720        assert!(matches!(
721            decide(&Owner::MagiRun, &landing),
722            Decision::Refuse(_)
723        ));
724        assert!(matches!(
725            decide(&Owner::EarlierAttempt, &landing),
726            Decision::Refuse(_)
727        ));
728    }
729
730    #[test]
731    fn a_dirty_worktree_is_refused_and_says_why() {
732        let dirty = Holder {
733            dirty: true,
734            dirty_files: vec!["scratch.txt".to_owned()],
735            ..holder()
736        };
737        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &dirty) else {
738            panic!("dirty must be refused");
739        };
740        assert!(
741            why.contains("uncommitted") && why.contains("scratch.txt"),
742            "{why}"
743        );
744        assert!(why.contains("f82f") && why.contains("gating") && why.contains("dirty"));
745    }
746
747    #[test]
748    fn a_live_run_is_refused() {
749        let live = Holder {
750            liveness: Liveness::Live,
751            driver_pid: Some(4242),
752            ..holder()
753        };
754        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &live) else {
755            panic!("live must be refused");
756        };
757        assert!(
758            why.contains("right now") && why.contains("f82f") && why.contains("4242"),
759            "{why}"
760        );
761    }
762
763    #[test]
764    fn unknown_without_a_recorded_pid_is_not_proof_of_a_stopped_driver() {
765        let holder = Holder {
766            liveness: Liveness::Unknown,
767            ..holder()
768        };
769        assert!(matches!(
770            decide(&Owner::EarlierAttempt, &holder),
771            Decision::Refuse(_)
772        ));
773    }
774
775    #[test]
776    fn a_driver_that_could_not_be_shown_dead_is_refused() {
777        let unproven = Holder {
778            driver_unproven: true,
779            ..holder()
780        };
781        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &unproven) else {
782            panic!("an unproven driver must be refused");
783        };
784        assert!(why.contains("driver"), "{why}");
785    }
786
787    #[test]
788    fn a_head_off_the_tip_is_refused() {
789        let off = Holder {
790            head: "b".repeat(40),
791            ..holder()
792        };
793        assert!(matches!(
794            decide(&Owner::EarlierAttempt, &off),
795            Decision::Refuse(_)
796        ));
797    }
798}