Skip to main content

magi/
base_sync_recovery.rs

1//! Durable bounded synchronization intervals and single-use owner recovery approvals.
2use 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")]
10/// Why synchronization stopped. Only exhaustion admits a new recovery interval.
11pub enum StopKind {
12    /// The ordinary moving-base budget was spent.
13    Exhausted,
14    /// Replaying the retained changes failed.
15    Conflict,
16    /// A remote ownership or publication operation failed.
17    Publication,
18    /// The remote branch contains foreign work.
19    Divergence,
20    /// The change was integrated, but closing its PR failed and may be retried.
21    ClosingIntegratedPr,
22    /// Legacy or unexpected evidence cannot justify recovery.
23    Unknown,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27/// Stable identity of a stopped synchronization.
28pub struct Stop {
29    /// Typed cause.
30    pub kind: StopKind,
31    /// Retained candidate commit.
32    pub head: String,
33    /// Base tip observed at the stop.
34    pub tip: String,
35    /// Cumulative attempts when stopped.
36    pub attempts: usize,
37    /// Original diagnostic, retained verbatim.
38    pub detail: String,
39    /// When this record was created.
40    pub at: Timestamp,
41    /// A synchronized HEAD had not reached its gate before the base advanced.
42    #[serde(default)]
43    pub unverified_sync: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47/// One explicit owner request, consumed durably before rebasing.
48pub struct Authorization {
49    #[serde(default)]
50    pub scope: Option<Scope>,
51    /// Older approvals authorized one attempt only.
52    #[serde(default)]
53    pub interval: bool,
54    #[serde(default)]
55    pub invalidated: Option<String>,
56    /// Task whose claim admitted the request.
57    pub task: String,
58    /// Exact saved execution authorized to recover.
59    pub run: String,
60    /// Immutable stop authorized by this request.
61    pub stop: Stop,
62    /// When this record was created.
63    pub at: Timestamp,
64    /// Persisted before any Git mutation. An interrupted consumption grants no retry.
65    pub consumed: bool,
66    /// Queue approval generation, absent for an explicit recovery command.
67    #[serde(default)]
68    pub generation: Option<u64>,
69    /// Historical evidence stays attached to its original heads.
70    pub reviews: Vec<ReviewRound>,
71    /// Prior gate outcomes, never evidence for the recovered head.
72    pub gate: Vec<CommandOutcome>,
73    /// Head targeted by the archived gate.
74    pub gate_head: Option<String>,
75    /// Whether the archived gate was attempted.
76    pub gate_ran: bool,
77}
78
79impl Authorization {
80    /// Consume a refused approval without granting recovery, retaining the reason.
81    pub(crate) fn refuse(&mut self, reason: impl Into<String>) {
82        self.invalidated = Some(reason.into());
83        self.consumed = true;
84    }
85}
86
87/// A bounded active interval. Lifetime attempts remain in `BaseSync`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Episode {
90    /// Monotonic interval identity.
91    pub id: usize,
92    /// Why this interval was opened.
93    pub reason: String,
94    /// Reservations, including interrupted operations.
95    pub used: usize,
96    /// Last successful synchronization or actually executed verification in this interval.
97    pub synced_head: Option<String>,
98    /// Last successful synchronization, distinct from verification progress.
99    #[serde(default)]
100    pub last_sync_head: Option<String>,
101    /// An interruption after progress; consumed at most once.
102    pub boundary: Option<Boundary>,
103    /// Earlier interruption evidence superseded by actual progress.
104    #[serde(default)]
105    pub prior_boundaries: Vec<Boundary>,
106}
107
108/// Durable interruption evidence, never inferred from elapsed time.
109#[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/// Write-ahead reservation. A restart continues this exact operation.
118#[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    /// Exact result accepted by the rebase validator, saved before scratch cleanup.
126    #[serde(default)]
127    pub validated_head: Option<String>,
128}
129
130/// Consumed owner approval for an infrastructure boundary.
131#[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/// Approval scope additional to the immutable stop.
144#[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)]
152/// Persistent stop and authorization history, additive for legacy runs.
153pub struct Recovery {
154    /// Append-only interval and operation evidence, absent in old records.
155    #[serde(default)]
156    pub episodes: Vec<Episode>,
157    #[serde(default)]
158    pub operations: Vec<Operation>,
159    /// Stops remain evidence after successful recovery.
160    #[serde(default)]
161    pub stops: Vec<Stop>,
162    #[serde(default)]
163    pub renewals: Vec<Renewal>,
164    /// Current stop, if any.
165    pub stop: Option<Stop>,
166    /// Requests and archived evidence in authorization order.
167    pub authorizations: Vec<Authorization>,
168}
169
170impl RunState {
171    /// Shared CLI, notice and web instruction, based on the typed checkpoint.
172    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    /// Called by the same atomic state save that retains the resource stop.
282    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    /// Reserve once before Git; retrying an unfinished operation spends nothing more.
350    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    /// This checkpoint only rechecks integration and retries PR closure. It
398    /// never clears the conflict guard or grants an additional rebase.
399    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        // Also recognizes the diagnostic written before typed stops existed.
411        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    /// Legacy exhaustion is recognized only by the exact message and budget.
419    /// Once recorded, its identity remains stable across no-progress passes.
420    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    /// Open the approved bounded interval and detach old gate evidence atomically
570    /// with consumption. The caller must save before reserving Git work.
571    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
600/// A generic task release cannot authorize a stopped base synchronization.
601/// Use the queue's own home, including isolated triage stores.
602pub(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    /// Keep a producer stop stable across generic releases and driver passes.
620    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}