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}