1use std::path::{Path, PathBuf};
28
29use anyhow::Result;
30
31use crate::git;
32use crate::run::{Liveness, RunState, RunStatus};
33
34#[derive(Debug, Clone)]
36pub struct Takeover {
37 pub earlier: Vec<String>,
40 pub home: PathBuf,
42 pub choice: Option<crate::reconcile::Choice>,
46 pub task_instruction: Option<String>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Refused {
61 Foreign {
63 branch: String,
65 path: String,
67 why: String,
69 },
70 Unsafe {
72 branch: String,
74 path: String,
76 why: String,
78 },
79 ReleaseFailed {
81 branch: String,
83 path: String,
85 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#[derive(Debug, Clone)]
120pub struct Released {
121 pub old_id: String,
123 index: usize,
124 path: PathBuf,
125 home: PathBuf,
126 tip: String,
129 pub audit: String,
131}
132
133impl Released {
134 pub async fn restore(&self, repo: &Path, branch: &str) {
138 let path = self.path.to_string_lossy().to_string();
139 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#[derive(Debug, Clone)]
172pub struct Holder {
173 pub run: String,
175 pub status: RunStatus,
177 pub liveness: Liveness,
179 pub driver_unproven: bool,
182 pub dirty: bool,
184 pub dirty_files: Vec<String>,
186 pub driver_pid: Option<u32>,
188 pub head: String,
190 pub tip: String,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Owner {
197 EarlierAttempt,
199 MagiRun,
202 Foreign(String),
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum Decision {
209 Release,
211 Refuse(String),
213}
214
215fn short(sha: &str) -> String {
216 sha.chars().take(7).collect()
217}
218
219pub 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
271async 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 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
315fn 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
355pub 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 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 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 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}