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}