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}