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