Skip to main content

agentmux_launcher/saga/log/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// In-memory launcher saga registry — Pillar 1 Step 6
5// (docs/specs/SPEC_PILLAR1_STEP6_SAGA_COLLAPSE_2026_07_16.md).
6//
7// This replaced the durable SQLite saga log (sagas.db / launcher-sagas.db,
8// `SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md`) wholesale. The durable
9// layer's crash-time behavior was, by its own design, a no-op: the LSD-3
10// recovery walker never replayed or compensated anything — it only marked
11// interrupted rows `failed_compensation` for operator review via
12// `--diag sagas` ("we DO NOT auto-replay or auto-compensate launcher
13// sagas", in the walker's own header, deleted with it). With srv
14// authoritative over session state and crash-reproject rebuilding windows
15// from it (Pillar 1 Steps 1–5), an interrupted launcher saga leaves nothing
16// durable to review: both concrete sagas (window_cleanup_cascade,
17// pool_respawn) narrate cleanup the host performs organically, and their
18// srv-side effects are reconstructed by reproject regardless. The SQLite file
19// bought WAL churn every session in exchange for a diagnostic tombstone.
20//
21// What stays: the LIVE coordinator semantics. The registry keeps the same
22// method surface the coordinator drives (start/terminate saga,
23// start/finish step, snapshot for diagnostics) so `--diag`-style
24// in-process introspection and the `[saga]` launcher-log narration are
25// unchanged. What's gone: `open(path)` / `open_read_only` (no file),
26// `vacuum_older_than` (replaced by a bounded in-memory retention cap),
27// and the startup recovery walker (a fresh process has an empty registry
28// by construction — there is nothing to walk).
29//
30// Concurrency: a single `Mutex<Inner>` — same serialization the SQLite
31// `Mutex<Connection>` provided, without the I/O under the lock.
32
33use std::collections::BTreeMap;
34use std::sync::Mutex;
35
36use agentmux_common::ipc::{Command, Event};
37use chrono::Utc;
38#[cfg(test)]
39use serde::{Deserialize, Serialize};
40
41use super::PipeTarget;
42
43#[cfg(test)]
44mod tests;
45
46/// Retention cap for TERMINAL sagas (completed / failed /
47/// failed_compensation). When exceeded, the oldest terminal sagas are
48/// evicted. In-flight sagas are never evicted — mirroring the old
49/// vacuum's "never vacuum running/compensating" rule. 128 comfortably
50/// exceeds anything a session produces (each window close = 1 saga,
51/// each pool promote = 1 saga) while bounding memory at a few hundred KB
52/// worst case.
53const TERMINAL_RETENTION_CAP: usize = 128;
54
55/// Errors from the saga registry. JSON serialization is the only
56/// fallible operation left after the SQLite layer's removal; the
57/// duplicate-id insert error is preserved from the durable log's
58/// PRIMARY KEY semantics ("a duplicate saga_id is a bug worth
59/// surfacing, not a silent overwrite").
60#[derive(Debug, thiserror::Error)]
61pub enum LogError {
62    #[error("json: {0}")]
63    Json(#[from] serde_json::Error),
64    #[error("duplicate saga_id {0}")]
65    DuplicateSagaId(u64),
66}
67
68/// Outcome of a launcher saga, written by `terminate_saga`.
69///
70/// `FailedCompensation` survives the durability collapse: it is no
71/// longer produced by a recovery walker (deleted), but the state
72/// string remains part of the diagnostic vocabulary and the variant
73/// is kept so `mark_failed_compensation` (retained for API parity and
74/// tests) still has its terminal state.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SagaOutcome {
77    /// Saga ran to completion successfully. `SagaAction::Done` path.
78    Completed,
79    /// Saga failed with no compensation having run.
80    Failed { reason: String },
81    /// Terminal "needs operator attention" state. Historically written
82    /// by the startup recovery walker; retained in the vocabulary.
83    #[allow(dead_code)]
84    FailedCompensation { reason: String },
85}
86
87impl SagaOutcome {
88    fn state_str(&self) -> &'static str {
89        match self {
90            SagaOutcome::Completed => "completed",
91            SagaOutcome::Failed { .. } => "failed",
92            SagaOutcome::FailedCompensation { .. } => "failed_compensation",
93        }
94    }
95
96    fn reason(&self) -> Option<&str> {
97        match self {
98            SagaOutcome::Completed => None,
99            SagaOutcome::Failed { reason }
100            | SagaOutcome::FailedCompensation { reason } => Some(reason.as_str()),
101        }
102    }
103}
104
105/// Serialize a `PipeTarget` for the step's `target` field. snake_case
106/// strings so diagnostic output stays greppable.
107fn pipe_target_str(t: PipeTarget) -> &'static str {
108    match t {
109        PipeTarget::LauncherSelf => "launcher_self",
110        PipeTarget::Host => "host",
111        PipeTarget::Srv => "srv",
112    }
113}
114
115/// A saga in `running`, `compensating`, or `failed` state.
116/// Kept (name included) for the in-process diagnostic surface.
117#[cfg(test)]
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct UnresolvedLauncherSaga {
120    pub saga_id: u64,
121    pub name: String,
122    pub state: String,
123    pub started_at: String,
124    pub input_json: String,
125    pub failure_reason: Option<String>,
126    pub steps: Vec<UnresolvedLauncherStep>,
127}
128
129/// A step attached to a saga. Steps are returned in `step_index`
130/// ascending order.
131#[cfg(test)]
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct UnresolvedLauncherStep {
134    pub step_index: u32,
135    pub name: String,
136    pub state: String,
137    pub target: Option<String>,
138    pub cmd_json: Option<String>,
139    pub output_json: Option<String>,
140    pub started_at: String,
141    pub ended_at: Option<String>,
142}
143
144/// Operator-facing snapshot of a recent saga. Sorted most-recent-first
145/// by `ended_at` falling back to `started_at`.
146#[cfg(test)]
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct SagaSummary {
149    pub saga_id: u64,
150    pub name: String,
151    pub state: String,
152    pub started_at: String,
153    pub ended_at: Option<String>,
154    pub failure_reason: Option<String>,
155    /// Count of steps in `succeeded` or `compensated` state — i.e.
156    /// progress through the saga.
157    pub step_count: u32,
158    /// JSON of saga input args, for operator triage.
159    pub input_json: String,
160}
161
162// Everything but `step_index` is read only by the `cfg(test)` snapshot
163// readers (SPEC_PILLAR1_STEP6_SAGA_COLLAPSE_2026_07_16.md §3).
164#[cfg_attr(not(test), allow(dead_code))]
165#[derive(Debug, Clone)]
166struct StepRecord {
167    step_index: u32,
168    name: String,
169    state: String, // "pending" | "succeeded"
170    target: Option<String>,
171    cmd_json: Option<String>,
172    output_json: Option<String>,
173    started_at: String,
174    ended_at: Option<String>,
175}
176
177// `name`, `started_at` and `input_json` are read only by the `cfg(test)`
178// snapshot readers (SPEC_PILLAR1_STEP6_SAGA_COLLAPSE_2026_07_16.md §3).
179#[cfg_attr(not(test), allow(dead_code))]
180#[derive(Debug, Clone)]
181struct SagaRecord {
182    name: String,
183    state: String, // "running" | "completed" | "failed" | "failed_compensation"
184    started_at: String,
185    ended_at: Option<String>,
186    failure_reason: Option<String>,
187    input_json: String,
188    steps: Vec<StepRecord>, // kept sorted by step_index (appended in order)
189}
190
191impl SagaRecord {
192    fn is_terminal(&self) -> bool {
193        matches!(
194            self.state.as_str(),
195            "completed" | "failed" | "failed_compensation"
196        )
197    }
198
199    #[cfg(test)]
200    fn recency_key(&self) -> &str {
201        self.ended_at.as_deref().unwrap_or(&self.started_at)
202    }
203}
204
205/// In-memory launcher saga registry. Owned by `SagaCoordinator` as
206/// `Arc<LauncherSagaLog>` (the name is kept from the durable era to
207/// minimize churn at the ~5 coordinator call sites; it IS still a log —
208/// an in-memory, bounded one).
209pub struct LauncherSagaLog {
210    inner: Mutex<BTreeMap<u64, SagaRecord>>,
211}
212
213impl Default for LauncherSagaLog {
214    fn default() -> Self {
215        Self::new()
216    }
217}
218
219impl LauncherSagaLog {
220    /// Create an empty registry. Infallible — there is no file to open
221    /// and no schema to migrate.
222    pub fn new() -> Self {
223        Self {
224            inner: Mutex::new(BTreeMap::new()),
225        }
226    }
227
228    /// Compatibility constructor from the durable era (the registry is
229    /// always in-memory now). Kept so the coordinator's tests — written
230    /// against the old API — compile unchanged.
231    #[allow(dead_code)]
232    pub fn open_in_memory() -> Result<Self, LogError> {
233        Ok(Self::new())
234    }
235
236    /// Test hook: force a saga into an arbitrary state (e.g.
237    /// `compensating`, which no production path writes today). The
238    /// durable-era tests did this with raw SQL.
239    #[cfg(test)]
240    pub(crate) fn set_state_for_test(&self, saga_id: u64, state: &str) {
241        if let Some(saga) = self.inner.lock().unwrap().get_mut(&saga_id) {
242            saga.state = state.to_string();
243        }
244    }
245
246    /// Highest existing `saga_id`, or 0 if empty. The coordinator seeds
247    /// `next_saga_id` from this; for a fresh in-memory registry it is
248    /// always 0, so saga ids are per-process correlation ids (which is
249    /// all the host's idempotency LRU and the pipe's drop-failure
250    /// paths ever needed them to be).
251    pub fn max_saga_id(&self) -> Result<u64, LogError> {
252        let inner = self.inner.lock().unwrap();
253        Ok(inner.keys().next_back().copied().unwrap_or(0))
254    }
255
256    /// Insert a fresh saga in `running` state. A duplicate saga_id is
257    /// a bug worth surfacing, not a silent overwrite.
258    pub fn start_saga(
259        &self,
260        saga_id: u64,
261        name: &str,
262        input: &serde_json::Value,
263    ) -> Result<(), LogError> {
264        let input_json = serde_json::to_string(input)?;
265        let mut inner = self.inner.lock().unwrap();
266        if inner.contains_key(&saga_id) {
267            return Err(LogError::DuplicateSagaId(saga_id));
268        }
269        inner.insert(
270            saga_id,
271            SagaRecord {
272                name: name.to_string(),
273                state: "running".to_string(),
274                started_at: now_rfc3339(),
275                ended_at: None,
276                failure_reason: None,
277                input_json,
278                steps: Vec::new(),
279            },
280        );
281        Self::enforce_retention(&mut inner);
282        Ok(())
283    }
284
285    /// Write a saga's terminal state. No-op when the saga_id is
286    /// unknown (mirrors the durable log's UPDATE-on-missing-row).
287    pub fn terminate_saga(&self, saga_id: u64, outcome: SagaOutcome) -> Result<(), LogError> {
288        let mut inner = self.inner.lock().unwrap();
289        if let Some(saga) = inner.get_mut(&saga_id) {
290            saga.state = outcome.state_str().to_string();
291            saga.ended_at = Some(now_rfc3339());
292            saga.failure_reason = outcome.reason().map(str::to_string);
293        }
294        Ok(())
295    }
296
297    /// Record a `pending` step before its command is dispatched.
298    pub fn start_step(
299        &self,
300        saga_id: u64,
301        step_index: u32,
302        name: &str,
303        target: PipeTarget,
304        cmd: &Command,
305    ) -> Result<(), LogError> {
306        let cmd_json = serde_json::to_string(cmd)?;
307        let mut inner = self.inner.lock().unwrap();
308        if let Some(saga) = inner.get_mut(&saga_id) {
309            saga.steps.push(StepRecord {
310                step_index,
311                name: name.to_string(),
312                state: "pending".to_string(),
313                target: Some(pipe_target_str(target).to_string()),
314                cmd_json: Some(cmd_json),
315                output_json: None,
316                started_at: now_rfc3339(),
317                ended_at: None,
318            });
319        }
320        Ok(())
321    }
322
323    /// Mark a step `succeeded` and store the awaited event as JSON.
324    pub fn finish_step(
325        &self,
326        saga_id: u64,
327        step_index: u32,
328        output: &Event,
329    ) -> Result<(), LogError> {
330        let output_json = serde_json::to_string(output)?;
331        let mut inner = self.inner.lock().unwrap();
332        if let Some(step) = Self::step_mut(&mut inner, saga_id, step_index) {
333            step.state = "succeeded".to_string();
334            step.output_json = Some(output_json);
335            step.ended_at = Some(now_rfc3339());
336        }
337        Ok(())
338    }
339
340    /// All sagas in `running`, `compensating`, or `failed` state, each
341    /// with its full step list, ordered by saga_id ascending. With no
342    /// recovery walker this is now purely a diagnostic read.
343    #[cfg(test)]
344    pub fn unresolved_sagas(&self) -> Result<Vec<UnresolvedLauncherSaga>, LogError> {
345        let inner = self.inner.lock().unwrap();
346        Ok(inner
347            .iter()
348            .filter(|(_, s)| matches!(s.state.as_str(), "running" | "compensating" | "failed"))
349            .map(|(id, s)| UnresolvedLauncherSaga {
350                saga_id: *id,
351                name: s.name.clone(),
352                state: s.state.clone(),
353                started_at: s.started_at.clone(),
354                input_json: s.input_json.clone(),
355                failure_reason: s.failure_reason.clone(),
356                steps: s.steps.iter().map(step_out).collect(),
357            })
358            .collect())
359    }
360
361    /// Mark a saga `failed_compensation`. Idempotent; preserves any
362    /// existing failure_reason by APPENDING the new reason (post-mortem
363    /// preservation, codex P2 PR #647 round 1). No-op on unknown id.
364    /// Retained for API parity + diagnostics even though the recovery
365    /// walker that was its sole production caller is gone.
366    #[allow(dead_code)]
367    pub fn mark_failed_compensation(
368        &self,
369        saga_id: u64,
370        reason: &str,
371    ) -> Result<(), LogError> {
372        let mut inner = self.inner.lock().unwrap();
373        if let Some(saga) = inner.get_mut(&saga_id) {
374            saga.state = "failed_compensation".to_string();
375            saga.ended_at = Some(now_rfc3339());
376            saga.failure_reason = match saga.failure_reason.take() {
377                Some(existing) if !existing.is_empty() => {
378                    Some(format!("{} | recovered: {}", existing, reason))
379                }
380                _ => Some(reason.to_string()),
381            };
382        }
383        Ok(())
384    }
385
386    /// Up to `limit` recent sagas, most-recent-first by
387    /// `ended_at ?? started_at`, ties broken by saga_id descending.
388    #[cfg(test)]
389    pub fn snapshot_recent(&self, limit: usize) -> Result<Vec<SagaSummary>, LogError> {
390        let inner = self.inner.lock().unwrap();
391        let mut rows: Vec<(&u64, &SagaRecord)> = inner.iter().collect();
392        rows.sort_by(|(id_a, a), (id_b, b)| {
393            b.recency_key()
394                .cmp(a.recency_key())
395                .then(id_b.cmp(id_a))
396        });
397        Ok(rows
398            .into_iter()
399            .take(limit)
400            .map(|(id, s)| SagaSummary {
401                saga_id: *id,
402                name: s.name.clone(),
403                state: s.state.clone(),
404                started_at: s.started_at.clone(),
405                ended_at: s.ended_at.clone(),
406                failure_reason: s.failure_reason.clone(),
407                step_count: s
408                    .steps
409                    .iter()
410                    .filter(|st| matches!(st.state.as_str(), "succeeded" | "compensated"))
411                    .count() as u32,
412                input_json: s.input_json.clone(),
413            })
414            .collect())
415    }
416
417    /// Evict the oldest TERMINAL sagas beyond the retention cap.
418    /// In-flight sagas are never evicted (they'd vanish from
419    /// diagnostics mid-run and their terminal write would silently
420    /// no-op). Replaces the durable log's `vacuum_older_than`.
421    fn enforce_retention(inner: &mut BTreeMap<u64, SagaRecord>) {
422        let terminal: Vec<u64> = inner
423            .iter()
424            .filter(|(_, s)| s.is_terminal())
425            .map(|(id, _)| *id)
426            .collect();
427        if terminal.len() > TERMINAL_RETENTION_CAP {
428            // BTreeMap iteration is saga_id ascending == oldest first.
429            let excess = terminal.len() - TERMINAL_RETENTION_CAP;
430            for id in terminal.into_iter().take(excess) {
431                inner.remove(&id);
432            }
433        }
434    }
435
436    fn step_mut<'a>(
437        inner: &'a mut BTreeMap<u64, SagaRecord>,
438        saga_id: u64,
439        step_index: u32,
440    ) -> Option<&'a mut StepRecord> {
441        inner
442            .get_mut(&saga_id)?
443            .steps
444            .iter_mut()
445            .find(|s| s.step_index == step_index)
446    }
447}
448
449#[cfg(test)]
450fn step_out(s: &StepRecord) -> UnresolvedLauncherStep {
451    UnresolvedLauncherStep {
452        step_index: s.step_index,
453        name: s.name.clone(),
454        state: s.state.clone(),
455        target: s.target.clone(),
456        cmd_json: s.cmd_json.clone(),
457        output_json: s.output_json.clone(),
458        started_at: s.started_at.clone(),
459        ended_at: s.ended_at.clone(),
460    }
461}
462
463/// RFC3339 timestamp for `started_at` / `ended_at` fields. Single
464/// helper so test+production paths agree on format precisely.
465fn now_rfc3339() -> String {
466    Utc::now().to_rfc3339()
467}