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    /// The branch tip when the worktree was released; the branch sync may
127    /// have moved it since.
128    tip: String,
129    /// What was released and why it was safe, for the new run's events.
130    pub audit: String,
131}
132
133impl Released {
134    /// Undo the release after the new run failed to start: check the branch
135    /// out again at the old path and unmark the old run. Best-effort - if the
136    /// worktree cannot be re-added the old run stays marked, which is true.
137    pub async fn restore(&self, repo: &Path, branch: &str) {
138        let path = self.path.to_string_lossy().to_string();
139        // Nothing holds the branch now, so it can be put back where the old
140        // run left it before its worktree is recreated at that commit.
141        let refname = format!("refs/heads/{branch}");
142        if git::rev_parse(repo, &refname).await.ok().as_deref() != Some(self.tip.as_str())
143            && let Err(e) = git::git(repo, &["branch", "-f", branch, &self.tip]).await
144        {
145            tracing::warn!("could not put `{branch}` back at {}: {e:#}", self.tip);
146            return;
147        }
148        if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
149            tracing::warn!(
150                "could not put run {}'s worktree back at {path}: {e:#}",
151                self.old_id
152            );
153            return;
154        }
155        let put_back = RunState::load_under(&self.old_id, &self.home).and_then(|mut s| {
156            if let Some(c) = s.candidates.get_mut(self.index) {
157                c.folded = false;
158            }
159            s.released_to = None;
160            s.released_branches.retain(|b| b != branch);
161            s.events.pop();
162            s.save_under(&self.home)
163        });
164        if let Err(e) = put_back {
165            tracing::warn!("could not unmark run {}: {e:#}", self.old_id);
166        }
167    }
168}
169
170/// What is known about the run and worktree holding the branch.
171#[derive(Debug, Clone)]
172pub struct Holder {
173    /// The run's id.
174    pub run: String,
175    /// Where the run got to.
176    pub status: RunStatus,
177    /// Whether anything is driving it.
178    pub liveness: Liveness,
179    /// A driver pid is recorded but could not be shown dead: it may well be
180    /// running, so "unknown" must not read as "gone".
181    pub driver_unproven: bool,
182    /// Any uncommitted change, untracked files included.
183    pub dirty: bool,
184    /// The first few changed paths, for the refusal text.
185    pub dirty_files: Vec<String>,
186    /// The recorded driver pid, for the refusal text.
187    pub driver_pid: Option<u32>,
188    /// HEAD of the worktree.
189    pub head: String,
190    /// The branch tip in the repository.
191    pub tip: String,
192}
193
194/// Whose worktree holds the branch, as far as magi can prove.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Owner {
197    /// A candidate worktree of an earlier attempt at the same task.
198    EarlierAttempt,
199    /// A candidate worktree some run's record names, laid under that run's
200    /// own worktree root: magi made it.
201    MagiRun,
202    /// Not provably magi's; the text says why.
203    Foreign(String),
204}
205
206/// The verdict of [`decide`].
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum Decision {
209    /// Safe to release the worktree.
210    Release,
211    /// Not safe to release; the text says exactly why, for the operator.
212    Refuse(String),
213}
214
215fn short(sha: &str) -> String {
216    sha.chars().take(7).collect()
217}
218
219/// Whether `holder`'s worktree may be released to a new run.
220///
221/// [`Liveness::Unknown`] releases only when no driver pid was ever recorded
222/// (nothing that could still be running); a recorded pid that could not be
223/// shown dead is refused like a live one. A run of another task that is in
224/// `landing` (possibly waiting on the owner's approval) is refused as well.
225pub fn decide(owner: &Owner, holder: &Holder) -> Decision {
226    if let Owner::Foreign(why) = owner {
227        return Decision::Refuse(why.clone());
228    }
229    let mut why = Vec::new();
230    if holder.liveness == Liveness::Live {
231        why.push(format!(
232            "run {} is being worked on right now{}",
233            crate::run::short_of(&holder.run),
234            holder
235                .driver_pid
236                .map(|p| format!(" (driver pid {p})"))
237                .unwrap_or_default()
238        ));
239    } else if holder.driver_unproven {
240        why.push("its driver process could not be shown to be gone".to_owned());
241    }
242    if *owner == Owner::MagiRun && holder.status == RunStatus::Landing {
243        why.push("that run is in `landing`, possibly waiting on an approval".to_owned());
244    }
245    if holder.dirty {
246        let files = holder.dirty_files.join(", ");
247        why.push(format!("its worktree has uncommitted changes ({files})"));
248    }
249    if holder.head != holder.tip {
250        why.push("its HEAD is not at the branch tip".to_owned());
251    }
252    if why.is_empty() {
253        return Decision::Release;
254    }
255    let kind = match owner {
256        Owner::EarlierAttempt => "an earlier attempt at this task",
257        _ => "a magi run of another task",
258    };
259    Decision::Refuse(format!(
260        "run {} (status `{}`, worktree {}, HEAD {}, branch tip {}) is {kind} and still has the \
261         branch checked out, so it was not released automatically: {}",
262        crate::run::short_of(&holder.run),
263        holder.status.as_str(),
264        if holder.dirty { "dirty" } else { "clean" },
265        short(&holder.head),
266        short(&holder.tip),
267        why.join("; ")
268    ))
269}
270
271/// Read what [`decide`] needs from disk and git.
272async fn inspect(
273    repo: &Path,
274    branch: &str,
275    path: &Path,
276    state: &RunState,
277    home: &Path,
278) -> Result<Holder> {
279    let claimed = crate::daemon::is_working_on(home, &state.id, jiff::Timestamp::now());
280    let liveness = state.liveness(claimed);
281    // Spelled out: `status.showUntrackedFiles=no` in a user's config would
282    // otherwise hide new files from `git status` and let them be deleted.
283    let porcelain = git::git(path, &["status", "--porcelain", "--untracked-files=normal"]).await?;
284    Ok(Holder {
285        run: state.id.clone(),
286        status: state.status,
287        liveness,
288        driver_unproven: liveness == Liveness::Unknown && state.driver_pid.is_some(),
289        dirty: !porcelain.trim().is_empty(),
290        dirty_files: porcelain
291            .lines()
292            .take(5)
293            .map(|l| l.get(3..).unwrap_or(l).trim().to_owned())
294            .collect(),
295        driver_pid: state.driver_pid,
296        head: git::rev_parse(path, "HEAD").await?,
297        tip: git::rev_parse(repo, &format!("refs/heads/{branch}")).await?,
298    })
299}
300
301fn same_path(a: &Path, b: &Path) -> bool {
302    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
303    canon(a) == canon(b)
304}
305
306fn foreign(branch: &str, path: &Path, why: &str) -> anyhow::Error {
307    Refused::Foreign {
308        branch: branch.to_owned(),
309        path: path.display().to_string(),
310        why: why.to_owned(),
311    }
312    .into()
313}
314
315/// The run and candidate whose recorded worktree is `path`, when magi
316/// provably made it: the record names the path, the path sits directly in the
317/// record's own worktree root (`<root>/<short>/<dir>`), and exactly one run
318/// says so. `Err` is the reason a path under a root could not be proven.
319fn find_magi_owner(
320    path: &Path,
321    home: &Path,
322) -> std::result::Result<Option<(RunState, usize)>, String> {
323    let Some(bay) = path.parent() else {
324        return Ok(None);
325    };
326    let Some(bay_name) = bay.file_name().and_then(|n| n.to_str()) else {
327        return Ok(None);
328    };
329    let mut found = Vec::new();
330    for id in crate::run::list_ids_in(&home.join("runs")) {
331        if crate::run::short_of(&id) != bay_name {
332            continue;
333        }
334        let Ok(state) = RunState::load_under(&id, home) else {
335            return Err(format!("run {bay_name}'s record could not be read"));
336        };
337        if !same_path(&state.worktree_root(), bay) {
338            continue;
339        }
340        if let Some(i) = state
341            .candidates
342            .iter()
343            .position(|c| !c.folded && same_path(&c.worktree, path))
344        {
345            found.push((state, i));
346        }
347    }
348    match found.len() {
349        0 => Ok(None),
350        1 => Ok(found.pop()),
351        _ => Err(format!("more than one run record claims it ({bay_name})")),
352    }
353}
354
355/// Release the worktree holding `branch` to `new_run`, when an earlier attempt
356/// at the same task, or a run magi itself recorded, holds it and it is safe. Returns the run whose worktree
357/// was released (see [`Released`]), or `None` when nothing needed (or was allowed) to happen.
358///
359/// An `Err` is a refusal or a failed removal; the message is what the
360/// operator reads. Nothing is left half-done: the old run's record is written
361/// before the removal and rolled back if git refuses it.
362pub async fn release(
363    repo: &Path,
364    branch: &str,
365    new_run: &str,
366    takeover: &Takeover,
367) -> Result<Option<Released>> {
368    let Some(path) = git::worktree_holding(repo, branch).await? else {
369        return Ok(None);
370    };
371    // The holder has to be an unfolded candidate worktree of a run on record.
372    // One nobody recorded (made by hand, say) is not ours to touch.
373    let mut owner = None;
374    for id in &takeover.earlier {
375        let Ok(state) = RunState::load_under(id, &takeover.home) else {
376            continue;
377        };
378        if let Some(i) = state
379            .candidates
380            .iter()
381            .position(|c| !c.folded && same_path(&c.worktree, &path))
382        {
383            // Naming the path is not enough: magi must have laid it too.
384            if !path
385                .parent()
386                .is_some_and(|b| same_path(&state.worktree_root(), b))
387            {
388                return Err(foreign(
389                    branch,
390                    &path,
391                    &format!(
392                        "run {} records it, but it is outside that run's worktree root, so \
393                         magi did not make it",
394                        crate::run::short_of(id)
395                    ),
396                ));
397            }
398            owner = Some((state, i, Owner::EarlierAttempt));
399            break;
400        }
401    }
402    if owner.is_none() {
403        owner = match find_magi_owner(&path, &takeover.home) {
404            Ok(found) => found.map(|(s, i)| (s, i, Owner::MagiRun)),
405            Err(why) => return Err(foreign(branch, &path, &why)),
406        };
407    }
408    let Some((mut state, index, kind)) = owner else {
409        return Err(foreign(
410            branch,
411            &path,
412            &format!(
413                "{} is not a candidate worktree any magi run recorded (made by hand, or by \
414                 something other than magi)",
415                path.display()
416            ),
417        ));
418    };
419
420    let holder = inspect(repo, branch, &path, &state, &takeover.home).await?;
421    if let Decision::Refuse(why) = decide(&kind, &holder) {
422        return Err(Refused::Unsafe {
423            branch: branch.to_owned(),
424            path: path.display().to_string(),
425            why,
426        }
427        .into());
428    }
429    let audit = format!(
430        "run {} (status `{}`, no driver, clean, HEAD {} = branch tip, branch `{branch}` kept)",
431        crate::run::short_of(&holder.run),
432        holder.status.as_str(),
433        short(&holder.head)
434    );
435
436    let old_id = state.id.clone();
437    state.candidates[index].folded = true;
438    state.released_to = Some(new_run.to_owned());
439    if !state.released_branches.iter().any(|b| b == branch) {
440        state.released_branches.push(branch.to_owned());
441    }
442    state.event(
443        "release",
444        format!(
445            "worktree of `{branch}` released to run {}: {audit}; this run can no longer be \
446             resumed from here",
447            crate::run::short_of(new_run)
448        ),
449    );
450    state.save_under(&takeover.home)?;
451
452    // Re-read right before the removal for the driver and the tip, and let git
453    // itself refuse a dirty worktree: the removal is *not* `--force`, so an
454    // edit made after the first look is refused rather than thrown away.
455    // Ignored files such as `target/` go with the worktree by design.
456    // Against the record as it is on disk now, not the copy read earlier: a
457    // resume that started in between has saved its driver there. (The resume
458    // side refuses a released run too - see `Runner::execute_graph`.)
459    let again = match RunState::load_under(&old_id, &takeover.home) {
460        Ok(fresh) => inspect(repo, branch, &path, &fresh, &takeover.home).await,
461        Err(e) => Err(e),
462    };
463    let safe = matches!(&again, Ok(h) if decide(&kind, h) == Decision::Release);
464    let removed = safe
465        && git::worktree_remove_clean(repo, &path)
466            .await
467            .unwrap_or(false);
468    if !removed || path.exists() {
469        state = RunState::load_under(&old_id, &takeover.home)?;
470        state.candidates[index].folded = false;
471        state.released_to = None;
472        state.released_branches.retain(|b| b != branch);
473        state.events.pop();
474        state.event(
475            "release",
476            format!(
477                "release of `{branch}` to run {} was undone: {audit}",
478                crate::run::short_of(new_run)
479            ),
480        );
481        state.save_under(&takeover.home)?;
482        return Err(Refused::ReleaseFailed {
483            branch: branch.to_owned(),
484            path: path.display().to_string(),
485            run: crate::run::short_of(&old_id).to_owned(),
486        }
487        .into());
488    }
489    Ok(Some(Released {
490        old_id,
491        index,
492        path,
493        home: takeover.home.clone(),
494        tip: holder.tip,
495        audit,
496    }))
497}
498
499#[cfg(test)]
500mod tests {
501    #[test]
502    fn refused_display_is_the_english_text() {
503        let r = Refused::Foreign {
504            branch: "b".into(),
505            path: "/w".into(),
506            why: "w".into(),
507        };
508        assert_eq!(
509            r.to_string(),
510            "branch `b` is checked out in /w, which magi will not remove by itself: w. Remove \
511             that worktree (`git worktree remove`) if it is not needed, and try again."
512        );
513    }
514
515    use super::*;
516
517    fn holder() -> Holder {
518        Holder {
519            run: "20260901-000000-f82f".to_owned(),
520            status: RunStatus::Gating,
521            liveness: Liveness::Unknown,
522            driver_unproven: false,
523            dirty: false,
524            dirty_files: Vec::new(),
525            driver_pid: None,
526            head: "a".repeat(40),
527            tip: "a".repeat(40),
528        }
529    }
530
531    #[test]
532    fn a_clean_superseded_stale_run_is_released() {
533        assert_eq!(decide(&Owner::EarlierAttempt, &holder()), Decision::Release);
534        let dead = Holder {
535            liveness: Liveness::Dead,
536            ..holder()
537        };
538        assert_eq!(decide(&Owner::EarlierAttempt, &dead), Decision::Release);
539    }
540
541    #[test]
542    fn a_foreign_worktree_is_refused_with_its_reason() {
543        let owner = Owner::Foreign("it is a foreign path".to_owned());
544        assert_eq!(
545            decide(&owner, &holder()),
546            Decision::Refuse("it is a foreign path".to_owned())
547        );
548    }
549
550    #[test]
551    fn a_landing_run_of_another_task_is_refused_but_an_earlier_attempt_is_not() {
552        let landing = Holder {
553            status: RunStatus::Landing,
554            ..holder()
555        };
556        assert!(matches!(
557            decide(&Owner::MagiRun, &landing),
558            Decision::Refuse(_)
559        ));
560        assert_eq!(decide(&Owner::EarlierAttempt, &landing), Decision::Release);
561    }
562
563    #[test]
564    fn a_dirty_worktree_is_refused_and_says_why() {
565        let dirty = Holder {
566            dirty: true,
567            dirty_files: vec!["scratch.txt".to_owned()],
568            ..holder()
569        };
570        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &dirty) else {
571            panic!("dirty must be refused");
572        };
573        assert!(
574            why.contains("uncommitted") && why.contains("scratch.txt"),
575            "{why}"
576        );
577        assert!(why.contains("f82f") && why.contains("gating") && why.contains("dirty"));
578    }
579
580    #[test]
581    fn a_live_run_is_refused() {
582        let live = Holder {
583            liveness: Liveness::Live,
584            driver_pid: Some(4242),
585            ..holder()
586        };
587        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &live) else {
588            panic!("live must be refused");
589        };
590        assert!(
591            why.contains("right now") && why.contains("f82f") && why.contains("4242"),
592            "{why}"
593        );
594    }
595
596    #[test]
597    fn a_driver_that_could_not_be_shown_dead_is_refused() {
598        let unproven = Holder {
599            driver_unproven: true,
600            ..holder()
601        };
602        let Decision::Refuse(why) = decide(&Owner::EarlierAttempt, &unproven) else {
603            panic!("an unproven driver must be refused");
604        };
605        assert!(why.contains("driver"), "{why}");
606    }
607
608    #[test]
609    fn a_head_off_the_tip_is_refused() {
610        let off = Holder {
611            head: "b".repeat(40),
612            ..holder()
613        };
614        assert!(matches!(
615            decide(&Owner::EarlierAttempt, &off),
616            Decision::Refuse(_)
617        ));
618    }
619}