Skip to main content

agentmux_srv/sagas/
delete_tab.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.7 (Step 5 PR 1) — DeleteTab saga.
5//
6// Replaces the SQLite-first delete pattern in `service.rs`'s
7// `("workspace", "CloseTab")` handler with a reducer-driven saga.
8// The legacy handler called `wcore::delete_tab` first and then
9// dispatched `Command::DeleteTab` 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. `DeleteTab { workspace_id, tab_id }` — reducer removes the tab
16//    from canonical state (cascading to its blocks in-state) and
17//    emits `Event::TabDeleted` plus optional `Event::ActiveTabChanged`
18//    if the removed tab was active. The persist subscriber writes
19//    SQLite via `wcore::delete_tab` (cascades to blocks + layout +
20//    PTY controllers).
21//
22// **Pre-conditions:**
23// 1. Tab must exist in the reducer state and be in the named
24//    workspace.
25// 2. Tab must NOT be the workspace's last tab. Mirrors `TearOffTab`'s
26//    "cannot tear off last tab" guard. Removing the last tab leaves
27//    an empty workspace whose UI representation is awkward — callers
28//    that want full-workspace teardown should issue
29//    `DeleteWorkspace` (which is its own saga in Step 5 PR 2).
30//
31// **Block controller cascade:** the persist subscriber's
32// `apply_tab_deleted` invokes `wcore::delete_tab` which calls
33// `delete_tab_inner` → `delete_controller(block_id)` for each block.
34// The saga therefore does not need to do explicit controller-kill
35// like `delete_block.rs` does (DeleteBlock's persist path is
36// `wcore::delete_block` which only handles the SQLite + layout
37// prune, not controller teardown).
38//
39// **Compensation:** like DeleteBlock, un-deleting a tab requires
40// reconstructing SQLite rows (Tab + LayoutState + cascaded Blocks)
41// AND re-spawning the PTY controllers — neither feasible from saga
42// state. Pragma per the brief: log warning on failure, no automatic
43// re-create. PR 2's restart-recovery scan will surface partial
44// failures from the durable saga log.
45//
46// **Pre-condition source — reducer vs SQLite:** check reducer state.
47// The legacy CloseTab path was SQLite-first which made SQLite
48// authoritative; the saga inverts this — the reducer's `tab_ids` is
49// the source of truth, with the persist subscriber writing SQLite
50// in response. Tabs that exist in SQLite but not in the reducer
51// indicate a bootstrap-mismatch (separate bug); the saga rejecting
52// them is the correct conservative behaviour.
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 DeleteTab saga. On success returns
63/// `{"workspace_id": "...", "tab_id": "..."}`.
64pub async fn run(
65    state: &AppState,
66    workspace_id: String,
67    tab_id: String,
68) -> Result<Value, String> {
69    // Held from the last-tab pre-check through the DeleteTab dispatch.
70    // Agents are now stopped BEFORE the dispatch (pane-close spec §9.5), so
71    // the pre-check must stay true until then: two concurrent closes of the
72    // last two tabs would otherwise both pass it, both stop their agents, and
73    // the loser would leave a tab full of stopped agents.
74    let _close_guard = super::close_pane::workspace_close_lock(&workspace_id).await;
75
76    // Pre-conditions: tab exists in workspace; not the last tab.
77    //
78    // **Last-tab pre-check is best-effort.** A reducer-level guard
79    // would be atomic but it broke legitimate CreateTab compensation
80    // (codex P1 round 2 #633) and frontend keyboard flow (codex P1
81    // round 1 #633). Round 3 walked back the reducer guard. The
82    // saga keeps a soft pre-check here as a UX guard; the TOCTOU
83    // window (two concurrent CloseTabs on different tabs in a 2-tab
84    // workspace) is reachable in theory but the user-facing call
85    // sites (the tabbar close button and the keyboard handler
86    // `simpleCloseStaticTab` in keymodel-nav.ts, both routed through
87    // tabbar.tsx's `requestClose`) refuse once only one tab is left,
88    // so user-driven concurrent CloseTabs can't reach this saga.
89    // Automated test harnesses could still race; document the
90    // limitation, accept it.
91    {
92        let s = state.srv_state.lock().await;
93        let Some(workspace) = s.workspaces.get(&workspace_id) else {
94            return Err(format!(
95                "DeleteTab: workspace not found: {}",
96                workspace_id
97            ));
98        };
99        if !workspace.tab_ids.iter().any(|t| t == &tab_id) {
100            return Err(format!(
101                "DeleteTab: tab {} is not in workspace {}",
102                tab_id, workspace_id
103            ));
104        }
105        if workspace.tab_ids.len() <= 1 {
106            return Err(format!(
107                "DeleteTab: cannot delete last tab in workspace {}",
108                workspace_id
109            ));
110        }
111        if !s.tabs.contains_key(&tab_id) {
112            // Inconsistent state — workspace.tab_ids references a
113            // tab that's not in state.tabs. Reject; bootstrap-rebuild
114            // gap is a separate concern.
115            return Err(format!("DeleteTab: tab record missing: {}", tab_id));
116        }
117    }
118
119    // Capture the tab's block_ids before dispatch — we'll use these
120    // to clean up PTY controllers if the saga partially succeeds
121    // (reducer dispatched, SQLite apply failed). (codex P2 PR #633
122    // round 3.)
123    let block_ids_to_cleanup: Vec<String> = {
124        let s = state.srv_state.lock().await;
125        s.tabs
126            .get(&tab_id)
127            .map(|t| t.block_ids.clone())
128            .unwrap_or_default()
129    };
130
131    let saga_id = alloc_saga_id(state);
132    if let Err(e) = emit_saga_started(
133        state,
134        saga_id,
135        "delete_tab",
136        json!({
137            "workspace_id": &workspace_id,
138            "tab_id": &tab_id,
139        }),
140    )
141    .await
142    {
143        return Err(e);
144    }
145    // Stop every agent in the tab — gracefully, concurrently, one deadline —
146    // before its records go (pane-close spec §9.5). The persist step's
147    // `delete_tab_inner` still calls `delete_controller` per block; it finds
148    // nothing left to stop.
149    super::agent_teardown::run_many(state, &block_ids_to_cleanup, super::agent_teardown::Policy::close()).await;
150    let ctx = SagaCtx::new(state, saga_id);
151    let result = run_saga("delete_tab", run_inner(ctx, workspace_id, tab_id.clone())).await;
152    // If the tab is gone from reducer state, the reducer dispatched
153    // (whether or not SQLite apply succeeded). Kill controllers for
154    // every block that was in the tab. Idempotent on missing
155    // controllers. Same pattern as `delete_block::run` (codex P2
156    // round 2 + 3).
157    {
158        let tab_still_in_reducer = state.srv_state.lock().await.tabs.contains_key(&tab_id);
159        if !tab_still_in_reducer {
160            for block_id in &block_ids_to_cleanup {
161                crate::sagas::agent_teardown::discard(block_id);
162            }
163        }
164    }
165    for block_id in &block_ids_to_cleanup {
166        super::close_pane::finish_close(state, block_id).await;
167    }
168    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
169    result
170}
171
172async fn run_inner(
173    ctx: SagaCtx<'_>,
174    workspace_id: String,
175    tab_id: String,
176) -> Result<Value, String> {
177    // Step 1: dispatch DeleteTab through the reducer. The persist
178    // subscriber sees TabDeleted (and optional ActiveTabChanged) and
179    // runs `wcore::delete_tab` which cascades to blocks + layout +
180    // PTY controllers.
181    if let Err(reason) = ctx
182        .dispatch(Command::DeleteTab {
183            workspace_id: workspace_id.clone(),
184            tab_id: tab_id.clone(),
185            // User-facing flow — reducer enforces last-tab guard
186            // atomically (codex P2 round 4 PR #633).
187            force: false,
188        })
189        .await
190    {
191        // No automatic compensation. See module doc-comment for
192        // rationale.
193        tracing::warn!(
194            workspace_id = %workspace_id,
195            tab_id = %tab_id,
196            "[saga] DeleteTab dispatch failed (no automatic compensation): {}",
197            reason
198        );
199        return Err(format!("DeleteTab: {}", reason));
200    }
201
202    Ok(json!({
203        "workspace_id": workspace_id,
204        "tab_id": tab_id,
205    }))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::backend::obj::{Tab, Workspace};
212    use crate::server::tests::test_state;
213    use agentmux_common::ipc::Event;
214
215    async fn dispatch_apply(
216        state: &crate::server::AppState,
217        cmd: agentmux_common::ipc::Command,
218    ) -> Vec<agentmux_common::ipc::Event> {
219        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
220        for ev in &events {
221            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
222        }
223        events
224    }
225
226    /// Seed: a workspace with two tabs (last-tab guard requires >1).
227    /// Returns `(state, ws_id, tab_a_id, tab_b_id)`.
228    async fn seed_workspace_with_two_tabs() -> (
229        crate::server::AppState,
230        String,
231        String,
232        String,
233    ) {
234        let state = test_state();
235        let ws_evs = dispatch_apply(
236            &state,
237            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
238        )
239        .await;
240        let ws_id = ws_evs
241            .iter()
242            .find_map(|e| match e {
243                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
244                _ => None,
245            })
246            .unwrap();
247        let mut tab_ids = Vec::new();
248        for name in &["tab-a", "tab-b"] {
249            let tab_evs = dispatch_apply(
250                &state,
251                agentmux_common::ipc::Command::CreateTab {
252                    workspace_id: ws_id.clone(),
253                    name: name.to_string(),
254                },
255            )
256            .await;
257            tab_ids.push(
258                tab_evs
259                    .iter()
260                    .find_map(|e| match e {
261                        Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
262                        _ => None,
263                    })
264                    .unwrap(),
265            );
266        }
267        (state, ws_id, tab_ids[0].clone(), tab_ids[1].clone())
268    }
269
270    #[tokio::test]
271    async fn happy_path_removes_tab_from_reducer_and_sqlite() {
272        let (state, ws_id, tab_a, tab_b) = seed_workspace_with_two_tabs().await;
273
274        // Sanity: both tabs present pre-delete.
275        {
276            let s = state.srv_state.lock().await;
277            assert_eq!(s.workspaces[&ws_id].tab_ids, vec![tab_a.clone(), tab_b.clone()]);
278            assert!(s.tabs.contains_key(&tab_a));
279        }
280        assert!(state.mstore.get::<Tab>(&tab_a).unwrap().is_some());
281
282        let result = run(&state, ws_id.clone(), tab_a.clone()).await.unwrap();
283        assert_eq!(result["tab_id"], tab_a);
284        assert_eq!(result["workspace_id"], ws_id);
285
286        // Reducer: tab_a gone; workspace.tab_ids has only tab_b.
287        let s = state.srv_state.lock().await;
288        assert!(!s.tabs.contains_key(&tab_a));
289        assert_eq!(s.workspaces[&ws_id].tab_ids, vec![tab_b.clone()]);
290        drop(s);
291
292        // SQLite: tab gone; workspace.tabids reflects.
293        assert!(state.mstore.get::<Tab>(&tab_a).unwrap().is_none());
294        let ws_persist = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
295        assert_eq!(ws_persist.tabids, vec![tab_b]);
296    }
297
298    #[tokio::test]
299    async fn rejects_when_workspace_not_found() {
300        let state = test_state();
301        let err = run(&state, "ghost-ws".into(), "ghost-tab".into())
302            .await
303            .unwrap_err();
304        assert!(err.contains("workspace not found"), "got: {}", err);
305    }
306
307    #[tokio::test]
308    async fn rejects_when_tab_not_in_workspace() {
309        let (state, ws_id, _tab_a, _tab_b) = seed_workspace_with_two_tabs().await;
310        let err = run(&state, ws_id, "ghost-tab".into()).await.unwrap_err();
311        assert!(err.contains("not in workspace"), "got: {}", err);
312    }
313
314    #[tokio::test]
315    async fn rejects_last_tab() {
316        let state = test_state();
317        let ws_evs = dispatch_apply(
318            &state,
319            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
320        )
321        .await;
322        let ws_id = ws_evs
323            .iter()
324            .find_map(|e| match e {
325                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
326                _ => None,
327            })
328            .unwrap();
329        let tab_evs = dispatch_apply(
330            &state,
331            agentmux_common::ipc::Command::CreateTab {
332                workspace_id: ws_id.clone(),
333                name: "only".into(),
334            },
335        )
336        .await;
337        let only_tab = tab_evs
338            .iter()
339            .find_map(|e| match e {
340                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
341                _ => None,
342            })
343            .unwrap();
344        let err = run(&state, ws_id, only_tab).await.unwrap_err();
345        assert!(err.contains("last tab"), "got: {}", err);
346    }
347}