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}