Skip to main content

agentmux_srv/sagas/
delete_block.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.7 (Step 5 PR 1) — DeleteBlock saga.
5//
6// Replaces the SQLite-first delete pattern in `service.rs`'s
7// `("object", "DeleteBlock")` handler with a reducer-driven saga.
8// The legacy handler called `wcore::delete_block` first and then
9// dispatched `Command::DeleteBlock` to keep the reducer in sync — a
10// short-circuit that pre-dates the saga coordinator + persist
11// subscriber pattern (closes gap §4 in
12// `docs/retro/reducer-architecture-gaps-2026-05-01.md`).
13//
14// **Steps:**
15// 0. `agent_teardown::run` for the block, with the caller's policy
16//    (`Policy::close()` by default, `Policy::quit()` from `self_quit`) — the same graceful
17//    shutdown the pane's × uses (interrupt a running turn, EOF, force-kill
18//    only at the deadline, release its processes, save final state),
19//    BEFORE the records go (SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md
20//    §4.2).
21// 1. `DeleteBlock { tab_id, block_id }` — reducer removes the block
22//    from canonical state and emits `Event::BlockDeleted`. The
23//    persist subscriber writes SQLite via `wcore::delete_block`.
24// 2. `LayoutDeleteNodeByBlock` (SPEC_864 site #6) — reducer prunes the
25//    block from `db_layout`, the single writer's own copy of the tree.
26//    For one tab of a stacked pane it removes just that member and keeps
27//    the pane and its other tabs; the last member takes the leaf.
28// 2b. `queue_source_layout_delete` — tells any frontend that already
29//    has this tab's tree loaded (via `LayoutState.pendingbackendactions`)
30//    to prune the node too. Added after
31//    `INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_RESURRECTION_2026_07_08.md`
32//    found that without this, step 2's direct backend write is
33//    invisible to an already-loaded frontend, whose next unrelated
34//    edit in the tab silently resurrects the just-pruned node as a
35//    dangling reference.
36//
37// **Block controller side-effect:** the process is stopped gracefully by
38// step 0. `blockcontroller::delete_controller` then runs AFTER the saga,
39// whenever the reducer has removed the block (see the round-3 note in
40// `run`); by then it is idempotent cleanup of an already-stopped
41// controller, not a kill. (This header used to say the controller was
42// killed before the reducer dispatch — the legacy RPC handler's order,
43// long since replaced; that stale line misled a reader into thinking this
44// saga hard-kills.)
45//
46// This saga is therefore the right primitive for a close srv starts
47// itself on one tab: unlike `close_pane::run` with one id, which queues a
48// frontend action only when the whole leaf goes, step 2b updates an
49// already-loaded frontend.
50//
51// **Compensation:** delete sagas are awkward to compensate — once
52// the block row + controller are gone, "un-delete" requires
53// reconstructing both the SQLite row and the PTY/process subtree,
54// neither of which is meaningful from saga state. We follow the
55// brief's pragma: log a warning on dispatch failure, no automatic
56// re-create. The reducer's `DeleteBlock` is silent-no-op on missing
57// inputs (see reducer.rs handle_delete_block), so the only failure
58// path is mstore write errors surfaced by the persist subscriber —
59// in which case the controller is already gone (intentional; the
60// PTY can't be partially-killed) and the SQLite row may or may not
61// have been written. PR 2's `compensate_unresolved` resume scan
62// surfaces these via the durable saga log for operator follow-up.
63//
64// **Pre-condition:** the block must exist in the reducer state.
65// Without this, the reducer would silently no-op (handle_delete_block
66// returns an empty event vec on missing tab/block) and the user
67// would see "delete succeeded" while nothing happened. The saga
68// surfaces a clear "block not found" error instead. We check the
69// reducer state (not SQLite) because `Command::DeleteBlock` carries
70// `tab_id` looked up from the block's owning record in srv state
71// (service.rs: `s.blocks[block_id].tab_id`) and we want to validate
72// against the reducer's view of (tab → blocks) — that's what the
73// dispatch will mutate.
74
75use agentmux_common::ipc::Command;
76use serde_json::{json, Value};
77
78use super::{
79    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
80};
81use crate::server::AppState;
82
83/// Run the DeleteBlock saga. On success returns
84/// `{"block_id": "...", "tab_id": "..."}`.
85pub async fn run(
86    state: &AppState,
87    tab_id: String,
88    block_id: String,
89) -> Result<Value, String> {
90    run_with_policy(state, tab_id, block_id, super::agent_teardown::Policy::close()).await.0
91}
92
93/// [`run`] with the teardown policy for the block (`self_quit` passes
94/// `Policy::quit()`). Returns the saga's result and the teardown's report;
95/// the report is there whenever the teardown ran, even if the close then
96/// failed, and `None` only when the close failed before it.
97pub async fn run_with_policy(
98    state: &AppState,
99    tab_id: String,
100    block_id: String,
101    policy: super::agent_teardown::Policy,
102) -> (Result<Value, String>, Option<super::agent_teardown::TeardownReport>) {
103    let mut report = None;
104    let result = run_inner_with_policy(state, tab_id, block_id, policy, &mut report).await;
105    (result, report)
106}
107
108async fn run_inner_with_policy(
109    state: &AppState,
110    tab_id: String,
111    block_id: String,
112    policy: super::agent_teardown::Policy,
113    report_out: &mut Option<super::agent_teardown::TeardownReport>,
114) -> Result<Value, String> {
115    // Pre-condition: block exists and is in the named tab. Reducer
116    // would silent-no-op otherwise (see handle_delete_block); the
117    // saga surfaces a clear error instead.
118    {
119        let s = state.srv_state.lock().await;
120        match s.blocks.get(&block_id) {
121            None => {
122                return Err(format!("DeleteBlock: block not found: {}", block_id));
123            }
124            Some(block) if block.tab_id != tab_id => {
125                return Err(format!(
126                    "DeleteBlock: block {} is in tab {}, not {}",
127                    block_id, block.tab_id, tab_id
128                ));
129            }
130            _ => {}
131        }
132        if !s.tabs.contains_key(&tab_id) {
133            return Err(format!("DeleteBlock: tab not found: {}", tab_id));
134        }
135    }
136
137    let saga_id = alloc_saga_id(state);
138    if let Err(e) = emit_saga_started(
139        state,
140        saga_id,
141        "delete_block",
142        json!({
143            "tab_id": &tab_id,
144            "block_id": &block_id,
145        }),
146    )
147    .await
148    {
149        return Err(e);
150    }
151    // Stop the block's process and save its final state BEFORE its records
152    // are deleted — the same per-agent order the pane's × uses
153    // (`close_pane`, SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md
154    // §4.2). Deleting first left the controller's own exit cleanup writing
155    // to a block that no longer existed. After `emit_saga_started`, so a
156    // saga-start collision still has no side effect (round 1 below).
157    *report_out = Some(super::agent_teardown::run(state, &block_id, policy).await);
158    let ctx = SagaCtx::new(state, saga_id);
159    let result = run_saga("delete_block", run_inner(ctx, tab_id, block_id.clone())).await;
160    // Controller-kill ordering. Three rounds of bot review:
161    //   * Round 1 (agent): killed BEFORE emit_saga_started → reagent
162    //     P2: side-effect leak if start_saga collides.
163    //   * Round 2 (this PR): conditional on result.is_ok() → codex P2
164    //     round 2: leaks PTY when reducer succeeds but
165    //     `apply_event_to_mstore` fails inside `SagaCtx::dispatch`
166    //     (block already removed from reducer state, RPC returns
167    //     error, retry pre-check sees "block not found" → controller
168    //     never cleaned up).
169    //   * Round 3 (this fix): kill controller whenever the reducer
170    //     dispatched DeleteBlock — i.e., whenever block was removed
171    //     from reducer state. This includes both success and the
172    //     reducer-succeeded-mstore-failed cases. We approximate this
173    //     by checking reducer state for the block: if it's gone, the
174    //     reducer dispatched (regardless of mstore outcome), so kill
175    //     the controller. Idempotent on missing controller.
176    {
177        let block_still_in_reducer =
178            state.srv_state.lock().await.blocks.contains_key(&block_id);
179        if !block_still_in_reducer {
180            crate::sagas::agent_teardown::discard(&block_id);
181            // MPS persist_map is keyed by (event, scope) and its key set is
182            // never otherwise pruned — a deleted block's persisted history
183            // (install_progress, block:activity, ...) would linger for the
184            // life of the process. Purge it alongside the controller kill.
185            state.broker.purge_scope(&format!("block:{}", block_id));
186        }
187    }
188    if let Err(reason) = &result {
189        // The pane is showing the shutdown log and waiting; tell it why.
190        super::close_pane::publish_shutdown_error(state, &block_id, reason);
191    }
192    super::close_pane::finish_close(state, &block_id).await;
193    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
194    result
195}
196
197async fn run_inner(
198    ctx: SagaCtx<'_>,
199    tab_id: String,
200    block_id: String,
201) -> Result<Value, String> {
202    // Step 1: dispatch DeleteBlock through the reducer. The persist
203    // subscriber sees the BlockDeleted event and runs
204    // `wcore::delete_block` (SQLite row deletes; the layout prune moved
205    // to Step 2 — SPEC_864 site #6).
206    if let Err(reason) = ctx
207        .dispatch(Command::DeleteBlock {
208            tab_id: tab_id.clone(),
209            block_id: block_id.clone(),
210        })
211        .await
212    {
213        // No automatic compensation — un-deleting a block requires
214        // reconstructing both SQLite + PTY which we cannot do from
215        // saga state. Surface the failure; PR 2's restart-recovery
216        // scan picks up the durable log row for operator review.
217        tracing::warn!(
218            tab_id = %tab_id,
219            block_id = %block_id,
220            "[saga] DeleteBlock dispatch failed (no automatic compensation): {}",
221            reason
222        );
223        return Err(format!("DeleteBlock: {}", reason));
224    }
225
226    // Step 2 (SPEC_864 site #6): prune the deleted block's layout node
227    // through the reducer — the single writer of db_layout — instead of
228    // the retired wcore-direct prune the persist subscriber used to run.
229    // The reducer resolves block→node itself (it owns the tree) and
230    // silently no-ops when the block has no node (frontend already
231    // pushed a pruned tree, block never laid out, empty tab). The
232    // resulting `LayoutNodeDeleted{new_tree, tree_cleared}` event is
233    // what the subscriber persists.
234    //
235    // Best-effort, mirroring the old wcore prune's `let _ =
236    // store.update(&mut layout)`: a prune failure must not fail the
237    // block deletion itself (the block + controller are already gone).
238    //
239    // CORRECTION (found via INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_
240    // RESURRECTION_2026_07_08.md): this comment used to claim "an
241    // orphaned node is exactly what the frontend's own delete-push
242    // converges away" — that's backwards. The frontend's `LayoutModel`
243    // doesn't auto-sync from a bare backend rootnode write (see
244    // `layout_helpers.rs::queue_target_layout_insert`'s doc comment); a
245    // frontend that already has this tab's tree loaded is never told
246    // this dispatch happened, so its *next* unrelated edit re-pushes
247    // its stale copy — still containing this node — and resurrects the
248    // dangling reference instead of converging it away. Step 2b below
249    // closes that gap via the same `pendingbackendactions` channel
250    // tear-off/redock already use for exactly this reason.
251    if let Err(reason) = ctx
252        .dispatch(Command::LayoutDeleteNodeByBlock {
253            tab_id: tab_id.clone(),
254            block_id: block_id.clone(),
255            correlation_id: String::new(),
256        })
257        .await
258    {
259        tracing::warn!(
260            tab_id = %tab_id,
261            block_id = %block_id,
262            "[saga] LayoutDeleteNodeByBlock dispatch failed (best-effort; layout node may orphan until frontend push): {}",
263            reason
264        );
265    }
266
267    // Step 2b: tell any frontend that already has this tab's layout
268    // tree loaded to prune the node too, via the same pendingbackendactions
269    // channel `queue_source_layout_delete` already uses for tear-off's
270    // source-tab prune. Without this, Step 2's direct reducer write is
271    // invisible to an already-loaded frontend, and its next unrelated
272    // edit in this tab resurrects the just-deleted node as a dangling
273    // reference (empty/dead pane). Best-effort for the same reason as
274    // Step 2 — the block is already gone either way.
275    if let Err(reason) =
276        crate::server::service::layout_helpers::queue_source_layout_delete(
277            ctx.state, &tab_id, &block_id,
278        )
279        .await
280    {
281        tracing::warn!(
282            tab_id = %tab_id,
283            block_id = %block_id,
284            "[saga] queue_source_layout_delete failed (best-effort; a frontend with a stale loaded copy of this tab may resurrect the node): {}",
285            reason
286        );
287    }
288
289    Ok(json!({
290        "tab_id": tab_id,
291        "block_id": block_id,
292    }))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::backend::obj::Block;
299    use crate::server::tests::test_state;
300    use agentmux_common::ipc::Event;
301
302    async fn dispatch_apply(
303        state: &crate::server::AppState,
304        cmd: agentmux_common::ipc::Command,
305    ) -> Vec<agentmux_common::ipc::Event> {
306        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
307        for ev in &events {
308            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
309        }
310        events
311    }
312
313    /// Seed a workspace + tab + block and return their ids.
314    async fn seed() -> (
315        crate::server::AppState,
316        String, // workspace_id
317        String, // tab_id
318        String, // block_id
319    ) {
320        let state = test_state();
321        let ws_evs = dispatch_apply(
322            &state,
323            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
324        )
325        .await;
326        let ws_id = ws_evs
327            .iter()
328            .find_map(|e| match e {
329                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
330                _ => None,
331            })
332            .unwrap();
333        let tab_evs = dispatch_apply(
334            &state,
335            agentmux_common::ipc::Command::CreateTab {
336                workspace_id: ws_id.clone(),
337                name: "t".into(),
338            },
339        )
340        .await;
341        let tab_id = tab_evs
342            .iter()
343            .find_map(|e| match e {
344                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
345                _ => None,
346            })
347            .unwrap();
348        let blk_evs = dispatch_apply(
349            &state,
350            agentmux_common::ipc::Command::CreateBlock {
351                tab_id: tab_id.clone(),
352                meta: serde_json::Value::Null,
353            },
354        )
355        .await;
356        let block_id = blk_evs
357            .iter()
358            .find_map(|e| match e {
359                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
360                _ => None,
361            })
362            .unwrap();
363        (state, ws_id, tab_id, block_id)
364    }
365
366    #[tokio::test]
367    async fn happy_path_removes_block_from_reducer_and_sqlite() {
368        let (state, _ws_id, tab_id, block_id) = seed().await;
369
370        // Sanity: block is present pre-delete.
371        {
372            let s = state.srv_state.lock().await;
373            assert!(s.blocks.contains_key(&block_id));
374            assert_eq!(s.tabs[&tab_id].block_ids, vec![block_id.clone()]);
375        }
376        assert!(state.mstore.get::<Block>(&block_id).unwrap().is_some());
377
378        let result = run(&state, tab_id.clone(), block_id.clone()).await.unwrap();
379        assert_eq!(result["block_id"], block_id);
380        assert_eq!(result["tab_id"], tab_id);
381
382        // Reducer: block gone, tab's block_ids empty.
383        let s = state.srv_state.lock().await;
384        assert!(!s.blocks.contains_key(&block_id));
385        assert!(s.tabs[&tab_id].block_ids.is_empty());
386        drop(s);
387
388        // SQLite: block gone.
389        assert!(state.mstore.get::<Block>(&block_id).unwrap().is_none());
390    }
391
392    #[tokio::test]
393    async fn saga_prunes_layout_through_reducer_coherently() {
394        // SPEC_864 site #6 — the layout prune is Step 2 of this saga
395        // (Command::LayoutDeleteNodeByBlock), not the retired
396        // wcore-direct write in the persist subscriber. Invariant:
397        // after the saga, the reducer's TabRecord tree and
398        // db_layout's rootnode agree (single-writer coherence), and a
399        // single-block tree ends genuinely empty on both sides.
400        let (state, _ws_id, tab_id, block_id) = seed().await;
401
402        // Seed a layout tree holding the block, reducer-routed +
403        // persisted (same LayoutSetTree path the frontend push uses).
404        let tree = agentmux_common::LayoutNode {
405            id: "n-solo".into(),
406            data: Some(agentmux_common::LayoutNodeData {
407                block_id: block_id.clone(),
408                ..Default::default()
409            }),
410            ..Default::default()
411        };
412        dispatch_apply(
413            &state,
414            agentmux_common::ipc::Command::LayoutSetTree {
415                tab_id: tab_id.clone(),
416                new_tree: Some(tree),
417                correlation_id: String::new(),
418                slices: None,
419            },
420        )
421        .await;
422        // Sanity: reducer + SQLite both hold the seeded tree.
423        {
424            let s = state.srv_state.lock().await;
425            assert!(s.tabs[&tab_id].rootnode.is_some());
426        }
427        let layout_id = state
428            .mstore
429            .get::<crate::backend::obj::Tab>(&tab_id)
430            .unwrap()
431            .unwrap()
432            .layoutstate;
433        assert!(state
434            .mstore
435            .get::<crate::backend::obj::LayoutState>(&layout_id)
436            .unwrap()
437            .unwrap()
438            .rootnode
439            .is_some());
440
441        run(&state, tab_id.clone(), block_id.clone()).await.unwrap();
442
443        // Reducer tree: pruned to empty (block was the sole leaf).
444        {
445            let s = state.srv_state.lock().await;
446            assert!(
447                s.tabs[&tab_id].rootnode.is_none(),
448                "reducer tree must be pruned by the saga's Step-2 dispatch"
449            );
450        }
451        // db_layout: matches the reducer (tree_cleared persisted the wipe).
452        assert!(
453            state
454                .mstore
455                .get::<crate::backend::obj::LayoutState>(&layout_id)
456                .unwrap()
457                .unwrap()
458                .rootnode
459                .is_none(),
460            "db_layout must agree with the reducer post-saga (single-writer coherence)"
461        );
462    }
463
464    #[tokio::test]
465    async fn saga_queues_a_pendingbackendactions_delete_for_a_stale_frontend() {
466        // Regression test for
467        // INVESTIGATION_LAYOUT_DEAD_SPACE_STALE_TREE_RESURRECTION_2026_07_08.md:
468        // Step 2 (LayoutDeleteNodeByBlock) alone prunes db_layout correctly
469        // but is invisible to a frontend that already has this tab's tree
470        // loaded — its next unrelated edit re-pushes its stale copy and
471        // resurrects the just-deleted node. Step 2b must queue a "delete"
472        // pendingbackendactions entry so that frontend converges instead.
473        let (state, _ws_id, tab_id, block_id) = seed().await;
474
475        let tree = agentmux_common::LayoutNode {
476            id: "n-solo".into(),
477            data: Some(agentmux_common::LayoutNodeData {
478                block_id: block_id.clone(),
479                ..Default::default()
480            }),
481            ..Default::default()
482        };
483        dispatch_apply(
484            &state,
485            agentmux_common::ipc::Command::LayoutSetTree {
486                tab_id: tab_id.clone(),
487                new_tree: Some(tree),
488                correlation_id: String::new(),
489                slices: None,
490            },
491        )
492        .await;
493
494        run(&state, tab_id.clone(), block_id.clone()).await.unwrap();
495
496        let tab = state
497            .mstore
498            .must_get::<crate::backend::obj::Tab>(&tab_id)
499            .unwrap();
500        let layout = state
501            .mstore
502            .must_get::<crate::backend::obj::LayoutState>(&tab.layoutstate)
503            .unwrap();
504        let actions = layout.pendingbackendactions.unwrap_or_default();
505        let delete_action = actions
506            .iter()
507            .find(|a| a.actiontype == "delete" && a.blockid == block_id);
508        assert!(
509            delete_action.is_some(),
510            "expected a queued 'delete' pendingbackendactions entry for the pruned block \
511             (matches LayoutTreeActionType.DeleteNode in frontend/layout/lib/types.ts), got: {actions:?}"
512        );
513    }
514
515    /// SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §2.2: deleting one tab
516    /// of a stacked pane (e.g. a terminal tab whose shell exited —
517    /// close-on-exit runs this saga) keeps the pane and its other tabs. It
518    /// used to delete the whole leaf, leaving the sibling (an agent, say)
519    /// running with no pane.
520    #[tokio::test]
521    async fn deleting_one_tab_of_a_stacked_pane_keeps_the_pane_and_its_sibling() {
522        let (state, _ws_id, tab_id, agent) = seed().await;
523        let term = dispatch_apply(
524            &state,
525            agentmux_common::ipc::Command::CreateBlock {
526                tab_id: tab_id.clone(),
527                meta: serde_json::Value::Null,
528            },
529        )
530        .await
531        .iter()
532        .find_map(|e| match e {
533            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
534            _ => None,
535        })
536        .unwrap();
537        dispatch_apply(
538            &state,
539            agentmux_common::ipc::Command::LayoutSetTree {
540                tab_id: tab_id.clone(),
541                new_tree: Some(agentmux_common::LayoutNode {
542                    id: "pane".into(),
543                    data: Some(agentmux_common::LayoutNodeData {
544                        block_id: term.clone(),
545                        block_stack: vec![agent.clone(), term.clone()],
546                        active_block_id: term.clone(),
547                        ..Default::default()
548                    }),
549                    ..Default::default()
550                }),
551                correlation_id: String::new(),
552                slices: None,
553            },
554        )
555        .await;
556
557        run(&state, tab_id.clone(), term.clone()).await.unwrap();
558
559        let s = state.srv_state.lock().await;
560        assert!(s.blocks.contains_key(&agent), "sibling block survives");
561        let leaf = s.tabs[&tab_id].rootnode.as_ref().expect("the pane survives");
562        let data = leaf.data.as_ref().unwrap();
563        assert_eq!(data.block_stack, vec![agent.clone()]);
564        assert_eq!(data.block_id, agent, "the sibling becomes the visible tab");
565    }
566
567    #[tokio::test]
568    async fn rejects_when_block_not_found() {
569        let (state, _ws_id, tab_id, _block_id) = seed().await;
570        let err = run(&state, tab_id, "ghost-block".into()).await.unwrap_err();
571        assert!(err.contains("block not found"), "got: {}", err);
572    }
573
574    #[tokio::test]
575    async fn rejects_when_block_in_different_tab() {
576        let (state, _ws_id, tab_id, block_id) = seed().await;
577        // Create a second tab; ask to delete block via that tab's id.
578        let tab_evs = dispatch_apply(
579            &state,
580            agentmux_common::ipc::Command::CreateTab {
581                workspace_id: _ws_id.clone(),
582                name: "other".into(),
583            },
584        )
585        .await;
586        let other_tab = tab_evs
587            .iter()
588            .find_map(|e| match e {
589                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
590                _ => None,
591            })
592            .unwrap();
593        let err = run(&state, other_tab, block_id.clone()).await.unwrap_err();
594        assert!(
595            err.contains("is in tab") && err.contains(&tab_id),
596            "got: {}",
597            err
598        );
599        // Block must still be present (saga rejected pre-dispatch).
600        let s = state.srv_state.lock().await;
601        assert!(s.blocks.contains_key(&block_id));
602    }
603}