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}
47
48#[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#[derive(Debug, Clone)]
66pub struct Released {
67 pub old_id: String,
69 index: usize,
70 path: PathBuf,
71 home: PathBuf,
72 tip: String,
75 pub audit: String,
77}
78
79impl Released {
80 pub async fn restore(&self, repo: &Path, branch: &str) {
84 let path = self.path.to_string_lossy().to_string();
85 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#[derive(Debug, Clone)]
118pub struct Holder {
119 pub run: String,
121 pub status: RunStatus,
123 pub liveness: Liveness,
125 pub driver_unproven: bool,
128 pub dirty: bool,
130 pub dirty_files: Vec<String>,
132 pub driver_pid: Option<u32>,
134 pub head: String,
136 pub tip: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum Owner {
143 EarlierAttempt,
145 MagiRun,
148 Foreign(String),
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum Decision {
155 Release,
157 Refuse(String),
159}
160
161fn short(sha: &str) -> String {
162 sha.chars().take(7).collect()
163}
164
165pub 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
217async 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 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
261fn 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
301pub 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 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 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 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}