Skip to main content

agentmux_srv/sagas/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.5 — srv-side saga coordinator.
5//
6// NOT the same framework as `agentmux_launcher::saga`, despite the shared
7// name — see the "Why srv, not launcher" note directly below for the
8// original reasoning, and
9// docs/architecture/DECISION_SAGA_REDUCER_TWO_FRAMEWORKS_2026_09_07.md
10// for the decision to keep both rather than merge or rename them.
11//
12// **Why srv, not launcher:** the existing E.1a coordinator
13// framework lives in `agentmux-launcher::saga`. At the time this module was
14// written it had no consumers; Phase F.5 later gave it exactly one
15// (`pool_respawn`) — still cross-process-shaped, still not what this crate
16// needs. The original Phase E spec assumed
17// sagas would fan out across host/launcher/srv via cross-process
18// IPC; the actual implementation kept that fan-out in the frontend
19// (`requestTearOff` calls srv-rpc and host-rpc directly), so every
20// saga in the E.5 plan mutates only srv state. In-process oneshot
21// dispatch beats an IPC round-trip on every saga step. See
22// `docs/retro/saga-coordinator-location-analysis-2026-04-30.md` for
23// the full reasoning, including the robustness trade-offs (which
24// are the same for both placements).
25//
26// **Shape:** sagas are async functions that:
27//   1. allocate a fresh saga_id via `alloc_saga_id`,
28//   2. emit `Event::SagaStarted` via `emit_saga_started`,
29//   3. drive their state machine via `SagaCtx::dispatch` /
30//      `SagaCtx::compensate`,
31//   4. emit `Event::SagaCompleted` or `Event::SagaFailed` via
32//      `emit_terminal` once the inner work returns.
33//
34// `run_saga(state, name, future)` is a thin wrapper that does
35// 1+2+4 + applies a 5 s timeout. Sagas pass the future directly
36// (not a closure), avoiding the lifetime-of-SagaCtx complication
37// that closure-style coordinators run into.
38//
39// **Compensation:** the saga's inner future is responsible for
40// driving compensation before returning `Err`. `SagaCtx::compensate`
41// is a best-effort dispatch that swallows errors (the saga is
42// already failing; secondary failures get logged). Idempotency of
43// compensating commands (`MoveTab` back to source, `DeleteWorkspace`,
44// etc.) keeps the cleanup safe even if a step partially applied.
45//
46// What this module does NOT close (per the location analysis §4.2):
47// * Per-step SQLite transactions in the subscriber (gap; F1.A).
48// * Host pool-promote and renderer registration outside the saga
49//   (gap; Phase F).
50// * Saga state across srv restart (gap; Phase F+).
51
52pub mod agent_teardown;
53pub mod close_pane;
54pub mod delete_block;
55pub mod delete_tab;
56pub mod delete_workspace;
57pub mod log;
58pub mod orphan_reaper;
59pub mod promote_block_to_tab;
60pub mod recovery;
61pub mod pending_shutdown;
62pub mod redock_floating_pane;
63pub mod self_quit;
64pub mod restore_torn_off_tab;
65pub mod tear_off_block;
66pub mod tear_off_tab;
67
68// Step 7 — E.7 integration tests. Cross-saga end-to-end coverage
69// that exercises reducer + saga coordinator + persist subscriber +
70// saga log together against a real `AppState` (in-memory mstore +
71// sagalog). Per-saga unit tests under each saga module already cover
72// happy + reject paths in isolation; this module focuses on
73// multi-surface consistency (reducer/mstore/saga-log) that PR 2's
74// `compensate_unresolved` will rely on.
75#[cfg(test)]
76mod integration_tests;
77
78use std::sync::atomic::{AtomicU32, Ordering};
79
80use agentmux_common::ipc::{Command, Event};
81use serde_json::Value;
82
83use crate::sagas::log::{command_discriminant_name, SagaOutcome};
84use crate::server::AppState;
85
86/// Maximum wall-clock time a saga is allowed to run before the
87/// coordinator force-fails it. Tear-off sagas should complete in
88/// tens of milliseconds; the budget is generous to absorb SQLite
89/// write spikes without flapping in CI.
90const SAGA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
91
92/// Read-only context passed to a saga's inner async function.
93/// Wraps the AppState handle and the saga's allocated id.
94///
95/// Construct via [`SagaCtx::new`] — the durability log requires the
96/// per-step counter to start at zero and be owned by the ctx (so
97/// concurrent sagas don't interleave step indices).
98pub struct SagaCtx<'a> {
99    pub(crate) state: &'a AppState,
100    pub(crate) saga_id: u64,
101    /// Monotonic step index (0, 1, 2, ...) for this saga. Each
102    /// `dispatch` / `compensate` call `fetch_add(1)`s and writes the
103    /// resulting index into the saga log. Atomic because saga inner
104    /// futures may parallelise dispatches in the future (today they
105    /// don't, but the cost is one cache line).
106    pub(crate) step_index: AtomicU32,
107    /// (codex P1 PR #636 round 4.) Stack of forward-step indices that
108    /// have completed successfully and are eligible to be undone by
109    /// the next `compensate` call. `dispatch` pushes on success;
110    /// `compensate` pops to determine which original forward step
111    /// it's reversing, and marks that step `compensated` in the log.
112    /// Without this, in-process compensation only writes new
113    /// `compensated` rows at fresh indices; the original `succeeded`
114    /// rows stay `succeeded`, so resume-on-restart re-replays them
115    /// and either no-ops or worse double-applies the inverse.
116    ///
117    /// `Mutex<Vec>` (rather than a lock-free counter) because saga
118    /// inner futures could in theory parallelize compensations; in
119    /// practice they're serial today, so contention is zero.
120    pub(crate) forward_step_stack: tokio::sync::Mutex<Vec<u32>>,
121}
122
123impl<'a> SagaCtx<'a> {
124    /// Construct a fresh context for a saga that has just allocated
125    /// its `saga_id` (via [`alloc_saga_id`]).
126    pub fn new(state: &'a AppState, saga_id: u64) -> Self {
127        Self {
128            state,
129            saga_id,
130            step_index: AtomicU32::new(0),
131            forward_step_stack: tokio::sync::Mutex::new(Vec::new()),
132        }
133    }
134
135    /// Saga-id this context belongs to. Used by sagas that need to
136    /// log progress with the saga prefix.
137    #[allow(dead_code)]
138    pub fn saga_id(&self) -> u64 {
139        self.saga_id
140    }
141
142    /// Acquire the reducer's state lock for read-only inspection.
143    /// Used by sagas that need to inspect post-step state to decide
144    /// the next step (e.g. RestoreTornOffTab checking whether the
145    /// source workspace is now empty before issuing the cascade
146    /// delete). Hold briefly — the reducer is single-mutex.
147    pub async fn state_lock(&self) -> tokio::sync::MutexGuard<'_, crate::state::State> {
148        self.state.srv_state.lock().await
149    }
150
151    /// Dispatch `cmd` through the srv reducer and apply the emitted
152    /// events to SQLite + the broadcast bus, exactly like the
153    /// in-handler reducer-dispatch helpers.
154    ///
155    /// Returns the emitted event vec on success. If the reducer
156    /// emits any `Event::Error`, the error message is returned and
157    /// SQLite/bus side-effects are skipped — the caller must then
158    /// dispatch compensation for the saga's already-applied steps.
159    pub async fn dispatch(&self, cmd: Command) -> Result<Vec<Event>, String> {
160        // Saga durability — write a `pending` step row before
161        // dispatch so a crash mid-dispatch leaves a recoverable
162        // breadcrumb (PR 2's compensate-on-restart will see it).
163        let idx = self.step_index.fetch_add(1, Ordering::Relaxed);
164        let step_name = command_discriminant_name(&cmd);
165        if let Err(e) = self
166            .state
167            .saga_log
168            .start_step(self.saga_id, idx, &step_name, &cmd)
169        {
170            // Log-write failure is non-fatal: the in-memory saga
171            // path is still authoritative for THIS srv run; we lose
172            // crash-recovery for this step, but the user's command
173            // shouldn't fail because durability hiccupped.
174            tracing::warn!(
175                saga_id = self.saga_id,
176                step_index = idx,
177                "[saga] start_step log write failed: {} — continuing without durable log for this step",
178                e
179            );
180        }
181
182        let events = crate::server::service::dispatch_to_reducer(self.state, cmd).await;
183        if let Some(message) = events.iter().find_map(|e| match e {
184            Event::Error { message, .. } => Some(message.clone()),
185            _ => None,
186        }) {
187            if let Err(e) = self.state.saga_log.fail_step(self.saga_id, idx, &message) {
188                tracing::warn!(
189                    saga_id = self.saga_id,
190                    step_index = idx,
191                    "[saga] fail_step log write failed: {}",
192                    e
193                );
194            }
195            return Err(message);
196        }
197        for ev in &events {
198            if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &self.state.mstore)
199            {
200                // (reagent P1 PR #631 round 2) Mark the step as
201                // failed in the durable log BEFORE returning. Without
202                // this, the step row stays in `pending` state even
203                // though the reducer already applied the command
204                // (line 139); PR 2's compensate-on-restart sees a
205                // `pending` step and can't determine whether the
206                // command was applied.
207                let err_msg = e.to_string();
208                if let Err(log_err) =
209                    self.state.saga_log.fail_step(self.saga_id, idx, &err_msg)
210                {
211                    tracing::warn!(
212                        saga_id = self.saga_id,
213                        step_index = idx,
214                        "[saga] fail_step log write failed during mstore-apply error path: {}",
215                        log_err,
216                    );
217                }
218                return Err(err_msg);
219            }
220        }
221        if let Err(e) = self.state.saga_log.finish_step(self.saga_id, idx, &events) {
222            tracing::warn!(
223                saga_id = self.saga_id,
224                step_index = idx,
225                "[saga] finish_step log write failed: {}",
226                e
227            );
228        }
229        // (codex P1 PR #636 round 4.) Track this idx as a successful
230        // forward step eligible for compensation. The next
231        // `compensate` call will pop this and mark the original step
232        // `compensated`, preventing resume-on-restart from re-replaying
233        // an inverse that already ran in-process.
234        self.forward_step_stack.lock().await.push(idx);
235        crate::server::service::publish_events(self.state, &events);
236        Ok(events)
237    }
238
239    /// Best-effort compensating dispatch. Same as `dispatch` but
240    /// SQLite-write failures are logged and swallowed. Intended for
241    /// the unwind path: the saga is already returning an error to
242    /// the caller; throwing on cleanup hides the original cause and
243    /// prevents subsequent compensating commands from running.
244    pub async fn compensate(&self, cmd: Command) {
245        // Compensation gets its own step row so the durable log
246        // distinguishes "step that succeeded forward" from "step
247        // that ran in unwind". Index continues monotonically from
248        // forward steps so `--diag sagas` shows the full sequence.
249        let idx = self.step_index.fetch_add(1, Ordering::Relaxed);
250        let step_name = command_discriminant_name(&cmd);
251        if let Err(e) = self
252            .state
253            .saga_log
254            .start_step(self.saga_id, idx, &step_name, &cmd)
255        {
256            tracing::warn!(
257                saga_id = self.saga_id,
258                step_index = idx,
259                "[saga] compensate start_step log write failed: {}",
260                e
261            );
262        }
263        let events =
264            crate::server::service::dispatch_to_reducer(self.state, cmd.clone()).await;
265        if let Some(message) = events.iter().find_map(|e| match e {
266            Event::Error { message, .. } => Some(message.clone()),
267            _ => None,
268        }) {
269            tracing::warn!(
270                saga_id = self.saga_id,
271                "[saga] compensation rejected by reducer: {} (cmd discriminant: {:?})",
272                message,
273                std::mem::discriminant(&cmd),
274            );
275            if let Err(e) = self.state.saga_log.fail_step(self.saga_id, idx, &message) {
276                tracing::warn!(
277                    saga_id = self.saga_id,
278                    step_index = idx,
279                    "[saga] compensate fail_step log write failed: {}",
280                    e
281                );
282            }
283            return;
284        }
285        for ev in &events {
286            if let Err(e) =
287                crate::persist_subscriber::apply_event_to_mstore(ev, &self.state.mstore)
288            {
289                tracing::warn!(
290                    saga_id = self.saga_id,
291                    "[saga] compensation: SQLite write failed: {}",
292                    e
293                );
294            }
295        }
296        if let Err(e) = self
297            .state
298            .saga_log
299            .compensate_step(self.saga_id, idx, &events)
300        {
301            tracing::warn!(
302                saga_id = self.saga_id,
303                step_index = idx,
304                "[saga] compensate_step log write failed: {}",
305                e
306            );
307        }
308        // (codex P1 PR #636 round 4.) Pop the most-recent successful
309        // forward step from the stack and mark its original log row
310        // as compensated. This prevents resume-on-restart from
311        // double-replaying the inverse of a step that already had
312        // in-process compensation. Idempotent — UPDATE only matches
313        // rows still in `succeeded` state.
314        if let Some(forward_idx) = self.forward_step_stack.lock().await.pop() {
315            if let Err(e) = self
316                .state
317                .saga_log
318                .mark_step_compensated(self.saga_id, forward_idx)
319            {
320                tracing::warn!(
321                    saga_id = self.saga_id,
322                    forward_step_index = forward_idx,
323                    "[saga] mark_step_compensated (live) log write failed: {} — restart may re-replay this inverse",
324                    e
325                );
326            }
327        }
328        crate::server::service::publish_events(self.state, &events);
329    }
330}
331
332/// Allocate the next saga_id. Monotonic per srv-process run.
333pub fn alloc_saga_id(state: &AppState) -> u64 {
334    state.saga_id_alloc.fetch_add(1, Ordering::Relaxed) + 1
335}
336
337/// Emit `Event::SagaStarted` for a freshly-allocated saga_id.
338/// Sagas call this immediately after `alloc_saga_id` so subscribers
339/// see the start record before any per-step events.
340///
341/// Also writes a `running` row to the durable saga log (PR 1 of
342/// SPEC_SAGA_DURABILITY_2026-05-01.md), recording `input` as the
343/// saga's arguments serialized to JSON. PR 2's `compensate_unresolved`
344/// + `--diag sagas` rely on this for crash-recovery provenance, so
345/// callers should pass a structured representation of their inputs
346/// (typically `serde_json::json!({...})`). (reagent P1 PR #631 —
347/// `Value::Null` placeholder erased provenance.)
348///
349/// **Fail-fast on log error.** (codex P1 PR #631 round 2.) If
350/// `start_saga` fails — most likely a UNIQUE constraint violation
351/// from a saga_id collision — the saga MUST NOT proceed. Otherwise
352/// later `terminate()` calls would `UPDATE saga SET ... WHERE saga_id=?`
353/// against a *different run's* row, mixing lifecycle data across
354/// sagas and silently corrupting the durability log. Returning
355/// `Err` here propagates up to the caller, which records the
356/// failure via `emit_terminal` (with a fresh saga_id allocated by
357/// the caller's `alloc_saga_id` retry path, if any).
358pub async fn emit_saga_started(
359    state: &AppState,
360    saga_id: u64,
361    name: &'static str,
362    input: serde_json::Value,
363) -> Result<(), String> {
364    if let Err(e) = state.saga_log.start_saga(saga_id, name, &input) {
365        let msg = format!(
366            "saga durable start row insert failed for saga_id={}: {} (likely ID collision; refusing to run)",
367            saga_id, e
368        );
369        tracing::error!(
370            saga_id,
371            name,
372            "[saga] {} — aborting saga to avoid corrupting prior run's lifecycle row",
373            msg,
374        );
375        return Err(msg);
376    }
377    let v = state.srv_state.lock().await.bump_version();
378    let _ = state.srv_events_tx.send(Event::SagaStarted {
379        saga_id,
380        name: name.to_string(),
381        version: v,
382    });
383    Ok(())
384}
385
386/// Outcome a saga's inner future hands back to `emit_terminal`.
387///
388/// (codex P1 PR #631) The original PR 1 implementation mapped every
389/// `Err` to `SagaOutcome::Compensated`, which is wrong for timeout/
390/// abort paths: `run_saga` wraps the inner future in
391/// `tokio::time::timeout`, and a timeout cancels the future *before*
392/// it can run its compensation block. Recording "compensated" when
393/// nothing was compensated would hide partially-applied state from
394/// PR 2's `compensate_unresolved` resume scan — exactly the failure
395/// mode this log exists to catch.
396///
397/// `Compensated` should only be recorded when compensation actually
398/// completed; everything else (timeout, panic-converted-to-error,
399/// pre-compensation early-return) records `Failed`, which leaves the
400/// saga visible to PR 2's resume scan.
401#[derive(Debug)]
402pub enum SagaTerminal<'a> {
403    /// All steps applied successfully.
404    Completed,
405    /// Compensation block ran to completion. Caller asserts this only
406    /// after every compensating dispatch returned without error.
407    Compensated { reason: &'a str },
408    /// Saga aborted before/during compensation: timeout, panic,
409    /// pre-compensation early-return, or any other path where
410    /// compensation can't be assumed to have run. Default for the
411    /// "I don't know if compensation completed" case.
412    Failed { reason: &'a str },
413}
414
415/// Emit the saga's terminal lifecycle event + durable log row.
416///
417/// Maps `SagaTerminal` to:
418/// - `Completed` → `Event::SagaCompleted` + log state `completed`.
419/// - `Compensated { reason }` → `Event::SagaFailed { reason }` + log state `compensated`.
420/// - `Failed { reason }` → `Event::SagaFailed { reason }` + log state `failed`.
421///
422/// The renderer-facing event is the same `SagaFailed` for both
423/// non-success paths (the renderer doesn't currently distinguish).
424/// The durable log distinguishes — PR 2's resume scan picks up
425/// `failed` rows where compensation may not have run.
426pub async fn emit_terminal(state: &AppState, saga_id: u64, terminal: SagaTerminal<'_>) {
427    let log_outcome = match &terminal {
428        SagaTerminal::Completed => SagaOutcome::Completed,
429        SagaTerminal::Compensated { reason } => SagaOutcome::Compensated {
430            reason: reason.to_string(),
431        },
432        SagaTerminal::Failed { reason } => SagaOutcome::Failed {
433            reason: reason.to_string(),
434        },
435    };
436    // (codex P1 PR #636 round 7 — reverted from round 6.)
437    // Bulk-mark only on Compensated. Round 6 extended to Failed too,
438    // but BOTH bots flagged that as data-loss: timeout/abort paths
439    // classify as Failed and never run compensation, but the bulk-
440    // mark would relabel forward steps as `compensated`, hiding
441    // them from recovery and leaving side effects permanently
442    // applied.
443    //
444    // Sagas that DO unwind via inner-future ctx.compensate calls
445    // should classify as Compensated (the per-step pop already
446    // marks 1:1; this bulk call catches residual 1:N cases like
447    // tear_off_block's single DeleteWorkspace undoing both
448    // CreateWorkspace + CreateTab). `classify_run_saga_result`
449    // maps non-timeout Err → Compensated to support this; timeouts
450    // → Failed so recovery picks up un-undone rows.
451    if matches!(terminal, SagaTerminal::Compensated { .. }) {
452        if let Err(e) = state.saga_log.mark_all_succeeded_steps_compensated(saga_id) {
453            tracing::warn!(
454                saga_id,
455                "[saga] mark_all_succeeded_steps_compensated failed: {} — restart may re-replay an inverse",
456                e
457            );
458        }
459    }
460    if let Err(e) = state.saga_log.terminate(saga_id, log_outcome) {
461        tracing::warn!(
462            saga_id,
463            "[saga] terminate log write failed: {} — saga lifecycle row will look 'running' to PR 2's resume scan, which will then compensate it",
464            e
465        );
466    }
467    let v = state.srv_state.lock().await.bump_version();
468    let event = match terminal {
469        SagaTerminal::Completed => Event::SagaCompleted {
470            saga_id,
471            version: v,
472        },
473        SagaTerminal::Compensated { reason } | SagaTerminal::Failed { reason } => {
474            Event::SagaFailed {
475                saga_id,
476                reason: reason.to_string(),
477                version: v,
478            }
479        }
480    };
481    let _ = state.srv_events_tx.send(event);
482}
483
484/// Convenience: classify the standard `run_saga` `Result<Value, String>`
485/// outcome into a `SagaTerminal`.
486///
487/// - `Ok(_)` → `Completed`.
488/// - `Err(_)` → `Failed`.
489///
490/// (codex P1 PR #631 round 2.) The earlier round mapped non-timeout
491/// `Err` to `Compensated` on the assumption that "our sagas drive
492/// compensation in their inner future before returning `Err`."
493/// That's true for the *forward* dispatch failures, but
494/// `SagaCtx::compensate` is **best-effort** — if a compensating
495/// dispatch is itself rejected by the reducer, `compensate` logs a
496/// warning and returns without signaling failure. Marking those as
497/// `Compensated` would hide partially-applied state from PR 2's
498/// restart recovery (which scans for `running`/`failed` to know what
499/// to compensate).
500///
501/// Conservative default: classify all errors as `Failed`. Sagas that
502/// can *prove* compensation succeeded (e.g. a future per-step
503/// compensation-success log) construct `SagaTerminal::Compensated`
504/// directly without going through this helper.
505pub fn classify_run_saga_result(result: &Result<serde_json::Value, String>) -> SagaTerminal<'_> {
506    match result {
507        Ok(_) => SagaTerminal::Completed,
508        // Timeouts/aborts: compensation never ran (run_saga's
509        // tokio::time::timeout cancels the inner future before it
510        // can compensate). Classify as Failed so recovery picks up
511        // the un-undone forward steps.
512        Err(reason) if reason.contains("timed out") => SagaTerminal::Failed { reason },
513        // Other Err: by convention, our sagas drive compensation
514        // in their inner future before returning Err (each
515        // ctx.compensate call already marked its target). Classify
516        // as Compensated so emit_terminal's bulk-mark cleans up any
517        // residual succeeded rows from 1:N compensation patterns
518        // (e.g. tear_off_block's single DeleteWorkspace undoing
519        // multiple CreateX steps). Sagas that abort without
520        // compensating should explicitly construct
521        // SagaTerminal::Failed instead of using this helper.
522        // (codex round 7 reversal of round 1's blanket-Failed.)
523        Err(reason) => SagaTerminal::Compensated { reason },
524    }
525}
526
527/// Run a saga's inner future under a 5 s timeout. The inner future
528/// is responsible for emitting `SagaStarted` (the saga itself, since
529/// it owns the saga_id allocation) and any compensation it needs;
530/// `run_saga` only enforces the timeout and emits the terminal
531/// `SagaCompleted` / `SagaFailed`.
532///
533/// Concrete usage (per saga):
534/// ```ignore
535/// pub async fn run(state: &AppState, ...) -> Result<Value, String> {
536///     let saga_id = alloc_saga_id(state);
537///     emit_saga_started(state, saga_id, "tear_off_tab", serde_json::json!({})).await;
538///     let ctx = SagaCtx::new(state, saga_id);
539///     let result = run_saga(run_inner(ctx, ...)).await;
540///     emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
541///     result
542/// }
543/// ```
544pub async fn run_saga<Fut>(name: &'static str, fut: Fut) -> Result<Value, String>
545where
546    Fut: std::future::Future<Output = Result<Value, String>>,
547{
548    match tokio::time::timeout(SAGA_TIMEOUT, fut).await {
549        Ok(r) => r,
550        Err(_) => Err(format!("saga '{}' timed out after {:?}", name, SAGA_TIMEOUT)),
551    }
552}