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}