1use anyhow::{Result, ensure};
3use jiff::Timestamp;
4use serde::{Deserialize, Serialize};
5
6use crate::run::{CommandOutcome, ReviewRound, RunState, RunStatus};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum StopKind {
12 Exhausted,
14 Conflict,
16 Publication,
18 Divergence,
20 ClosingIntegratedPr,
22 Unknown,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Stop {
29 pub kind: StopKind,
31 pub head: String,
33 pub tip: String,
35 pub attempts: usize,
37 pub detail: String,
39 pub at: Timestamp,
41 #[serde(default)]
43 pub unverified_sync: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Authorization {
49 #[serde(default)]
50 pub scope: Option<Scope>,
51 #[serde(default)]
53 pub interval: bool,
54 #[serde(default)]
55 pub invalidated: Option<String>,
56 pub task: String,
58 pub run: String,
60 pub stop: Stop,
62 pub at: Timestamp,
64 pub consumed: bool,
66 #[serde(default)]
68 pub generation: Option<u64>,
69 pub reviews: Vec<ReviewRound>,
71 pub gate: Vec<CommandOutcome>,
73 pub gate_head: Option<String>,
75 pub gate_ran: bool,
77}
78
79impl Authorization {
80 pub(crate) fn refuse(&mut self, reason: impl Into<String>) {
82 self.invalidated = Some(reason.into());
83 self.consumed = true;
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Episode {
90 pub id: usize,
92 pub reason: String,
94 pub used: usize,
96 pub synced_head: Option<String>,
98 #[serde(default)]
100 pub last_sync_head: Option<String>,
101 pub boundary: Option<Boundary>,
103 #[serde(default)]
105 pub prior_boundaries: Vec<Boundary>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Boundary {
111 pub pass: usize,
112 pub at: Timestamp,
113 pub head: String,
114 pub consumed: bool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Operation {
120 pub id: usize,
121 pub episode: usize,
122 pub head: String,
123 pub tip: String,
124 pub finished: bool,
125 #[serde(default)]
127 pub validated_head: Option<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Renewal {
133 pub task: String,
134 pub run: String,
135 pub generation: u64,
136 pub instruction: String,
137 pub episode: usize,
138 pub boundary_pass: usize,
139 pub head: String,
140 pub tip: String,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct Scope {
146 pub tip: String,
147 pub instruction: String,
148 pub episode: usize,
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152pub struct Recovery {
154 #[serde(default)]
156 pub episodes: Vec<Episode>,
157 #[serde(default)]
158 pub operations: Vec<Operation>,
159 #[serde(default)]
161 pub stops: Vec<Stop>,
162 #[serde(default)]
163 pub renewals: Vec<Renewal>,
164 pub stop: Option<Stop>,
166 pub authorizations: Vec<Authorization>,
168}
169
170impl RunState {
171 pub fn base_sync_next_action(&self) -> String {
173 let episode = self.base_sync_recovery.episodes.last().map_or(0, |e| e.id);
174 let lifetime = self.base_sync.as_ref().map_or(0, |s| s.attempts);
175 format!(
176 "{} ({}/{} in episode {}, {} lifetime synchronizations)",
177 self.base_sync_action(),
178 self.base_episode_used(),
179 crate::graph::BASE_SYNC_ROUNDS,
180 episode,
181 lifetime
182 )
183 }
184
185 fn base_sync_action(&self) -> String {
186 if let Some(reason) = self
187 .base_sync_recovery
188 .authorizations
189 .last()
190 .and_then(|a| a.invalidated.as_ref())
191 {
192 return format!("Approval invalidated: {reason}");
193 }
194 if self.resource_block.is_some() {
195 return "Restore the required resource, then explicitly resume the retained run; automatic retries retain the synchronization budget".into();
196 }
197 if self.base_sync_recovery.stop.is_none()
198 && let Some(op) = self
199 .base_sync_recovery
200 .operations
201 .last()
202 .filter(|o| !o.finished)
203 {
204 return format!(
205 "Resume reserved synchronization {} against base {}; inspect retained Git state if reconciliation is refused",
206 op.id, op.tip
207 );
208 }
209 match self.base_sync_recovery.stop.as_ref().map(|s| &s.kind) {
210 Some(StopKind::Exhausted) => {
211 let why = if self.base_sync_recovery.stop.as_ref().is_some_and(|s| s.unverified_sync) {
212 "The synchronized HEAD never reached its gate before the base moved again. "
213 } else {
214 ""
215 };
216 if self.base_recovery_pending() {
217 format!("{why}Approval recorded; resume the retained run to open the approved bounded synchronization interval")
218 } else {
219 format!("{why}Authorize a new bounded synchronization interval with `magi run --resume {} --recover-base`, or a new owner resumption approval", self.id)
220 }
221 },
222 Some(StopKind::Conflict) => "Inspect and resolve the retained textual conflict; base-budget recovery cannot resolve it".into(),
223 Some(StopKind::Divergence) => "Reconcile foreign remote branch commits before resuming; do not overwrite them".into(),
224 Some(StopKind::Publication) => "Inspect the retained publication failure and remote lease before resuming".into(),
225 Some(StopKind::ClosingIntegratedPr) => "Resume to retry closing the already integrated pull request".into(),
226 Some(StopKind::Unknown) => "Inspect the retained synchronization state before resuming; unknown evidence grants no new budget".into(),
227 None => "Continue verification on the current synchronized HEAD".into(),
228 }
229 }
230
231 pub(crate) fn ensure_base_episode(&mut self) {
232 if let Some(stop) = self.base_sync_recovery.stop.clone()
233 && !self.base_sync_recovery.stops.contains(&stop)
234 {
235 self.base_sync_recovery.stops.push(stop);
236 }
237 if self.base_sync_recovery.episodes.is_empty() {
238 let used = self.base_sync.as_ref().map_or(0, |s| s.attempts);
239 self.base_sync_recovery.episodes.push(Episode {
240 id: 0,
241 reason: "initial or conservative legacy migration".into(),
242 used,
243 synced_head: None,
244 last_sync_head: None,
245 boundary: None,
246 prior_boundaries: Vec::new(),
247 });
248 }
249 }
250
251 fn open_base_episode(&mut self, reason: &str) {
252 self.ensure_base_episode();
253 let id = self.base_sync_recovery.episodes.len();
254 self.base_sync_recovery.episodes.push(Episode {
255 id,
256 reason: reason.into(),
257 used: 0,
258 synced_head: None,
259 last_sync_head: None,
260 boundary: None,
261 prior_boundaries: Vec::new(),
262 });
263 }
264
265 pub(crate) fn base_episode_used(&self) -> usize {
266 self.base_sync_recovery.episodes.last().map_or_else(
267 || self.base_sync.as_ref().map_or(0, |s| s.attempts),
268 |e| e.used,
269 )
270 }
271
272 pub(crate) fn record_base_progress(&mut self, head: &str) {
273 self.ensure_base_episode();
274 let episode = self.base_sync_recovery.episodes.last_mut().unwrap();
275 if let Some(boundary) = episode.boundary.take() {
276 episode.prior_boundaries.push(boundary);
277 }
278 episode.synced_head = Some(head.into());
279 }
280
281 pub(crate) fn record_base_interruption(&mut self) {
283 let Some(block) = &self.resource_block else {
284 return;
285 };
286 if matches!(block.cause, crate::run::ResourceBlockCause::Unrecorded) {
287 return;
288 }
289 let Some(episode) = self.base_sync_recovery.episodes.last_mut() else {
290 return;
291 };
292 let Some(head) = episode.synced_head.clone() else {
293 return;
294 };
295 if episode.boundary.is_none() {
296 episode.boundary = Some(Boundary {
297 pass: self.passes.last().map_or(0, |p| p.index),
298 at: block.at,
299 head,
300 consumed: false,
301 });
302 }
303 }
304
305 pub(crate) fn renew_base_episode(
306 &mut self,
307 task: &str,
308 approval: &crate::queue::Resumption,
309 head: &str,
310 tip: &str,
311 ) -> bool {
312 if self
313 .base_sync_recovery
314 .renewals
315 .iter()
316 .any(|r| r.task == task && r.generation >= approval.generation)
317 || self
318 .base_sync_recovery
319 .authorizations
320 .iter()
321 .any(|a| a.task == task && a.generation.is_some_and(|g| g >= approval.generation))
322 {
323 return false;
324 }
325 let Some(e) = self.base_sync_recovery.episodes.last_mut() else {
326 return false;
327 };
328 let Some(b) = &mut e.boundary else {
329 return false;
330 };
331 if b.consumed || b.head != head || approval.at <= b.at {
332 return false;
333 }
334 b.consumed = true;
335 self.base_sync_recovery.renewals.push(Renewal {
336 task: task.into(),
337 run: self.id.clone(),
338 generation: approval.generation,
339 instruction: approval.instruction.clone(),
340 episode: e.id,
341 boundary_pass: b.pass,
342 head: head.into(),
343 tip: tip.into(),
344 });
345 self.open_base_episode("owner resumption after recorded infrastructure interruption");
346 true
347 }
348
349 pub(crate) fn reserve_base_operation(&mut self, head: &str, tip: &str) {
351 self.ensure_base_episode();
352 if self
353 .base_sync_recovery
354 .operations
355 .last()
356 .is_some_and(|o| !o.finished)
357 {
358 return;
359 }
360 let e = self.base_sync_recovery.episodes.last_mut().unwrap();
361 e.used += 1;
362 let id = self.base_sync_recovery.operations.len();
363 self.base_sync_recovery.operations.push(Operation {
364 id,
365 episode: e.id,
366 head: head.into(),
367 tip: tip.into(),
368 finished: false,
369 validated_head: None,
370 });
371 if let Some(sync) = &mut self.base_sync {
372 sync.attempts += 1;
373 }
374 }
375
376 pub(crate) fn finish_base_operation(&mut self, head: Option<&str>) {
377 if let Some(op) = self.base_sync_recovery.operations.last_mut() {
378 op.finished = true;
379 }
380 if let Some(head) = head {
381 let episode = self.base_sync_recovery.episodes.last_mut().unwrap();
382 if let Some(boundary) = episode.boundary.take() {
383 episode.prior_boundaries.push(boundary);
384 }
385 episode.synced_head = Some(head.into());
386 episode.last_sync_head = Some(head.into());
387 self.reconcile_verification_head(head);
388 }
389 }
390
391 pub(crate) fn base_sync_blocked(&self) -> bool {
392 self.base_sync
393 .as_ref()
394 .is_some_and(|s| s.conflict.is_some())
395 }
396
397 pub(crate) fn base_sync_closure_pending(&self) -> bool {
400 let Some(detail) = self
401 .base_sync
402 .as_ref()
403 .and_then(|sync| sync.conflict.as_ref())
404 else {
405 return false;
406 };
407 let Some(winner) = self.winner() else {
408 return false;
409 };
410 let prefix = format!(
412 "{} is already in {}/{}, but its pull request could not be closed (",
413 winner.branch, self.config.merge.remote, self.base_branch
414 );
415 detail.starts_with(&prefix) && detail.ends_with("); resume to retry")
416 }
417
418 pub(crate) fn record_base_sync_stop(&mut self, head: &str) {
421 let Some(sync) = self.base_sync.as_ref() else {
422 return;
423 };
424 let Some(detail) = sync.conflict.clone() else {
425 return;
426 };
427 if self
428 .base_sync_recovery
429 .stop
430 .as_ref()
431 .is_some_and(|s| s.head == head && s.tip == sync.tip && s.detail == detail)
432 {
433 self.status = RunStatus::Blocked;
434 return;
435 }
436 let exhausted = self.winner().is_some_and(|w| {
437 [
438 ("rebase(s)", "rebasing"),
439 ("synchronization(s)", "synchronizing"),
440 ].iter().any(|(noun, verb)| detail == format!(
441 "{} moved {} commit(s) ahead of {} after {} {}; {} again would only race it",
442 self.base_branch, sync.behind, w.branch, crate::graph::BASE_SYNC_ROUNDS, noun, verb
443 ))
444 });
445 let kind = if self.base_sync_closure_pending() {
446 StopKind::ClosingIntegratedPr
447 } else if sync.attempts >= crate::graph::BASE_SYNC_ROUNDS && exhausted {
448 StopKind::Exhausted
449 } else if detail.contains("not rebasing over") {
450 StopKind::Divergence
451 } else if detail.contains("refused the push")
452 || detail.contains("could not push")
453 || detail.contains("could not be pushed")
454 {
455 StopKind::Publication
456 } else if detail.contains("conflicts with") && detail.contains("did not rebase") {
457 StopKind::Conflict
458 } else {
459 StopKind::Unknown
460 };
461 let at = self
462 .events
463 .iter()
464 .rev()
465 .find(|event| event.node == "land" && event.message == detail)
466 .map_or(self.updated_at, |event| event.at);
467 let unverified_sync = sync.behind > 0
468 && self
469 .base_sync_recovery
470 .episodes
471 .last()
472 .is_some_and(|e| e.last_sync_head.as_deref() == Some(head))
473 && !(self.gate_ran && self.gate_head.as_deref() == Some(head));
474 let stop = Stop {
475 unverified_sync,
476 kind,
477 head: head.to_owned(),
478 tip: sync.tip.clone(),
479 attempts: sync.attempts,
480 detail,
481 at,
482 };
483 self.base_sync_recovery.stops.push(stop.clone());
484 self.base_sync_recovery.stop = Some(stop);
485 self.status = RunStatus::Blocked;
486 }
487
488 pub(crate) fn base_recovery_pending(&self) -> bool {
489 self.base_sync_recovery
490 .authorizations
491 .last()
492 .is_some_and(|a| {
493 !a.consumed
494 && a.invalidated.is_none()
495 && a.run == self.id
496 && self.base_sync_recovery.stop.as_ref() == Some(&a.stop)
497 })
498 }
499
500 pub(crate) fn authorize_base_sync(&mut self, task: &str) -> Result<()> {
501 let stop = self
502 .base_sync_recovery
503 .stop
504 .clone()
505 .ok_or_else(|| anyhow::anyhow!("no recorded base-sync stop"))?;
506 ensure!(
507 stop.kind == StopKind::Exhausted,
508 "only exhausted base movement permits recovery"
509 );
510 ensure!(
511 !self
512 .base_sync_recovery
513 .authorizations
514 .last()
515 .is_some_and(|a| !a.consumed && a.stop == stop),
516 "this stop already has a pending authorization; resume the existing request"
517 );
518 self.base_sync_recovery.authorizations.push(Authorization {
519 scope: None,
520 interval: true,
521 invalidated: None,
522 task: task.to_owned(),
523 run: self.id.clone(),
524 stop,
525 at: Timestamp::now(),
526 consumed: false,
527 generation: None,
528 reviews: Vec::new(),
529 gate: Vec::new(),
530 gate_head: None,
531 gate_ran: false,
532 });
533 Ok(())
534 }
535
536 pub(crate) fn authorize_base_sync_resumption(
537 &mut self,
538 task: &str,
539 approval: &crate::queue::Resumption,
540 ) -> Result<()> {
541 let stop = self
542 .base_sync_recovery
543 .stop
544 .as_ref()
545 .ok_or_else(|| anyhow::anyhow!("no recorded base-sync stop"))?;
546 ensure!(
547 approval.at > stop.at,
548 "approval predates the base-sync stop"
549 );
550 ensure!(
551 !self.base_sync_recovery.authorizations.iter().any(|a| {
552 a.task == task && a.generation.is_some_and(|g| g >= approval.generation)
553 }) && !self
554 .base_sync_recovery
555 .renewals
556 .iter()
557 .any(|r| r.task == task && r.generation >= approval.generation),
558 "operator resumption generation already used"
559 );
560 self.authorize_base_sync(task)?;
561 self.base_sync_recovery
562 .authorizations
563 .last_mut()
564 .unwrap()
565 .generation = Some(approval.generation);
566 Ok(())
567 }
568
569 pub(crate) fn consume_base_recovery(&mut self, head: &str) -> bool {
572 if !self.base_recovery_pending() {
573 return false;
574 }
575 let a = self.base_sync_recovery.authorizations.last_mut().unwrap();
576 a.consumed = true;
577 if a.stop.head != head {
578 a.refuse(format!(
579 "retained HEAD changed from {} to {head}; inspect it and authorize again with --recover-base",
580 a.stop.head
581 ));
582 return false;
583 }
584 a.reviews = self.reviews.clone();
585 a.gate = std::mem::take(&mut self.gate);
586 a.gate_head = self.gate_head.take();
587 a.gate_ran = std::mem::replace(&mut self.gate_ran, false);
588 let interval = a.interval;
589 self.base_sync.as_mut().unwrap().conflict = None;
590 self.open_base_episode("explicit exhausted-base recovery");
591 self.base_sync_recovery.stop = None;
592 if !interval {
593 self.base_sync_recovery.episodes.last_mut().unwrap().used =
594 crate::graph::BASE_SYNC_ROUNDS - 1;
595 }
596 true
597 }
598}
599
600pub(crate) fn held_task(queue: &crate::queue::Queue, task: &crate::queue::Task) -> bool {
603 held_run(queue, task).is_some()
604}
605
606pub(crate) fn held_run(queue: &crate::queue::Queue, task: &crate::queue::Task) -> Option<RunState> {
607 let home = queue.root().parent()?;
608 task.runs
609 .last()
610 .and_then(|id| RunState::load_under(id, home).ok())
611 .filter(|state| {
612 state.base_sync_blocked()
613 && !state.base_recovery_pending()
614 && !state.base_sync_closure_pending()
615 })
616}
617
618impl crate::queue::Task {
619 pub(crate) fn base_sync_notice(&mut self, state: &RunState) {
621 let Some(sync) = state
622 .base_sync
623 .as_ref()
624 .filter(|_| state.base_sync_blocked())
625 else {
626 return;
627 };
628 let stop = state.base_sync_recovery.stop.as_ref();
629 self.held_at = Some(stop.map_or(state.updated_at, |s| s.at));
630 self.notice_cause("base-sync-stop", Some(&state.id), None);
631 if let Some(cause) = &mut self.hold_notice_cause {
632 cause.target = Some(match stop {
633 Some(s) => format!("{}:{}:{}", s.head, s.tip, s.at),
634 None => format!("legacy:{}:{}", sync.tip, sync.attempts),
635 });
636 }
637 }
638}