agentmux_launcher\reducer/
window.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Window lifecycle reducer handlers. Extracted from reducer/mod.rs
5//! in task #182 PR-C for navigability.
6//!
7//! Handles ReportWindowOpened, ReportWindowClosed, and the
8//! ReportBackendWindowId{Registered,Unregistered} pair.
9
10use agentmux_common::ipc::{Event, WindowKind};
11
12use crate::reducer::Ctx;
13use crate::state::{State, WindowMirror};
14
15use agentmux_common::ipc::HwndDriftKind;
16
17/// Phase B.5 (window_id_map step a) — record the host-reported
18/// label → backend_window_id mapping. Idempotent on duplicate
19/// label (overwrites with the new ID and emits a fresh event so
20/// subscribers see the latest mapping).
21pub(super) fn handle_report_backend_window_id_registered(
22    state: &mut State,
23    label: String,
24    window_id: String,
25) -> Vec<Event> {
26    state
27        .backend_window_ids
28        .insert(label.clone(), window_id.clone());
29    let v = state.bump_version();
30    vec![Event::BackendWindowIdRegistered {
31        label,
32        window_id,
33        version: v,
34    }]
35}
36
37/// Phase B.5 (window_id_map step a) — drop the host-reported label
38/// from the map. Strict pairing: emits `BackendWindowIdUnregistered`
39/// only when the label was present (mirrors `WindowClosed` and
40/// `PoolWindowRemoved` semantics — codex P2 PR #577 round-2).
41pub(super) fn handle_report_backend_window_id_unregistered(
42    state: &mut State,
43    label: String,
44) -> Vec<Event> {
45    let removed = state.backend_window_ids.remove(&label);
46    let Some(window_id) = removed else {
47        return vec![];
48    };
49    let v = state.bump_version();
50    vec![Event::BackendWindowIdUnregistered {
51        label,
52        window_id,
53        version: v,
54    }]
55}
56
57/// Phase B.4 — record a host-reported window opening in the launcher's
58/// read-only mirror. Idempotent on duplicate opens (same label twice
59/// in a row): the second insert overwrites with fresh metadata and
60/// emits a fresh event. Subscribers must tolerate seeing the same
61/// label twice; cleaner once B.5 makes the launcher authoritative.
62///
63/// Phase B.5: also assigns an authoritative instance number from
64/// `state.instance_registry` and emits `WindowInstanceAssigned`.
65/// "main" is pre-seeded with 1; other labels get the next value of
66/// `next_instance_num`. Re-opens of an existing label preserve the
67/// original number — instance numbers are stable per-label-per-run.
68pub(super) fn handle_report_window_opened(
69    state: &mut State,
70    ctx: &Ctx,
71    label: String,
72    kind: WindowKind,
73    parent_label: Option<String>,
74) -> Vec<Event> {
75    // PR #664 codex P1 round 2 — drain-on-WindowOpened RESTORED as
76    // best-effort fallback. The explicit `ReportHwndOpened(Some(label))`
77    // from `client.rs::on_after_created` remains the AUTHORITATIVE
78    // link, and `apply_hwnd_opened` REPAIRS stale links it finds.
79    // But that explicit dispatch is gated on `hwnd_val != 0` host-side;
80    // if the HWND can't be resolved at on_after_created time from
81    // either of the 2 sources (Views, host), the explicit dispatch
82    // is skipped and the mirror would otherwise stay permanently
83    // unlinked, breaking WRR drift detection AND orphan-destroy
84    // reconciliation (no WindowClosed when OS destroys the HWND →
85    // permanent ghost InstancePanel rows).
86    //
87    // (PR #664 round 4 dropped a 3rd fallback `find_own_top_level_window`
88    // because it returns the FIRST visible window in the process —
89    // some other window's HWND in a multi-window session — which
90    // would corrupt other labels' mirrors via the `Repaired` arm.
91    // See client.rs::on_after_created comment for details.)
92    //
93    // The drain provides a fallback link from `pending_hwnds`. If the
94    // drain picks the WRONG HWND (the original burst-create race), the
95    // subsequent `apply_hwnd_opened` call from on_after_created will
96    // detect the mismatch and REPAIR — see the `Repaired` arm there.
97    // Net: best-effort link via drain, authoritative repair via
98    // explicit dispatch. The combination addresses both the
99    // hwnd_val=0 case and the burst-create race.
100    const PENDING_AGE_LIMIT_MS: u64 = 2_000;
101    let drained_hwnd: Option<u64> = state
102        .pending_hwnds
103        .iter()
104        .filter(|(_, p)| p.label_hint.is_none())
105        .filter(|(_, p)| ctx.now_ms.saturating_sub(p.arrived_at_ms) <= PENDING_AGE_LIMIT_MS)
106        .max_by_key(|(_, p)| p.arrived_at_ms)
107        .map(|(hwnd, _)| *hwnd);
108    if let Some(hwnd) = drained_hwnd {
109        state.pending_hwnds.remove(&hwnd);
110    }
111
112    // Drift-storm fix (PR #708 round 3) — if this open is the back
113    // half of a tear-off promote (host emit order is `pool_removed →
114    // pool_promoted → window_opened`), `handle_report_pool_window_promoted`
115    // recorded the label in `just_promoted_labels`. Initialize the new
116    // mirror with `foregrounded_since_open: true` so the open-transient
117    // corrective logic doesn't re-fire `HiddenSinceOpen` on the
118    // subsequent HWND repositioning. See state.rs::just_promoted_labels.
119    let was_just_promoted = state.just_promoted_labels.remove(&label);
120    // Lifetime-state preservation. The handler overwrites the mirror
121    // wholesale on every `ReportWindowOpened`; without OR-with-prior
122    // here, a 2nd open at the same label would reset every monotonic
123    // flag/anchor below.
124    //
125    // - `foregrounded_since_open`: monotonic per its own contract
126    //   ("has this label been foregrounded at any point since
127    //   ReportWindowOpened"). Preserved against duplicate opens
128    //   since codex P2 PR #708 round 3.
129    // - `hidden_since_open_emitted` / `off_monitor_drift_emitted` /
130    //   `corrective_window_move_emitted`: storm-cap flags. Each fires
131    //   AT MOST ONCE per window per session. A duplicate open that
132    //   reset these to false would re-arm the cap and the next
133    //   transition would fire the drift again.
134    // - `hidden_since_open_deferred`: pending-drift flag set when
135    //   `apply_hwnd_visibility_changed` suppresses a hide during the
136    //   placement grace. A duplicate open that cleared it would lose
137    //   the deferred signal (codex P2 PR #725 round 1).
138    // - `opened_at_ms`: grace-window anchor. Preserving the ORIGINAL
139    //   value avoids resetting the placement grace every time a
140    //   duplicate open arrives, which would let real hides past the
141    //   first grace window be re-suppressed (codex P2 PR #725 round 1).
142    let prior = state.windows.get(&label);
143    let prior_foregrounded = prior.map(|m| m.foregrounded_since_open).unwrap_or(false);
144    let prior_hidden_emitted = prior.map(|m| m.hidden_since_open_emitted).unwrap_or(false);
145    let prior_hidden_deferred = prior.map(|m| m.hidden_since_open_deferred).unwrap_or(false);
146    let prior_off_monitor_emitted = prior.map(|m| m.off_monitor_drift_emitted).unwrap_or(false);
147    let prior_corrective_emitted = prior.map(|m| m.corrective_window_move_emitted).unwrap_or(false);
148    let prior_opened_at_ms = prior.map(|m| m.opened_at_ms);
149
150    state.windows.insert(
151        label.clone(),
152        WindowMirror {
153            label: label.clone(),
154            kind,
155            parent_label: parent_label.clone(),
156            opened_at: ctx.now_rfc3339.clone(),
157            // Preserve original open time on duplicate so the grace
158            // anchor never moves forward.
159            opened_at_ms: prior_opened_at_ms.unwrap_or(ctx.now_ms),
160            // Best-effort drain above; authoritative explicit
161            // ReportHwndOpened from on_after_created arrives a few
162            // ms later via `apply_hwnd_opened` and REPAIRS any wrong
163            // link the drain picked.
164            hwnd: drained_hwnd,
165            visible: false,
166            iconic: false,
167            last_rect: None,
168            last_foreground_at_ms: None,
169            foregrounded_since_open: was_just_promoted || prior_foregrounded,
170            hidden_since_open_emitted: prior_hidden_emitted,
171            hidden_since_open_deferred: prior_hidden_deferred,
172            off_monitor_drift_emitted: prior_off_monitor_emitted,
173            corrective_window_move_emitted: prior_corrective_emitted,
174        },
175    );
176    let mut out = Vec::with_capacity(2);
177    let v = state.bump_version();
178    out.push(Event::WindowOpened {
179        label: label.clone(),
180        kind,
181        parent_label,
182        version: v,
183    });
184
185    // Assign instance number if this label isn't already in the
186    // registry. Re-opens of an existing label keep the original
187    // number — matches host's `WindowInstanceRegistry` semantics
188    // where a label is only registered once per session.
189    let num = if let Some(existing) = state.instance_registry.get(&label).copied() {
190        existing
191    } else {
192        let n = state.next_instance_num;
193        state.instance_registry.insert(label.clone(), n);
194        state.next_instance_num += 1;
195        n
196    };
197    let v = state.bump_version();
198    out.push(Event::WindowInstanceAssigned { label, num, version: v });
199    out
200}
201
202/// Phase B.4 — drop a host-reported window from the mirror. Returns
203/// `Event::WindowClosed` only when the label was actually in the
204/// mirror; an unknown-label close is a silent no-op (codex P2 PR
205/// #577 round-2). Without this gate, a `ReportWindowClosed` for a
206/// label the launcher never saw (e.g. a pool window that was popped
207/// from the queue but failed HWND validation in
208/// `promote_pool_window` — the orphan window's eventual
209/// `on_before_close` reaches us without a matching open) would
210/// emit an unpaired `WindowClosed` broadcast and break subscribers
211/// that assume open/close pairing.
212///
213/// Phase B.5 — also drops the label from `instance_registry` and
214/// emits `WindowInstanceReleased` if a number was assigned.
215/// `next_instance_num` is NOT decremented — instance numbers are
216/// monotonic per-launcher-run.
217pub(super) fn handle_report_window_closed(state: &mut State, label: String) -> Vec<Event> {
218    let was_present = state.windows.remove(&label).is_some();
219    // Drift-storm fix cleanup — drop any orphaned just-promoted entry.
220    // Bounded leak protection for the (rare) case where promote was
221    // emitted but the matching `ReportWindowOpened` never arrived
222    // (host crash mid-tear-off, etc.).
223    state.just_promoted_labels.remove(&label);
224    if !was_present {
225        // Silent: only emit when the close pairs with a known open.
226        return vec![];
227    }
228    let mut out = Vec::with_capacity(4);
229    let v = state.bump_version();
230    out.push(Event::WindowClosed {
231        label: label.clone(),
232        version: v,
233        // Clean close — host ran on_before_close before sending
234        // ReportWindowClosed. F.6 saga is safe to trigger.
235        crash_detected: false,
236    });
237    if let Some(num) = state.instance_registry.remove(&label) {
238        let v = state.bump_version();
239        out.push(Event::WindowInstanceReleased { label: label.clone(), num, version: v });
240    }
241
242    // Phase B.9.3 — OrphanInstance transition. The label we just
243    // removed was the LAST user-visible window (state.windows is
244    // now empty). If a Host is still registered as Running, its
245    // own close path won't quit_message_loop because the warm
246    // pool is keeping state.browsers non-empty. Emit drift +
247    // saga-style HostShouldQuit so the host can reap pool and
248    // quit cleanly. See B.9.3 in
249    // docs/retro/next-steps-2026-04-29.md.
250    if state.windows.is_empty() && super::connection::host_is_running(state) {
251        let v_drift = state.bump_version();
252        out.push(Event::HwndDriftDetected {
253            kind: HwndDriftKind::OrphanInstance,
254            label: Some(label),
255            hwnd: None,
256            detail: "Last user-visible window closed; host still alive (likely holding warm pool)"
257                .to_string(),
258            severity: agentmux_common::ipc::Severity::Warn,
259            version: v_drift,
260        });
261        let v_quit = state.bump_version();
262        out.push(Event::HostShouldQuit { version: v_quit });
263    }
264    out
265}