Skip to main content

meerkat_runtime/
terminal_status.rs

1//! Restart-first-class terminal-status evaluation over input-state witnesses.
2//!
3//! The durable truth for "did interaction X / run Y finish, and how?" is the
4//! set of per-input [`StoredInputState`] bundles: the DSL-owned seed carries
5//! `phase`, `last_run_id`, `terminal_outcome`, and `attempt_count`, and the
6//! runtime store commits those rows atomically at every machine lifecycle
7//! boundary (they are never deleted). This module owns the single canonical,
8//! pure evaluation used by BOTH witnesses — the live DSL-backed snapshot of a
9//! registered session and the durable store rows of an unregistered one — so
10//! the two sources cannot drift semantically.
11//!
12//! Honest limitation (run-status): an input that is re-staged to a later run
13//! rebinds `seed.last_run_id`, so a crashed-and-retried run can legitimately
14//! report [`RunTerminalStatus::NoDurableWitness`]. That is the durable truth;
15//! callers must not treat it as `Failed`.
16
17use chrono::{DateTime, Utc};
18use meerkat_core::lifecycle::{InputId, RunId};
19
20use crate::identifiers::IdempotencyKey;
21use crate::input_state::{
22    InputAbandonReason, InputLifecycleState, InputTerminalOutcome, StoredInputState,
23};
24
25/// Exactly-one lookup key for an interaction terminal-status query.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum InteractionSelector {
28    /// Look up by the canonical runtime input id.
29    InputId(InputId),
30    /// Look up by the caller-supplied idempotency key.
31    IdempotencyKey(String),
32}
33
34/// Which witness answered a terminal-status query.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum TerminalWitnessSource {
37    /// The session is registered; facts were read from live DSL authority.
38    LiveRuntime,
39    /// The session is not registered; facts were read from the durably
40    /// committed input-state rows in the runtime store.
41    DurableStore,
42}
43
44/// Typed terminal-status report for a single interaction (input).
45#[derive(Debug, Clone, PartialEq)]
46pub struct InteractionTerminalReport {
47    pub input_id: InputId,
48    /// DSL-owned lifecycle phase at the time of the witness.
49    pub phase: InputLifecycleState,
50    /// `None` => not yet terminal; `phase` says where the input is.
51    pub terminal: Option<InputTerminalOutcome>,
52    /// The run the input last contributed to (`seed.last_run_id`).
53    pub resolving_run_id: Option<RunId>,
54    pub attempt_count: u32,
55    pub idempotency_key: Option<IdempotencyKey>,
56    pub updated_at: DateTime<Utc>,
57}
58
59/// How a run resolved, projected from its terminal input witnesses.
60#[derive(Debug, Clone, PartialEq)]
61pub enum RunResolutionClass {
62    /// At least one witness was consumed by the run.
63    Consumed,
64    /// No witness was consumed; the first abandoned witness (in admission
65    /// order) supplies the typed cause.
66    Abandoned { reason: InputAbandonReason },
67    /// Only superseded/coalesced witnesses reference the run.
68    Displaced,
69}
70
71/// Terminal status of a run, derived from durable input witnesses.
72#[derive(Debug, Clone, PartialEq)]
73pub enum RunTerminalStatus {
74    /// At least one non-terminal witness is still bound to the run.
75    InFlight,
76    /// Every witness bound to the run is terminal.
77    Resolved { outcome: RunResolutionClass },
78    /// No input's `last_run_id` references the run. NOTE: re-staging rebinds
79    /// `last_run_id`, so a retried run can legitimately land here.
80    NoDurableWitness,
81}
82
83/// Terminal-status report for a run.
84#[derive(Debug, Clone, PartialEq)]
85pub struct RunTerminalReport {
86    pub run_id: RunId,
87    pub status: RunTerminalStatus,
88    /// Inputs whose `seed.last_run_id` references the run, in admission order.
89    pub witnesses: Vec<InteractionTerminalReport>,
90}
91
92/// A report tagged with the witness source that produced it.
93#[derive(Debug, Clone, PartialEq)]
94pub struct Sourced<T> {
95    pub source: TerminalWitnessSource,
96    pub report: T,
97}
98
99/// Project one input-state bundle into its typed interaction report.
100///
101/// Pure and I/O-free: the single canonical projection used by both the live
102/// and the durable witness paths.
103#[must_use]
104pub fn interaction_report(bundle: &StoredInputState) -> InteractionTerminalReport {
105    InteractionTerminalReport {
106        input_id: bundle.state.input_id.clone(),
107        phase: bundle.seed.phase,
108        terminal: bundle.seed.terminal_outcome.clone(),
109        resolving_run_id: bundle.seed.last_run_id.clone(),
110        attempt_count: bundle.seed.attempt_count,
111        idempotency_key: bundle.state.idempotency_key.clone(),
112        updated_at: bundle.state.updated_at,
113    }
114}
115
116/// Resolve an idempotency key to its input bundle by exact match on the
117/// persisted shell key.
118///
119/// On the durable path this key IS the recovered authority fact: recovery
120/// re-enters the machine-owned idempotency binding from this exact field, so
121/// the durable witness and the live admission map cannot diverge.
122#[must_use]
123pub fn find_by_idempotency_key<'a>(
124    inputs: &'a [StoredInputState],
125    key: &str,
126) -> Option<&'a StoredInputState> {
127    inputs.iter().find(|stored| {
128        stored
129            .state
130            .idempotency_key
131            .as_ref()
132            .is_some_and(|stored_key| stored_key.0 == key)
133    })
134}
135
136/// Evaluate the terminal status of `run_id` over a set of input witnesses.
137///
138/// Witnesses are the inputs whose `seed.last_run_id` references the run,
139/// ordered by admission sequence. Precedence for resolved runs:
140/// `Consumed` > `Abandoned` (first witness in admission order supplies the
141/// cause) > `Displaced`. An empty witness set is `NoDurableWitness`.
142#[must_use]
143pub fn evaluate_run(run_id: &RunId, inputs: &[StoredInputState]) -> RunTerminalReport {
144    let mut witnesses: Vec<&StoredInputState> = inputs
145        .iter()
146        .filter(|stored| stored.seed.last_run_id.as_ref() == Some(run_id))
147        .collect();
148    // Admission order; inputs without an admission sequence sort last, then
149    // input id keeps the order deterministic.
150    witnesses.sort_by(|a, b| {
151        let a_key = (
152            a.seed.admission_sequence.is_none(),
153            a.seed.admission_sequence,
154        );
155        let b_key = (
156            b.seed.admission_sequence.is_none(),
157            b.seed.admission_sequence,
158        );
159        a_key
160            .cmp(&b_key)
161            .then_with(|| a.state.input_id.0.cmp(&b.state.input_id.0))
162    });
163
164    let status = if witnesses.is_empty() {
165        RunTerminalStatus::NoDurableWitness
166    } else if witnesses
167        .iter()
168        .any(|stored| stored.seed.terminal_outcome.is_none())
169    {
170        RunTerminalStatus::InFlight
171    } else if witnesses.iter().any(|stored| {
172        matches!(
173            stored.seed.terminal_outcome,
174            Some(InputTerminalOutcome::Consumed)
175        )
176    }) {
177        RunTerminalStatus::Resolved {
178            outcome: RunResolutionClass::Consumed,
179        }
180    } else if let Some(reason) =
181        witnesses
182            .iter()
183            .find_map(|stored| match &stored.seed.terminal_outcome {
184                Some(InputTerminalOutcome::Abandoned { reason }) => Some(reason.clone()),
185                _ => None,
186            })
187    {
188        RunTerminalStatus::Resolved {
189            outcome: RunResolutionClass::Abandoned { reason },
190        }
191    } else {
192        RunTerminalStatus::Resolved {
193            outcome: RunResolutionClass::Displaced,
194        }
195    };
196
197    RunTerminalReport {
198        run_id: run_id.clone(),
199        status,
200        witnesses: witnesses.into_iter().map(interaction_report).collect(),
201    }
202}
203
204#[cfg(test)]
205#[allow(clippy::unwrap_used, clippy::expect_used)]
206mod tests {
207    use super::*;
208    use crate::input_state::{InputState, InputStateSeed};
209
210    fn witness(
211        run_id: Option<&RunId>,
212        terminal: Option<InputTerminalOutcome>,
213        admission_sequence: Option<u64>,
214        idempotency_key: Option<&str>,
215    ) -> StoredInputState {
216        let input_id = InputId::new();
217        let mut state = InputState::new_accepted(input_id.clone());
218        state.idempotency_key = idempotency_key.map(IdempotencyKey::new);
219        let phase = match &terminal {
220            None => InputLifecycleState::Staged,
221            Some(InputTerminalOutcome::Consumed) => InputLifecycleState::Consumed,
222            Some(InputTerminalOutcome::Superseded { .. }) => InputLifecycleState::Superseded,
223            Some(InputTerminalOutcome::Coalesced { .. }) => InputLifecycleState::Coalesced,
224            Some(InputTerminalOutcome::Abandoned { .. }) => InputLifecycleState::Abandoned,
225        };
226        StoredInputState {
227            state,
228            seed: InputStateSeed {
229                phase,
230                last_run_id: run_id.cloned(),
231                last_boundary_sequence: None,
232                admission_sequence,
233                terminal_outcome: terminal,
234                attempt_count: 1,
235                recovery_lane: None,
236            },
237        }
238    }
239
240    fn abandoned(reason: InputAbandonReason) -> Option<InputTerminalOutcome> {
241        Some(InputTerminalOutcome::Abandoned { reason })
242    }
243
244    #[test]
245    fn consumed_takes_precedence_over_abandoned_and_displaced() {
246        let run_id = RunId::new();
247        let inputs = vec![
248            witness(
249                Some(&run_id),
250                abandoned(InputAbandonReason::Cancelled),
251                Some(1),
252                None,
253            ),
254            witness(
255                Some(&run_id),
256                Some(InputTerminalOutcome::Consumed),
257                Some(2),
258                None,
259            ),
260            witness(
261                Some(&run_id),
262                Some(InputTerminalOutcome::Superseded {
263                    superseded_by: InputId::new(),
264                }),
265                Some(3),
266                None,
267            ),
268        ];
269        let report = evaluate_run(&run_id, &inputs);
270        assert_eq!(
271            report.status,
272            RunTerminalStatus::Resolved {
273                outcome: RunResolutionClass::Consumed
274            }
275        );
276        assert_eq!(report.witnesses.len(), 3);
277    }
278
279    #[test]
280    fn abandoned_cause_is_first_abandoned_witness_in_admission_order() {
281        let run_id = RunId::new();
282        let inputs = vec![
283            witness(
284                Some(&run_id),
285                abandoned(InputAbandonReason::Stopped),
286                Some(9),
287                None,
288            ),
289            witness(
290                Some(&run_id),
291                abandoned(InputAbandonReason::Cancelled),
292                Some(2),
293                None,
294            ),
295        ];
296        let report = evaluate_run(&run_id, &inputs);
297        assert_eq!(
298            report.status,
299            RunTerminalStatus::Resolved {
300                outcome: RunResolutionClass::Abandoned {
301                    reason: InputAbandonReason::Cancelled
302                }
303            },
304            "the FIRST abandoned witness in admission order supplies the cause"
305        );
306    }
307
308    #[test]
309    fn all_superseded_or_coalesced_is_displaced() {
310        let run_id = RunId::new();
311        let inputs = vec![
312            witness(
313                Some(&run_id),
314                Some(InputTerminalOutcome::Superseded {
315                    superseded_by: InputId::new(),
316                }),
317                Some(1),
318                None,
319            ),
320            witness(
321                Some(&run_id),
322                Some(InputTerminalOutcome::Coalesced {
323                    aggregate_id: InputId::new(),
324                }),
325                Some(2),
326                None,
327            ),
328        ];
329        let report = evaluate_run(&run_id, &inputs);
330        assert_eq!(
331            report.status,
332            RunTerminalStatus::Resolved {
333                outcome: RunResolutionClass::Displaced
334            }
335        );
336    }
337
338    #[test]
339    fn one_non_terminal_witness_is_in_flight() {
340        let run_id = RunId::new();
341        let inputs = vec![
342            witness(
343                Some(&run_id),
344                Some(InputTerminalOutcome::Consumed),
345                Some(1),
346                None,
347            ),
348            witness(Some(&run_id), None, Some(2), None),
349        ];
350        let report = evaluate_run(&run_id, &inputs);
351        assert_eq!(report.status, RunTerminalStatus::InFlight);
352    }
353
354    #[test]
355    fn empty_witness_set_is_no_durable_witness() {
356        let run_id = RunId::new();
357        let other_run = RunId::new();
358        let inputs = vec![witness(
359            Some(&other_run),
360            Some(InputTerminalOutcome::Consumed),
361            Some(1),
362            None,
363        )];
364        let report = evaluate_run(&run_id, &inputs);
365        assert_eq!(report.status, RunTerminalStatus::NoDurableWitness);
366        assert!(report.witnesses.is_empty());
367    }
368
369    #[test]
370    fn find_by_idempotency_key_is_exact_match_only() {
371        let run_id = RunId::new();
372        let inputs = vec![
373            witness(
374                Some(&run_id),
375                Some(InputTerminalOutcome::Consumed),
376                Some(1),
377                Some("interaction-1"),
378            ),
379            witness(Some(&run_id), None, Some(2), None),
380        ];
381        assert!(find_by_idempotency_key(&inputs, "interaction-1").is_some());
382        assert!(
383            find_by_idempotency_key(&inputs, "interaction").is_none(),
384            "prefix must not match"
385        );
386        assert!(
387            find_by_idempotency_key(&inputs, "interaction-12").is_none(),
388            "superstring must not match"
389        );
390    }
391
392    #[test]
393    fn interaction_report_projects_seed_and_shell_facts() {
394        let run_id = RunId::new();
395        let stored = witness(
396            Some(&run_id),
397            Some(InputTerminalOutcome::Consumed),
398            Some(7),
399            Some("key-7"),
400        );
401        let report = interaction_report(&stored);
402        assert_eq!(report.input_id, stored.state.input_id);
403        assert_eq!(report.phase, InputLifecycleState::Consumed);
404        assert_eq!(report.terminal, Some(InputTerminalOutcome::Consumed));
405        assert_eq!(report.resolving_run_id, Some(run_id));
406        assert_eq!(report.attempt_count, 1);
407        assert_eq!(report.idempotency_key, Some(IdempotencyKey::new("key-7")));
408    }
409}