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// 1. `DeleteBlock { tab_id, block_id }` — reducer removes the block
16//    from canonical state and emits `Event::BlockDeleted`. The
17//    persist subscriber writes SQLite via `wcore::delete_block`
18//    (cascades to layout pruning).
19//
20// **Block controller side-effect:** the legacy RPC handler killed
21// the block's PTY/controller via `blockcontroller::delete_controller`
22// BEFORE the wcore SQLite delete. The saga preserves that ordering
23// — controller-kill happens in this function before the reducer
24// dispatch, since the persist subscriber's `wcore::delete_block`
25// only handles SQLite and layout pruning, not process teardown. We
26// still drop the controller even if the saga later short-circuits
27// (block-not-found): the controller registry is a process-local
28// map, idempotent on missing keys.
29//
30// **Compensation:** delete sagas are awkward to compensate — once
31// the block row + controller are gone, "un-delete" requires
32// reconstructing both the SQLite row and the PTY/process subtree,
33// neither of which is meaningful from saga state. We follow the
34// brief's pragma: log a warning on dispatch failure, no automatic
35// re-create. The reducer's `DeleteBlock` is silent-no-op on missing
36// inputs (see reducer.rs handle_delete_block), so the only failure
37// path is wstore write errors surfaced by the persist subscriber —
38// in which case the controller is already gone (intentional; the
39// PTY can't be partially-killed) and the SQLite row may or may not
40// have been written. PR 2's `compensate_unresolved` resume scan
41// surfaces these via the durable saga log for operator follow-up.
42//
43// **Pre-condition:** the block must exist in the reducer state.
44// Without this, the reducer would silently no-op (handle_delete_block
45// returns an empty event vec on missing tab/block) and the user
46// would see "delete succeeded" while nothing happened. The saga
47// surfaces a clear "block not found" error instead. We check the
48// reducer state (not SQLite) because `Command::DeleteBlock` carries
49// `tab_id` looked up from the block's owning record in srv state
50// (service.rs: `s.blocks[block_id].tab_id`) and we want to validate
51// against the reducer's view of (tab → blocks) — that's what the
52// dispatch will mutate.
53
54use agentmux_common::ipc::Command;
55use serde_json::{json, Value};
56
57use super::{
58    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
59};
60use crate::server::AppState;
61
62/// Run the DeleteBlock saga. On success returns
63/// `{"block_id": "...", "tab_id": "..."}`.
64pub async fn run(
65    state: &AppState,
66    tab_id: String,
67    block_id: String,
68) -> Result<Value, String> {
69    // Pre-condition: block exists and is in the named tab. Reducer
70    // would silent-no-op otherwise (see handle_delete_block); the
71    // saga surfaces a clear error instead.
72    {
73        let s = state.srv_state.lock().await;
74        match s.blocks.get(&block_id) {
75            None => {
76                return Err(format!("DeleteBlock: block not found: {}", block_id));
77            }
78            Some(block) if block.tab_id != tab_id => {
79                return Err(format!(
80                    "DeleteBlock: block {} is in tab {}, not {}",
81                    block_id, block.tab_id, tab_id
82                ));
83            }
84            _ => {}
85        }
86        if !s.tabs.contains_key(&tab_id) {
87            return Err(format!("DeleteBlock: tab not found: {}", tab_id));
88        }
89    }
90
91    let saga_id = alloc_saga_id(state);
92    if let Err(e) = emit_saga_started(
93        state,
94        saga_id,
95        "delete_block",
96        json!({
97            "tab_id": &tab_id,
98            "block_id": &block_id,
99        }),
100    )
101    .await
102    {
103        return Err(e);
104    }
105    let ctx = SagaCtx::new(state, saga_id);
106    let result = run_saga("delete_block", run_inner(ctx, tab_id, block_id.clone())).await;
107    // Controller-kill ordering. Three rounds of bot review:
108    //   * Round 1 (agent): killed BEFORE emit_saga_started → reagent
109    //     P2: side-effect leak if start_saga collides.
110    //   * Round 2 (this PR): conditional on result.is_ok() → codex P2
111    //     round 2: leaks PTY when reducer succeeds but
112    //     `apply_event_to_wstore` fails inside `SagaCtx::dispatch`
113    //     (block already removed from reducer state, RPC returns
114    //     error, retry pre-check sees "block not found" → controller
115    //     never cleaned up).
116    //   * Round 3 (this fix): kill controller whenever the reducer
117    //     dispatched DeleteBlock — i.e., whenever block was removed
118    //     from reducer state. This includes both success and the
119    //     reducer-succeeded-wstore-failed cases. We approximate this
120    //     by checking reducer state for the block: if it's gone, the
121    //     reducer dispatched (regardless of wstore outcome), so kill
122    //     the controller. Idempotent on missing controller.
123    {
124        let block_still_in_reducer =
125            state.srv_state.lock().await.blocks.contains_key(&block_id);
126        if !block_still_in_reducer {
127            crate::backend::blockcontroller::delete_controller(&block_id);
128        }
129    }
130    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
131    result
132}
133
134async fn run_inner(
135    ctx: SagaCtx<'_>,
136    tab_id: String,
137    block_id: String,
138) -> Result<Value, String> {
139    // Step 1: dispatch DeleteBlock through the reducer. The persist
140    // subscriber sees the BlockDeleted event and runs
141    // `wcore::delete_block` (SQLite delete + layout prune).
142    if let Err(reason) = ctx
143        .dispatch(Command::DeleteBlock {
144            tab_id: tab_id.clone(),
145            block_id: block_id.clone(),
146        })
147        .await
148    {
149        // No automatic compensation — un-deleting a block requires
150        // reconstructing both SQLite + PTY which we cannot do from
151        // saga state. Surface the failure; PR 2's restart-recovery
152        // scan picks up the durable log row for operator review.
153        tracing::warn!(
154            tab_id = %tab_id,
155            block_id = %block_id,
156            "[saga] DeleteBlock dispatch failed (no automatic compensation): {}",
157            reason
158        );
159        return Err(format!("DeleteBlock: {}", reason));
160    }
161
162    Ok(json!({
163        "tab_id": tab_id,
164        "block_id": block_id,
165    }))
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::backend::obj::Block;
172    use crate::server::tests::test_state;
173    use agentmux_common::ipc::Event;
174
175    async fn dispatch_apply(
176        state: &crate::server::AppState,
177        cmd: agentmux_common::ipc::Command,
178    ) -> Vec<agentmux_common::ipc::Event> {
179        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
180        for ev in &events {
181            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
182        }
183        events
184    }
185
186    /// Seed a workspace + tab + block and return their ids.
187    async fn seed() -> (
188        crate::server::AppState,
189        String, // workspace_id
190        String, // tab_id
191        String, // block_id
192    ) {
193        let state = test_state();
194        let ws_evs = dispatch_apply(
195            &state,
196            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
197        )
198        .await;
199        let ws_id = ws_evs
200            .iter()
201            .find_map(|e| match e {
202                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
203                _ => None,
204            })
205            .unwrap();
206        let tab_evs = dispatch_apply(
207            &state,
208            agentmux_common::ipc::Command::CreateTab {
209                workspace_id: ws_id.clone(),
210                name: "t".into(),
211            },
212        )
213        .await;
214        let tab_id = tab_evs
215            .iter()
216            .find_map(|e| match e {
217                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
218                _ => None,
219            })
220            .unwrap();
221        let blk_evs = dispatch_apply(
222            &state,
223            agentmux_common::ipc::Command::CreateBlock {
224                tab_id: tab_id.clone(),
225                meta: serde_json::Value::Null,
226            },
227        )
228        .await;
229        let block_id = blk_evs
230            .iter()
231            .find_map(|e| match e {
232                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
233                _ => None,
234            })
235            .unwrap();
236        (state, ws_id, tab_id, block_id)
237    }
238
239    #[tokio::test]
240    async fn happy_path_removes_block_from_reducer_and_sqlite() {
241        let (state, _ws_id, tab_id, block_id) = seed().await;
242
243        // Sanity: block is present pre-delete.
244        {
245            let s = state.srv_state.lock().await;
246            assert!(s.blocks.contains_key(&block_id));
247            assert_eq!(s.tabs[&tab_id].block_ids, vec![block_id.clone()]);
248        }
249        assert!(state.wstore.get::<Block>(&block_id).unwrap().is_some());
250
251        let result = run(&state, tab_id.clone(), block_id.clone()).await.unwrap();
252        assert_eq!(result["block_id"], block_id);
253        assert_eq!(result["tab_id"], tab_id);
254
255        // Reducer: block gone, tab's block_ids empty.
256        let s = state.srv_state.lock().await;
257        assert!(!s.blocks.contains_key(&block_id));
258        assert!(s.tabs[&tab_id].block_ids.is_empty());
259        drop(s);
260
261        // SQLite: block gone.
262        assert!(state.wstore.get::<Block>(&block_id).unwrap().is_none());
263    }
264
265    #[tokio::test]
266    async fn rejects_when_block_not_found() {
267        let (state, _ws_id, tab_id, _block_id) = seed().await;
268        let err = run(&state, tab_id, "ghost-block".into()).await.unwrap_err();
269        assert!(err.contains("block not found"), "got: {}", err);
270    }
271
272    #[tokio::test]
273    async fn rejects_when_block_in_different_tab() {
274        let (state, _ws_id, tab_id, block_id) = seed().await;
275        // Create a second tab; ask to delete block via that tab's id.
276        let tab_evs = dispatch_apply(
277            &state,
278            agentmux_common::ipc::Command::CreateTab {
279                workspace_id: _ws_id.clone(),
280                name: "other".into(),
281            },
282        )
283        .await;
284        let other_tab = tab_evs
285            .iter()
286            .find_map(|e| match e {
287                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
288                _ => None,
289            })
290            .unwrap();
291        let err = run(&state, other_tab, block_id.clone()).await.unwrap_err();
292        assert!(
293            err.contains("is in tab") && err.contains(&tab_id),
294            "got: {}",
295            err
296        );
297        // Block must still be present (saga rejected pre-dispatch).
298        let s = state.srv_state.lock().await;
299        assert!(s.blocks.contains_key(&block_id));
300    }
301}