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//! [`decide`] is pure; [`release`] is the only function here that touches git
17//! or disk. Only a queue-driven review takes over anything: a hand-run
18//! `magi review` has no task, so it carries no [`Takeover`] and behaves as it
19//! always did.
20
21use std::path::{Path, PathBuf};
22
23use anyhow::Result;
24
25use crate::git;
26use crate::run::{Liveness, RunState, RunStatus};
27
28/// The earlier attempts at a task a new run may take a branch over from.
29#[derive(Debug, Clone)]
30pub struct Takeover {
31    /// Ids of runs the new attempt supersedes, from
32    /// [`crate::queue::Task::earlier_attempts`].
33    pub earlier: Vec<String>,
34    /// The magi home their records live under.
35    pub home: PathBuf,
36    /// The owner's answer to an earlier divergence question about the branch,
37    /// applied once the earlier worktree is released (git refuses to move a
38    /// branch that is checked out).
39    pub choice: Option<crate::reconcile::Choice>,
40}
41
42/// The operator has to decide: the takeover was refused, and the text says why.
43///
44/// A distinct type so the queue loop can hold the task for a person instead
45/// of spending an attempt on it (see `daemon::attempt`).
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Refused(pub String);
48
49impl std::fmt::Display for Refused {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.write_str(&self.0)
52    }
53}
54
55impl std::error::Error for Refused {}
56
57/// A worktree that was released, with what is needed to put it back if the
58/// review that took the branch over never starts.
59#[derive(Debug, Clone)]
60pub struct Released {
61    /// The run whose worktree was released.
62    pub old_id: String,
63    index: usize,
64    path: PathBuf,
65    home: PathBuf,
66    /// The branch tip when the worktree was released; the branch sync may
67    /// have moved it since.
68    tip: String,
69}
70
71impl Released {
72    /// Undo the release after the new run failed to start: check the branch
73    /// out again at the old path and unmark the old run. Best-effort - if the
74    /// worktree cannot be re-added the old run stays marked, which is true.
75    pub async fn restore(&self, repo: &Path, branch: &str) {
76        let path = self.path.to_string_lossy().to_string();
77        // Nothing holds the branch now, so it can be put back where the old
78        // run left it before its worktree is recreated at that commit.
79        let refname = format!("refs/heads/{branch}");
80        if git::rev_parse(repo, &refname).await.ok().as_deref() != Some(self.tip.as_str())
81            && let Err(e) = git::git(repo, &["branch", "-f", branch, &self.tip]).await
82        {
83            tracing::warn!("could not put `{branch}` back at {}: {e:#}", self.tip);
84            return;
85        }
86        if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
87            tracing::warn!(
88                "could not put run {}'s worktree back at {path}: {e:#}",
89                self.old_id
90            );
91            return;
92        }
93        let put_back = RunState::load_under(&self.old_id, &self.home).and_then(|mut s| {
94            if let Some(c) = s.candidates.get_mut(self.index) {
95                c.folded = false;
96            }
97            s.released_to = None;
98            s.released_branches.retain(|b| b != branch);
99            s.events.pop();
100            s.save_under(&self.home)
101        });
102        if let Err(e) = put_back {
103            tracing::warn!("could not unmark run {}: {e:#}", self.old_id);
104        }
105    }
106}
107
108/// What is known about the run and worktree holding the branch.
109#[derive(Debug, Clone)]
110pub struct Holder {
111    /// The run's id.
112    pub run: String,
113    /// Where the run got to.
114    pub status: RunStatus,
115    /// Whether anything is driving it.
116    pub liveness: Liveness,
117    /// A driver pid is recorded but could not be shown dead: it may well be
118    /// running, so "unknown" must not read as "gone".
119    pub driver_unproven: bool,
120    /// Any uncommitted change, untracked files included.
121    pub dirty: bool,
122    /// HEAD of the worktree.
123    pub head: String,
124    /// The branch tip in the repository.
125    pub tip: String,
126}
127
128/// The verdict of [`decide`].
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum Decision {
131    /// Safe to release the worktree.
132    Release,
133    /// Ours to decide about, but not safe; the text says why, for the
134    /// operator.
135    Refuse(String),
136    /// Not a superseded run's worktree: leave it alone and let git's own
137    /// refusal stand.
138    NotOurs,
139}
140
141fn short(sha: &str) -> String {
142    sha.chars().take(7).collect()
143}
144
145/// Whether `holder`'s worktree may be released to a new run.
146///
147/// [`Liveness::Unknown`] releases only when no driver pid was ever recorded
148/// (nothing that could still be running); a recorded pid that could not be
149/// shown dead is refused like a live one.
150pub fn decide(superseded: bool, holder: &Holder) -> Decision {
151    if !superseded {
152        return Decision::NotOurs;
153    }
154    let mut why = Vec::new();
155    if holder.liveness == Liveness::Live {
156        why.push("that run is being worked on right now".to_owned());
157    } else if holder.driver_unproven {
158        why.push("its driver process could not be shown to be gone".to_owned());
159    }
160    if holder.dirty {
161        why.push("its worktree has uncommitted changes".to_owned());
162    }
163    if holder.head != holder.tip {
164        why.push("its HEAD is not at the branch tip".to_owned());
165    }
166    if why.is_empty() {
167        return Decision::Release;
168    }
169    Decision::Refuse(format!(
170        "run {} (status `{}`, worktree {}, HEAD {}, branch tip {}) is an earlier attempt \
171         at this task and still has the branch checked out, so it was not released \
172         automatically: {}",
173        crate::run::short_of(&holder.run),
174        holder.status.as_str(),
175        if holder.dirty { "dirty" } else { "clean" },
176        short(&holder.head),
177        short(&holder.tip),
178        why.join("; ")
179    ))
180}
181
182/// Read what [`decide`] needs from disk and git.
183async fn inspect(
184    repo: &Path,
185    branch: &str,
186    path: &Path,
187    state: &RunState,
188    home: &Path,
189) -> Result<Holder> {
190    let claimed = crate::daemon::is_working_on(home, &state.id, jiff::Timestamp::now());
191    let liveness = state.liveness(claimed);
192    Ok(Holder {
193        run: state.id.clone(),
194        status: state.status,
195        liveness,
196        driver_unproven: liveness == Liveness::Unknown && state.driver_pid.is_some(),
197        // Spelled out: `status.showUntrackedFiles=no` in a user's config would
198        // otherwise hide new files from `git status` and let them be deleted.
199        dirty: !git::git(path, &["status", "--porcelain", "--untracked-files=normal"])
200            .await?
201            .trim()
202            .is_empty(),
203        head: git::rev_parse(path, "HEAD").await?,
204        tip: git::rev_parse(repo, &format!("refs/heads/{branch}")).await?,
205    })
206}
207
208fn same_path(a: &Path, b: &Path) -> bool {
209    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
210    canon(a) == canon(b)
211}
212
213/// Release the worktree holding `branch` to `new_run`, when an earlier attempt
214/// at the same task holds it and it is safe. Returns the run whose worktree
215/// was released (see [`Released`]), or `None` when nothing needed (or was allowed) to happen.
216///
217/// An `Err` is a refusal or a failed removal; the message is what the
218/// operator reads. Nothing is left half-done: the old run's record is written
219/// before the removal and rolled back if git refuses it.
220pub async fn release(
221    repo: &Path,
222    branch: &str,
223    new_run: &str,
224    takeover: &Takeover,
225) -> Result<Option<Released>> {
226    let Some(path) = git::worktree_holding(repo, branch).await? else {
227        return Ok(None);
228    };
229    // The holder has to be an unfolded candidate worktree of a run in the
230    // list. One nobody recorded (made by hand, say) is not ours to touch.
231    let mut owner = None;
232    for id in &takeover.earlier {
233        let Ok(state) = RunState::load_under(id, &takeover.home) else {
234            continue;
235        };
236        if let Some(i) = state
237            .candidates
238            .iter()
239            .position(|c| !c.folded && same_path(&c.worktree, &path))
240        {
241            owner = Some((state, i));
242            break;
243        }
244    }
245    let Some((mut state, index)) = owner else {
246        return Err(Refused(format!(
247            "branch `{branch}` is checked out in {}, which is not a worktree of an earlier \
248             attempt at this task (a run of another task, or one made by hand), so it was \
249             not touched. Remove that worktree (`git worktree remove`) if it is not needed, \
250             and try again.",
251            path.display()
252        ))
253        .into());
254    };
255
256    let holder = inspect(repo, branch, &path, &state, &takeover.home).await?;
257    match decide(true, &holder) {
258        Decision::NotOurs => return Ok(None),
259        Decision::Refuse(why) => {
260            return Err(Refused(format!(
261                "branch `{branch}` is checked out in {}: {why}. Commit or discard the work \
262                 there and remove that worktree (`git worktree remove`), or say the run may be \
263                 discarded, and try again.",
264                path.display()
265            ))
266            .into());
267        }
268        Decision::Release => {}
269    }
270
271    let old_id = state.id.clone();
272    state.candidates[index].folded = true;
273    state.released_to = Some(new_run.to_owned());
274    if !state.released_branches.iter().any(|b| b == branch) {
275        state.released_branches.push(branch.to_owned());
276    }
277    state.event(
278        "release",
279        format!(
280            "worktree of `{branch}` released to run {}; this run can no longer be resumed \
281             from here",
282            crate::run::short_of(new_run)
283        ),
284    );
285    state.save_under(&takeover.home)?;
286
287    // Re-read right before the removal for the driver and the tip, and let git
288    // itself refuse a dirty worktree: the removal is *not* `--force`, so an
289    // edit made after the first look is refused rather than thrown away.
290    // Ignored files such as `target/` go with the worktree by design.
291    // Against the record as it is on disk now, not the copy read earlier: a
292    // resume that started in between has saved its driver there. (The resume
293    // side refuses a released run too - see `Runner::execute_graph`.)
294    let again = match RunState::load_under(&old_id, &takeover.home) {
295        Ok(fresh) => inspect(repo, branch, &path, &fresh, &takeover.home).await,
296        Err(e) => Err(e),
297    };
298    let safe = matches!(&again, Ok(h) if decide(true, h) == Decision::Release);
299    let removed = safe
300        && git::worktree_remove_clean(repo, &path)
301            .await
302            .unwrap_or(false);
303    if !removed || path.exists() {
304        state = RunState::load_under(&old_id, &takeover.home)?;
305        state.candidates[index].folded = false;
306        state.released_to = None;
307        state.released_branches.retain(|b| b != branch);
308        state.events.pop();
309        state.save_under(&takeover.home)?;
310        return Err(Refused(format!(
311            "branch `{branch}` is checked out in {} by run {}, and releasing that worktree \
312             failed or found it changed (git refuses to remove a worktree with uncommitted \
313             changes); it was left as it was",
314            path.display(),
315            crate::run::short_of(&old_id)
316        ))
317        .into());
318    }
319    Ok(Some(Released {
320        old_id,
321        index,
322        path,
323        home: takeover.home.clone(),
324        tip: holder.tip,
325    }))
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn holder() -> Holder {
333        Holder {
334            run: "20260901-000000-f82f".to_owned(),
335            status: RunStatus::Gating,
336            liveness: Liveness::Unknown,
337            driver_unproven: false,
338            dirty: false,
339            head: "a".repeat(40),
340            tip: "a".repeat(40),
341        }
342    }
343
344    #[test]
345    fn a_clean_superseded_stale_run_is_released() {
346        assert_eq!(decide(true, &holder()), Decision::Release);
347        let dead = Holder {
348            liveness: Liveness::Dead,
349            ..holder()
350        };
351        assert_eq!(decide(true, &dead), Decision::Release);
352    }
353
354    #[test]
355    fn a_run_that_is_not_superseded_is_not_ours() {
356        assert_eq!(decide(false, &holder()), Decision::NotOurs);
357    }
358
359    #[test]
360    fn a_dirty_worktree_is_refused_and_says_why() {
361        let dirty = Holder {
362            dirty: true,
363            ..holder()
364        };
365        let Decision::Refuse(why) = decide(true, &dirty) else {
366            panic!("dirty must be refused");
367        };
368        assert!(why.contains("uncommitted"), "{why}");
369        assert!(why.contains("f82f") && why.contains("gating") && why.contains("dirty"));
370    }
371
372    #[test]
373    fn a_live_run_is_refused() {
374        let live = Holder {
375            liveness: Liveness::Live,
376            ..holder()
377        };
378        let Decision::Refuse(why) = decide(true, &live) else {
379            panic!("live must be refused");
380        };
381        assert!(why.contains("right now"), "{why}");
382    }
383
384    #[test]
385    fn a_driver_that_could_not_be_shown_dead_is_refused() {
386        let unproven = Holder {
387            driver_unproven: true,
388            ..holder()
389        };
390        let Decision::Refuse(why) = decide(true, &unproven) else {
391            panic!("an unproven driver must be refused");
392        };
393        assert!(why.contains("driver"), "{why}");
394    }
395
396    #[test]
397    fn a_head_off_the_tip_is_refused() {
398        let off = Holder {
399            head: "b".repeat(40),
400            ..holder()
401        };
402        assert!(matches!(decide(true, &off), Decision::Refuse(_)));
403    }
404}