agentmux_srv/server/service/window_close.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `window` service handler — `CloseWindow`. Split out of `window.rs`;
5//! see that file's dispatcher for the full method list.
6
7use crate::backend::obj::*;
8use crate::backend::service::{self, WebCallType, WebReturnType};
9
10use super::super::AppState;
11use super::reducer_helpers::{dispatch_to_reducer, publish_events};
12
13// Phase E.5.8 — CloseWindow migrated through the reducer.
14// Sequence:
15// 1. Look up the window's workspace (for cascade decision).
16// 2. Dispatch Command::CloseWindowInternal — emits
17// SrvWindowClosed; subscriber prunes Client.windowids.
18// 3. If no other window points at the same workspace, dispatch
19// Command::DeleteWorkspace which cascades through tabs+blocks.
20// Mirrors `wcore::close_window` behaviour where each window
21// owns one workspace, but uses the reducer-routed conditional
22// pattern so future multi-window-on-same-workspace flows
23// don't accidentally drop user state.
24pub(crate) async fn handle_close_window(state: &AppState, call: &WebCallType) -> WebReturnType {
25 let store = &state.mstore;
26 let args = &call.args;
27 let window_id: String = match service::get_arg(args, 0) {
28 Ok(v) => v,
29 Err(e) => return WebReturnType::error(e),
30 };
31 // Look up the window's workspace before we close it.
32 // Read SQLite (source of truth during migration).
33 let ws_id: Option<String> = match store.get::<Window>(&window_id) {
34 Ok(Some(w)) => Some(w.workspaceid.clone()),
35 Ok(None) => None,
36 Err(e) => {
37 return WebReturnType::error(format!(
38 "CloseWindow: window lookup failed: {}",
39 e
40 ));
41 }
42 };
43 // Step 1: drop the window mapping in reducer.
44 let close_events = dispatch_to_reducer(
45 state,
46 agentmux_common::ipc::Command::CloseWindowInternal {
47 window_id: window_id.clone(),
48 },
49 )
50 .await;
51 // reagent P1 #622: surface reducer rejection before
52 // applying / publishing. Every other primary dispatch in
53 // this PR follows this pattern; CloseWindow was missing it.
54 if let Some(err_msg) = close_events.iter().find_map(|e| match e {
55 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
56 _ => None,
57 }) {
58 return WebReturnType::error(err_msg);
59 }
60 // #2051 — the reducer's CloseWindowInternal is an idempotent
61 // silent no-op on unknown ids (no event emitted). The SQLite
62 // prune below only runs off the SrvWindowClosed event, so
63 // without this branch a window the reducer never knew
64 // (bootstrap skipped it, or state/store desync) returns 200
65 // while its `Client.windowids` entry and Window row survive
66 // forever — the leak class that fueled the #2048 window
67 // storm. Prune the durable store directly and warn about the
68 // divergence; a window that is gone from BOTH sides stays a
69 // clean idempotent success.
70 if close_events.is_empty() {
71 let had_row = ws_id.is_some();
72 let had_client_entry = store
73 .get_all::<Client>()
74 .ok()
75 .and_then(|cs| cs.into_iter().next())
76 .map(|c| c.windowids.iter().any(|id| id == &window_id))
77 .unwrap_or(false);
78 if !had_row && !had_client_entry {
79 return WebReturnType::success_empty();
80 }
81 tracing::warn!(
82 window_id = %window_id,
83 had_row,
84 had_client_entry,
85 "CloseWindow: window unknown to the reducer but still present in the \
86 store — pruning defensively (#2051)"
87 );
88 if let Err(e) =
89 crate::persist_subscriber::apply_srv_window_closed(store, &window_id)
90 {
91 return WebReturnType::error(format!(
92 "CloseWindow: divergence prune failed: {}",
93 e
94 ));
95 }
96 // Same guarded cascade as the normal path below, but
97 // judged from the store (the reducer has no record of
98 // this window's workspace peers). The prune above already
99 // deleted this window's own row, so any remaining
100 // reference means the workspace is genuinely shared. On a
101 // read error keep the workspace — losing user tabs is
102 // worse than leaking an empty workspace row.
103 if let Some(ws_id) = ws_id {
104 let any_other_window = store
105 .get_all::<Window>()
106 .map(|ws| ws.iter().any(|w| w.workspaceid == ws_id))
107 .unwrap_or(true);
108 if !any_other_window {
109 // Restore-on-relaunch (SPEC_SESSION_RESTORE_AND_SAVED_LAYOUTS_2026_08_13
110 // Feature 1) — is THIS close the one that empties
111 // `Client.windowids`, i.e. a genuine "the user is fully
112 // quitting" moment? Deliberately NOT the same question as
113 // `!any_other_window` above (that's workspace-sharing,
114 // answered independently per window, true for basically
115 // every close in the common 1:1 window:workspace case) —
116 // reviewer-caught bug (reagentx P1 on PR #2560): gating on
117 // workspace-destruction alone still let closing a small
118 // independent tear-off window (its own small 1:1 workspace,
119 // so `!any_other_window` is true for it too) AFTER the real
120 // main window overwrite the just-saved full-session
121 // snapshot with the tear-off's tiny one. Gating on
122 // "windowids is about to be empty" instead ties the
123 // snapshot to the same trigger `restore_last_session` itself
124 // waits for on the read side.
125 //
126 // Read FRESH here, not once up front at function entry
127 // (reagentx P1 on PR #2560, re-review): `apply_srv_window_closed`
128 // above already pruned this window's id, so this reflects
129 // the post-prune state. A stale pre-dispatch read let
130 // concurrent CloseWindow calls during a multi-window quit
131 // each independently see "the other window is still there"
132 // and conclude `will_empty_windowids = false` for BOTH,
133 // losing the snapshot entirely even though the user
134 // genuinely closed everything.
135 let will_empty_windowids = store
136 .get_all::<Client>()
137 .ok()
138 .and_then(|cs| cs.into_iter().next())
139 .map(|c| c.windowids.is_empty())
140 .unwrap_or(false);
141 // Best-effort: a failure here must never block the close itself.
142 if will_empty_windowids {
143 if let Some(snapshot) = super::session_restore::snapshot_workspace(store, &ws_id) {
144 super::session_restore::save_last_session_snapshot(store, snapshot);
145 }
146 }
147 close_workspace(state, ws_id).await;
148 }
149 }
150 return WebReturnType::success_empty();
151 }
152 for ev in &close_events {
153 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
154 return WebReturnType::error(format!(
155 "CloseWindow: SQLite write failed: {}",
156 e
157 ));
158 }
159 }
160 publish_events(state, &close_events);
161 // Step 2: cascade delete the workspace if no other window
162 // points at it. The reducer keeps state.windows updated;
163 // check there.
164 //
165 // Step 5 PR 2 — route the user-initiated cascade through
166 // the `delete_workspace` saga instead of dispatching
167 // `Command::DeleteWorkspace` inline. The saga records
168 // lifecycle brackets in the durable saga log so a crash
169 // mid-cascade is recoverable via `recovery::compensate_unresolved`.
170 // The saga also takes a snapshot of the workspace's
171 // tabs+blocks before issuing the cascade, so the durable
172 // log captures what was deleted (provenance for
173 // `--diag sagas`).
174 if let Some(ws_id) = ws_id {
175 let any_other_window = {
176 let s = state.srv_state.lock().await;
177 s.windows.values().any(|w| w.workspace_id == ws_id)
178 };
179 if !any_other_window {
180 // Restore-on-relaunch (SPEC_SESSION_RESTORE_AND_SAVED_LAYOUTS_2026_08_13
181 // Feature 1) — see the divergence-path branch above for why this
182 // is gated on "windowids is now empty" rather than
183 // `!any_other_window` (a workspace-sharing question, not a
184 // full-quit one). Workspace tabs/blocks are still fully intact
185 // at this point — `CloseWindowInternal`'s already-applied events
186 // (line 182-189 above) only touched Window/Client, the actual
187 // tab/block cascade is the saga call right below this — so
188 // `Client.windowids` reflects this window's own prune already,
189 // and reading it fresh here (not a stale function-entry
190 // snapshot — reagentx P1 on PR #2560, re-review) is what makes
191 // this correct under a concurrent multi-window quit.
192 let will_empty_windowids = store
193 .get_all::<Client>()
194 .ok()
195 .and_then(|cs| cs.into_iter().next())
196 .map(|c| c.windowids.is_empty())
197 .unwrap_or(false);
198 if will_empty_windowids {
199 if let Some(snapshot) = super::session_restore::snapshot_workspace(store, &ws_id) {
200 super::session_restore::save_last_session_snapshot(store, snapshot);
201 }
202 }
203 close_workspace(state, ws_id).await;
204 }
205 }
206 // Subscriber's apply_srv_window_closed already pruned
207 // Client.windowids and deleted the Window row; nothing
208 // more for the handler to do.
209 WebReturnType::success_empty()
210}
211
212/// Delete a closed window's workspace, every agent in it closed gracefully
213/// first (`agent_teardown`, `Policy::close()`).
214///
215/// Detached (`agent_teardown::detached`): the host waits only 2 s for
216/// `CloseWindow`, less than a graceful close takes, and when it hangs up
217/// this handler is dropped. The close still finishes, and on app exit srv
218/// waits for it.
219///
220/// A workspace the reducer tracks goes through the `delete_workspace` saga
221/// (reducer and store stay consistent, durable provenance). One it never
222/// knew — the usual case when its window diverged too (#2051) — would make
223/// the saga's reducer dispatch error out, so its agents are closed here, the
224/// store cascades directly, and each block's close is finished as the saga
225/// would (`close_pane::finish_close`).
226async fn close_workspace(state: &AppState, ws_id: String) {
227 let st = state.clone();
228 let done = crate::sagas::agent_teardown::detached(async move {
229 let reducer_knows_ws = st.srv_state.lock().await.workspaces.contains_key(&ws_id);
230 let result = if reducer_knows_ws {
231 crate::sagas::delete_workspace::run(&st, ws_id.clone()).await.map(|_| ())
232 } else {
233 let block_ids = store_block_ids(&st.mstore, &ws_id);
234 crate::sagas::agent_teardown::run_many(&st, &block_ids, crate::sagas::agent_teardown::Policy::close())
235 .await;
236 let deleted = crate::backend::wcore::delete_workspace(&st.mstore, &ws_id).map_err(|e| e.to_string());
237 for block_id in &block_ids {
238 crate::sagas::close_pane::finish_close(&st, block_id).await;
239 }
240 deleted
241 };
242 if let Err(e) = result {
243 tracing::warn!(workspace_id = %ws_id, reducer_knows_ws, "CloseWindow: workspace delete failed: {}", e);
244 }
245 })
246 .await;
247 if done.is_none() {
248 tracing::warn!("CloseWindow: the workspace close task panicked");
249 }
250}
251
252/// Every block in a workspace's tabs (regular and pinned), read from the store.
253fn store_block_ids(store: &crate::backend::storage::store::Store, ws_id: &str) -> Vec<String> {
254 let Ok(Some(ws)) = store.get::<Workspace>(ws_id) else { return Vec::new() };
255 ws.tabids
256 .iter()
257 .chain(ws.pinnedtabids.iter())
258 .filter_map(|tid| store.get::<Tab>(tid).ok().flatten())
259 .flat_map(|t| t.blockids)
260 .collect()
261}
262
263/// #2051 — `CloseWindow` divergence-path tests: the reducer's
264/// `CloseWindowInternal` silently no-ops on ids it never knew, and the
265/// SQLite prune normally rides the `SrvWindowClosed` event — so a window
266/// present only in the store used to survive a "successful" CloseWindow
267/// forever (the leak class behind the #2048 window storm). These pin the
268/// handler's direct-prune fallback.
269#[cfg(test)]
270mod close_window_divergence_tests {
271 use super::super::window::handle_window_service;
272 use crate::backend::obj::{Client, Window, Workspace};
273 use crate::backend::service::WebCallType;
274 use crate::backend::wcore;
275 use crate::server::tests::test_state;
276
277 fn close_call(window_id: &str) -> WebCallType {
278 WebCallType {
279 service: "window".to_string(),
280 method: "CloseWindow".to_string(),
281 uicontext: None,
282 args: vec![serde_json::Value::String(window_id.to_string())],
283 }
284 }
285
286 fn client_has_window(state: &crate::server::AppState, window_id: &str) -> bool {
287 state
288 .mstore
289 .get_all::<Client>()
290 .unwrap()
291 .into_iter()
292 .next()
293 .map(|c| c.windowids.iter().any(|id| id == window_id))
294 .unwrap_or(false)
295 }
296
297 /// A window written straight to the store (`wcore::create_window`
298 /// bypasses the reducer — exactly how bootstrap-skipped rows and
299 /// pre-reducer desyncs look) must still be fully pruned by
300 /// CloseWindow: success, row gone, `Client.windowids` entry gone,
301 /// and its now-orphaned workspace cascade-deleted.
302 #[tokio::test]
303 async fn store_only_window_is_pruned_and_workspace_cascades() {
304 let state = test_state();
305 let win = wcore::create_window_full(&state.mstore, "").unwrap();
306 assert!(client_has_window(&state, &win.oid), "precondition: windowids entry");
307 assert!(
308 !state.srv_state.lock().await.windows.contains_key(&win.oid),
309 "precondition: reducer must NOT know this window"
310 );
311
312 let ret = handle_window_service(&state, &close_call(&win.oid)).await;
313 assert!(ret.success, "divergent CloseWindow failed: {:?}", ret.error);
314
315 assert!(
316 state.mstore.get::<Window>(&win.oid).unwrap().is_none(),
317 "Window row must be deleted"
318 );
319 assert!(
320 !client_has_window(&state, &win.oid),
321 "Client.windowids entry must be pruned"
322 );
323 assert!(
324 state.mstore.get::<Workspace>(&win.workspaceid).unwrap().is_none(),
325 "orphaned workspace must cascade-delete"
326 );
327 }
328
329 /// A workspace still referenced by another window must survive the
330 /// divergence-path cascade guard.
331 #[tokio::test]
332 async fn shared_workspace_survives_divergent_close() {
333 let state = test_state();
334 let first = wcore::create_window_full(&state.mstore, "").unwrap();
335 let second =
336 wcore::create_window_full(&state.mstore, &first.workspaceid).unwrap();
337
338 let ret = handle_window_service(&state, &close_call(&first.oid)).await;
339 assert!(ret.success, "divergent CloseWindow failed: {:?}", ret.error);
340
341 assert!(
342 state.mstore.get::<Workspace>(&first.workspaceid).unwrap().is_some(),
343 "workspace referenced by another window must survive"
344 );
345 assert!(
346 state.mstore.get::<Window>(&second.oid).unwrap().is_some(),
347 "the other window must be untouched"
348 );
349 }
350
351 /// A window gone from BOTH the reducer and the store stays a clean
352 /// idempotent success — no error, no phantom prune.
353 #[tokio::test]
354 async fn truly_missing_window_is_an_idempotent_success() {
355 let state = test_state();
356 let ret = handle_window_service(&state, &close_call("no-such-window")).await;
357 assert!(ret.success, "idempotent CloseWindow failed: {:?}", ret.error);
358 }
359}
360
361/// Restore-on-relaunch snapshot-timing regression tests
362/// (SPEC_SESSION_RESTORE_AND_SAVED_LAYOUTS_2026_08_13.md Feature 1).
363/// Reviewer-caught bug (reagentx P1 on PR #2560): the original
364/// implementation snapshotted unconditionally on every window close, so
365/// closing a second, independent window AFTER the real session's window
366/// would silently overwrite the correct snapshot with the second window's
367/// (typically much smaller) content — since each window normally owns its
368/// own 1:1 workspace, "does this close destroy its own workspace" was true
369/// for nearly every close, not a useful filter. These pin the fix: the
370/// snapshot now only fires on the close that actually empties
371/// `Client.windowids` — the same condition the read side already requires.
372#[cfg(test)]
373mod snapshot_timing_tests {
374 use super::super::window::handle_window_service;
375 use super::super::window_create::handle_create_window;
376 use crate::backend::obj::{Client, Tab, Window, Workspace};
377 use crate::backend::service::WebCallType;
378 use crate::server::tests::test_state;
379
380 fn close_call(window_id: &str) -> WebCallType {
381 WebCallType {
382 service: "window".to_string(),
383 method: "CloseWindow".to_string(),
384 uicontext: None,
385 args: vec![serde_json::Value::String(window_id.to_string())],
386 }
387 }
388
389 async fn create_window(state: &crate::server::AppState) -> Window {
390 let call = WebCallType {
391 service: "window".to_string(),
392 method: "CreateWindow".to_string(),
393 uicontext: None,
394 args: vec![serde_json::Value::Null, serde_json::Value::String(String::new())],
395 };
396 let resp = handle_create_window(state, &call).await;
397 assert!(resp.success, "CreateWindow failed: {:?}", resp.error);
398 serde_json::from_value(resp.data.unwrap()).unwrap()
399 }
400
401 fn rename_first_tab(state: &crate::server::AppState, workspace_id: &str, name: &str) {
402 let ws = state.mstore.get::<Workspace>(workspace_id).unwrap().unwrap();
403 let mut tab = state.mstore.get::<Tab>(&ws.tabids[0]).unwrap().unwrap();
404 tab.name = name.to_string();
405 state.mstore.update(&mut tab).unwrap();
406 }
407
408 fn last_session_snapshot_tab_name(state: &crate::server::AppState) -> Option<String> {
409 let client = state.mstore.get_all::<Client>().unwrap().into_iter().next()?;
410 let snapshot = client.meta.get("session:last_topology")?;
411 snapshot["tabs"][0]["name"].as_str().map(|s| s.to_string())
412 }
413
414 /// `test_state()` seeds a "Starter workspace" bootstrap window
415 /// (`wcore::ensure_initial_data`) before any test code runs — without
416 /// closing it first, `Client.windowids` always has that extra entry, so
417 /// `will_empty_windowids` never actually goes true in these tests no
418 /// matter how many test-created windows get closed. Close it before
419 /// setting up a test's own "N independent windows" scenario.
420 async fn close_bootstrap_window(state: &crate::server::AppState) {
421 let bootstrap_id = state
422 .mstore
423 .get_all::<Client>()
424 .unwrap()
425 .into_iter()
426 .next()
427 .unwrap()
428 .windowids[0]
429 .clone();
430 let ret = handle_window_service(state, &close_call(&bootstrap_id)).await;
431 assert!(ret.success, "closing bootstrap window failed: {:?}", ret.error);
432 }
433
434 /// The core fix: with two independent windows open, closing the FIRST
435 /// one (the other is still open — `Client.windowids` does NOT empty)
436 /// must not touch the snapshot at all. Only closing the SECOND (last)
437 /// window may write one. Under the pre-fix code (unconditional
438 /// snapshot-on-every-close) this test fails at the first assertion —
439 /// the first close already wrote a snapshot.
440 #[tokio::test]
441 async fn closing_one_of_two_open_windows_does_not_touch_the_snapshot() {
442 let state = test_state();
443 // Closing the lone bootstrap window IS a genuine terminal close (it
444 // empties `Client.windowids` on its own), so it legitimately writes
445 // a snapshot of its own — capture that as the baseline rather than
446 // assuming "no snapshot yet".
447 close_bootstrap_window(&state).await;
448 let snapshot_after_bootstrap_close = last_session_snapshot_tab_name(&state);
449
450 let first = create_window(&state).await;
451 rename_first_tab(&state, &first.workspaceid, "first-window-tab");
452 let second = create_window(&state).await;
453 rename_first_tab(&state, &second.workspaceid, "second-window-tab");
454
455 // Close the FIRST window while the second is still open.
456 let ret = handle_window_service(&state, &close_call(&first.oid)).await;
457 assert!(ret.success, "CloseWindow failed: {:?}", ret.error);
458
459 assert_eq!(
460 last_session_snapshot_tab_name(&state),
461 snapshot_after_bootstrap_close,
462 "closing one of two open windows must not write a snapshot — \
463 the session hasn't actually ended yet"
464 );
465
466 // Now close the SECOND (last) window — this is the real end of the
467 // session, and must snapshot the second window's own content.
468 let ret = handle_window_service(&state, &close_call(&second.oid)).await;
469 assert!(ret.success, "CloseWindow failed: {:?}", ret.error);
470
471 assert_eq!(
472 last_session_snapshot_tab_name(&state).as_deref(),
473 Some("second-window-tab"),
474 "the terminal close must snapshot ITS OWN (last) window's content"
475 );
476 }
477
478 /// Direct regression for the reviewer's exact scenario: close the
479 /// window with the "real" session first, then a smaller second window
480 /// — the smaller window's close (being the one that actually empties
481 /// `Client.windowids`) legitimately becomes the final snapshot, but the
482 /// key guarantee is that this reflects a real, coherent design decision
483 /// (snapshot = "whatever was open at the moment of full quit") rather
484 /// than an arbitrary race between two unconditional writes.
485 #[tokio::test]
486 async fn snapshot_reflects_whichever_close_actually_empties_windowids() {
487 let state = test_state();
488 // See `closing_one_of_two_open_windows_does_not_touch_the_snapshot`:
489 // closing the lone bootstrap window is itself a terminal close, so
490 // it writes a snapshot — capture that as the baseline.
491 close_bootstrap_window(&state).await;
492 let snapshot_after_bootstrap_close = last_session_snapshot_tab_name(&state);
493
494 let main = create_window(&state).await;
495 rename_first_tab(&state, &main.workspaceid, "main-session");
496 let tearoff = create_window(&state).await;
497 rename_first_tab(&state, &tearoff.workspaceid, "tiny-tearoff");
498
499 // Close "main" first — does NOT empty windowids (tearoff still open).
500 let ret = handle_window_service(&state, &close_call(&main.oid)).await;
501 assert!(ret.success);
502 assert_eq!(
503 last_session_snapshot_tab_name(&state),
504 snapshot_after_bootstrap_close,
505 "closing main first must not snapshot — it isn't the terminal close"
506 );
507
508 // Close "tearoff" last — THIS empties windowids, so it (correctly,
509 // by definition of "what was open at full quit") is what's saved.
510 let ret = handle_window_service(&state, &close_call(&tearoff.oid)).await;
511 assert!(ret.success);
512 assert_eq!(
513 last_session_snapshot_tab_name(&state).as_deref(),
514 Some("tiny-tearoff")
515 );
516 }
517
518 /// reagentx P1 on PR #2560 (re-review): `will_empty_windowids` used to
519 /// be computed once at function entry, off a snapshot of
520 /// `Client.windowids` taken BEFORE this call's own dispatch mutated
521 /// anything. Under genuine concurrent overlap (two `CloseWindow` calls
522 /// as part of one multi-window quit), each could read "the other
523 /// window is still there" and both conclude `will_empty_windowids =
524 /// false` — losing the snapshot entirely even though the user closed
525 /// everything. Reading fresh, after each call's own prune has already
526 /// landed, closes that gap: whichever close actually observes an empty
527 /// `Client.windowids` afterward saves a snapshot, so at least one of
528 /// two concurrent terminal closes always does.
529 #[tokio::test]
530 async fn concurrent_close_of_the_last_two_windows_still_saves_a_snapshot() {
531 let state = test_state();
532 close_bootstrap_window(&state).await;
533
534 let first = create_window(&state).await;
535 rename_first_tab(&state, &first.workspaceid, "first-window-tab");
536 let second = create_window(&state).await;
537 rename_first_tab(&state, &second.workspaceid, "second-window-tab");
538
539 let first_call = close_call(&first.oid);
540 let second_call = close_call(&second.oid);
541 let (ret1, ret2) = tokio::join!(
542 handle_window_service(&state, &first_call),
543 handle_window_service(&state, &second_call),
544 );
545 assert!(ret1.success, "first concurrent close failed: {:?}", ret1.error);
546 assert!(ret2.success, "second concurrent close failed: {:?}", ret2.error);
547
548 assert!(
549 last_session_snapshot_tab_name(&state).is_some(),
550 "closing the last two windows concurrently must still save SOME \
551 snapshot, not lose it entirely"
552 );
553 }
554}