Skip to main content

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}