agentmux_launcher\wrr/mod.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.9.1 — Window Reality Reconciliation (WRR) reducer arm.
5//
6// Catches the class of bug surfaced during the B.6.1 smoke test:
7// a CEF browser opens, gets a Win32 HWND, and is then "lost" to
8// the user (off-screen, behind another window, never foregrounded).
9// The pre-B.9 reducer tracked identity (`label`, `kind`, `parent`)
10// but not observability (visible? on-monitor? has the user seen
11// it?), so its drift detector compared `host.browsers.len() ==
12// launcher.windows.len()` — both can be in lockstep wrong about
13// Win32 reality.
14//
15// Design lives at `docs/retro/wrr-design-2026-04-28.md`. This
16// module implements the launcher-side reducer arm:
17//
18// * `apply_*` functions: one per `Command::ReportHwnd*` /
19// `ReportMonitorTopologyChanged`. Each mutates `State` and
20// emits `Event::HwndDriftDetected` for every classification
21// it can determine at this transition.
22// * `severity_for(kind)`: the per-kind severity floor classifier.
23//
24// Pure event-driven. There is no clock task, no heartbeat — drift
25// is emitted at the moment the OS-driven Command is dispatched
26// through the reducer. See the design doc for the trade-off ("we
27// don't catch steady-state staleness without an event").
28
29use agentmux_common::ipc::{Event, HwndDriftKind, Rect, Severity};
30
31use crate::state::{PendingHwnd, State};
32
33pub mod rect;
34
35/// Internal — the four branches of `apply_hwnd_opened` against a
36/// known `label_hint`. Lifted into its own enum so the function
37/// can drop the `&mut WindowMirror` borrow before calling
38/// `state.bump_version()` on the drift-emitting path (rustc E0499).
39enum HwndOpenedOutcome {
40 /// Existing mirror was waiting for an HWND; linked successfully.
41 Linked,
42 /// Existing mirror was already linked to a DIFFERENT HWND. The
43 /// explicit `on_after_created` path is authoritative — REPAIR by
44 /// overwriting the stale link, and emit `HwndWithoutBrowser`
45 /// drift to surface the prior incorrect link for diagnostics.
46 /// Carry the prior HWND for the drift message.
47 /// (PR #664: replaces the no-repair behavior that caused the
48 /// `panel grows but no window appears` user symptom under
49 /// burst creates.)
50 Repaired(u64),
51 /// No mirror exists for that label — fall through to pending
52 /// stash (caller responsibility).
53 NoMatchingLabel,
54}
55
56/// Phase B.9.1 — handle `Command::ReportHwndOpened`. Either:
57/// 1. The hwnd's `label_hint` matches an existing
58/// `state.windows[label]` whose `hwnd` is `None` → link them.
59/// 2. No matching label → stash in `state.pending_hwnds` for a
60/// later reconciliation. If the class name doesn't look like
61/// an AgentMux window (filtered at the host hook, but
62/// defense-in-depth here too), don't even stash.
63pub fn apply_hwnd_opened(
64 state: &mut State,
65 hwnd: u64,
66 class_name: String,
67 title: String,
68 label_hint: Option<String>,
69 now_ms: u64,
70) -> Vec<Event> {
71 // Case 1: label_hint maps to an existing WindowMirror that's
72 // waiting for an HWND. Happy path — link them, no drift.
73 if let Some(label) = label_hint.as_deref() {
74 // Read mirror state via a scoped borrow so we can release
75 // it before calling `state.bump_version()` (which needs &mut
76 // self on State). Result tells us which branch to take
77 // outside the borrow.
78 //
79 // `drain_pending` is set when the link succeeds so we can
80 // remove a matching stale `pending_hwnds` entry AFTER
81 // releasing the mirror borrow. The dual-source design
82 // (WinEvent CREATE + on_after_created) can leave a stale
83 // pending entry; without draining it,
84 // `apply_hwnd_destroyed` would early-return on the
85 // stale entry and skip the orphan-destroy chain.
86 // (reagent #600 P1.)
87 // PR #664 codex P1 round 5 — STEAL HWND from any other mirror
88 // that currently claims it. The drain-on-WindowOpened may have
89 // wrong-linked the same HWND to a different label earlier; if
90 // we don't clear that other mirror's link, we end up with TWO
91 // mirrors pointing to the same HWND. `apply_hwnd_destroyed`
92 // uses `iter().find(...)` which returns the FIRST match — the
93 // other mirror would persist as a ghost row forever.
94 //
95 // Scan FIRST (immutable borrow), then mutate via `get_mut(label)`.
96 let stolen_from: Option<String> = state
97 .windows
98 .iter()
99 .find(|(other_label, m)| {
100 other_label.as_str() != label && m.hwnd == Some(hwnd)
101 })
102 .map(|(other_label, _)| other_label.clone());
103
104 let mut drain_pending = false;
105 let outcome: HwndOpenedOutcome = match state.windows.get_mut(label) {
106 Some(mirror) if mirror.hwnd.is_none() => {
107 mirror.hwnd = Some(hwnd);
108 drain_pending = true;
109 HwndOpenedOutcome::Linked
110 }
111 Some(mirror) if mirror.hwnd == Some(hwnd) => {
112 // Same HWND already linked. This is a benign
113 // duplicate from the dual-source design: WinEvent
114 // CREATE hook reports first (label_hint=None,
115 // pending), then `on_after_created` reports
116 // explicitly (label_hint=Some, this path). Or
117 // vice versa under timing variation. No-op,
118 // no drift. (codex #600 P2.)
119 //
120 // Drain any matching stale pending entry. Without
121 // this, `apply_hwnd_destroyed` would early-return
122 // on the stale pending entry instead of running
123 // the orphan-destroy chain. (reagent #600 P1.)
124 drain_pending = true;
125 HwndOpenedOutcome::Linked
126 }
127 Some(mirror) => {
128 // PR #664 — REPAIR instead of just emitting drift.
129 // The explicit `on_after_created` path is the
130 // AUTHORITATIVE source for label↔HWND linking. The
131 // launcher's drain-on-WindowOpened (in
132 // `handle_report_window_opened`) provides best-effort
133 // linking but can wrong-pick under burst creates;
134 // when the explicit path arrives later it REPAIRS
135 // any stale link by overwriting.
136 //
137 // The `prior` HWND that was wrongly linked here will
138 // be re-attributed when ITS OWN on_after_created
139 // fires (same flow, recursive REPAIR if needed).
140 //
141 // We still emit `HwndWithoutBrowser` drift so the
142 // existence of a stale link is visible in the log
143 // for diagnostic purposes. Without the drift event,
144 // the silent repair would mask real bugs.
145 let prior = mirror.hwnd.unwrap_or(0);
146 mirror.hwnd = Some(hwnd);
147 drain_pending = true;
148 HwndOpenedOutcome::Repaired(prior)
149 }
150 None => HwndOpenedOutcome::NoMatchingLabel,
151 };
152 // Apply the steal AFTER the get_mut borrow is released.
153 // (codex P1 round 5) Maintains the 1:1 HWND↔label invariant
154 // that `apply_hwnd_destroyed`'s find()-by-hwnd relies on.
155 // Steal is meaningful only when we actually claimed the HWND
156 // (Linked or Repaired); NoMatchingLabel falls through to
157 // pending stash without claiming.
158 let stole = matches!(outcome, HwndOpenedOutcome::Linked | HwndOpenedOutcome::Repaired(_))
159 && stolen_from.is_some();
160 if stole {
161 if let Some(other_label) = stolen_from.as_deref() {
162 if let Some(other) = state.windows.get_mut(other_label) {
163 other.hwnd = None;
164 }
165 }
166 }
167 if drain_pending {
168 state.pending_hwnds.remove(&hwnd);
169 }
170 match outcome {
171 HwndOpenedOutcome::Linked if !stole => return vec![],
172 HwndOpenedOutcome::Linked => {
173 // Linked + stole: we cleanly linked our mirror, but
174 // had to take the HWND from another label that was
175 // wrongly holding it (drain wrong-pick that wasn't
176 // yet repaired). Emit drift so the steal is visible.
177 let v = state.bump_version();
178 let other = stolen_from.as_deref().unwrap_or("?");
179 return vec![Event::HwndDriftDetected {
180 kind: HwndDriftKind::HwndWithoutBrowser,
181 label: Some(label.to_string()),
182 hwnd: Some(hwnd),
183 detail: format!(
184 "ReportHwndOpened label_hint={} linked hwnd={} (stole from label={})",
185 label, hwnd, other
186 ),
187 severity: severity_for(HwndDriftKind::HwndWithoutBrowser),
188 version: v,
189 }];
190 }
191 HwndOpenedOutcome::Repaired(existing) => {
192 // Repair is a normal self-healing path: the launcher's
193 // best-effort drain in `handle_report_window_opened`
194 // wrong-picked an HWND under burst-create concurrency,
195 // and the explicit `apply_hwnd_opened` from
196 // `client.rs::on_after_created` is now correcting it.
197 // Logging this at Error severity as a `HwndDriftDetected`
198 // event flooded the renderer with one drift per fresh
199 // top-level (6 in a clean v0.33.696 session) and made
200 // genuine drifts harder to spot.
201 //
202 // Now: log via tracing only, no event. The pure-state
203 // mutation (mirror.hwnd overwrite + drain_pending +
204 // optional steal-clear on the prior holder) still
205 // happens above. Linked + stole still emits drift —
206 // that's a different shape (clean link that had to
207 // claim a wrongly-held HWND, which the prior holder's
208 // own `apply_hwnd_opened` may not yet have repaired).
209 let stolen_suffix = stolen_from
210 .as_deref()
211 .map(|s| format!(" (stole from label={})", s))
212 .unwrap_or_default();
213 crate::log(&format!(
214 "[wrr] hwnd_repaired label={} prior_hwnd={} new_hwnd={}{}",
215 label, existing, hwnd, stolen_suffix
216 ));
217 return vec![];
218 }
219 HwndOpenedOutcome::NoMatchingLabel => { /* fall through to pending */ }
220 }
221 }
222
223 // Case 2: stash as pending. Filtered class names get dropped
224 // here too as belt-and-suspenders — host hook is the primary
225 // filter (see `wrr/classify.rs::is_app_class` in agentmux-cef).
226 state.pending_hwnds.insert(
227 hwnd,
228 PendingHwnd {
229 class_name,
230 title,
231 label_hint,
232 arrived_at_ms: now_ms,
233 },
234 );
235 vec![]
236}
237
238/// Phase B.9.1 — handle `Command::ReportHwndDestroyed`. Three
239/// outcomes:
240/// 1. HWND links to a `WindowMirror` AND we already received a
241/// `ReportWindowClosed` for that label (mirror is gone) →
242/// no drift, expected ordering.
243/// 2. HWND links to a `WindowMirror` that's STILL in
244/// `state.windows` → CEF didn't report close yet. Renderer
245/// probably crashed → `OrphanDestroy` drift.
246/// 3. HWND was pending (never linked) → drop the pending entry,
247/// no drift (it never claimed to be a real window).
248pub fn apply_hwnd_destroyed(state: &mut State, hwnd: u64, host_running: bool) -> Vec<Event> {
249 // Drain any pending entry first. Don't early-return: the
250 // dual-source design (WinEvent CREATE + explicit
251 // on_after_created link) can leave a stale pending entry
252 // co-existing with a linked mirror — we still need to run
253 // the mirror check below to fire the orphan-destroy chain
254 // on a renderer crash. (reagent #600 P1.)
255 let _ = state.pending_hwnds.remove(&hwnd);
256
257 // Find the label whose mirror is linked to this HWND, if any.
258 let orphan_label: Option<String> = state
259 .windows
260 .iter()
261 .find(|(_, m)| m.hwnd == Some(hwnd))
262 .map(|(label, _)| label.clone());
263
264 if let Some(label) = orphan_label {
265 // Case 2: orphan destroy. Clear the link AND the mirror
266 // entry — the window is gone from Win32, regardless of
267 // what CEF thinks. Future `ReportWindowClosed` for the
268 // label will be a no-op (closed-on-missing is silently
269 // tolerated upstream).
270 //
271 // Emit the SAME shutdown events the normal close path
272 // (`handle_report_window_closed`) would emit:
273 // `WindowClosed` (so subscribers prune mirrors / atoms)
274 // and `WindowInstanceReleased` (so the InstancePanel
275 // count drops). Without these the frontend would show a
276 // stale window after a renderer crash. Order: drift first
277 // (so logs lead with "this is bad"), then the shutdown
278 // events. (reagent #600 P1.)
279 let _ = state.windows.remove(&label);
280 let released_num = state.instance_registry.remove(&label);
281 let _ = state.backend_window_ids.remove(&label);
282 let v_drift = state.bump_version();
283 let drift = Event::HwndDriftDetected {
284 kind: HwndDriftKind::OrphanDestroy,
285 label: Some(label.clone()),
286 hwnd: Some(hwnd),
287 detail: format!(
288 "HWND destroyed without preceding ReportWindowClosed for label={}",
289 label
290 ),
291 severity: severity_for(HwndDriftKind::OrphanDestroy),
292 version: v_drift,
293 };
294 let v_closed = state.bump_version();
295 let closed = Event::WindowClosed {
296 label: label.clone(),
297 version: v_closed,
298 // crash-detected close: host's on_before_close didn't
299 // run, so the F.6 cleanup saga must skip this trigger
300 // (it would never receive the panes-reaped / pool-drain
301 // terminal reports).
302 crash_detected: true,
303 };
304 let mut out = vec![drift, closed];
305 if let Some(num) = released_num {
306 let v_released = state.bump_version();
307 out.push(Event::WindowInstanceReleased {
308 label,
309 num,
310 version: v_released,
311 });
312 }
313
314 // Mirror the OrphanInstance + HostShouldQuit pair the normal
315 // close path emits at `reducer/window.rs::handle_report_window_closed`.
316 // Without this, a crash-detected last-window close empties
317 // `state.windows` but never wakes the host's orphan reconciler
318 // (which only listens to `HostShouldQuit`), so the warm pool
319 // browsers stay alive and the host doesn't quit. Caller passes
320 // `host_running` so wrr stays out of the connection module's
321 // private API.
322 if state.windows.is_empty() && host_running {
323 let v_drift = state.bump_version();
324 out.push(Event::HwndDriftDetected {
325 kind: HwndDriftKind::OrphanInstance,
326 label: None,
327 hwnd: None,
328 detail:
329 "Last user-visible window destroyed (crash-detected); host still alive (likely holding warm pool)"
330 .to_string(),
331 severity: Severity::Warn,
332 version: v_drift,
333 });
334 let v_quit = state.bump_version();
335 out.push(Event::HostShouldQuit { version: v_quit });
336 }
337
338 return out;
339 }
340
341 // Case 1 (or: HWND was already removed from a mirror by a
342 // prior `WindowClosed`, then this destroy is the natural
343 // follow-up). No drift.
344 vec![]
345}
346
347/// Placement grace window. CEF creates top-level windows hidden,
348/// runs `SetWindowPos` to place them, then shows them. The
349/// intermediate `WM_HIDE` events arrive before `WM_FOREGROUND`,
350/// which would otherwise look like `HiddenSinceOpen` drift on
351/// every fresh window. Hides occurring within this window of the
352/// host's `ReportWindowOpened` are part of normal placement and
353/// don't count.
354const HIDDEN_SINCE_OPEN_GRACE_MS: u64 = 500;
355
356/// Phase B.9.1 — handle `Command::ReportHwndVisibilityChanged`.
357/// Drift fires only on `visible=false` for a known label that has
358/// not been foregrounded since open AND is past the post-open
359/// placement grace window.
360pub fn apply_hwnd_visibility_changed(
361 state: &mut State,
362 hwnd: u64,
363 visible: bool,
364 now_ms: u64,
365) -> Vec<Event> {
366 let mut drift: Option<Event> = None;
367 let mut version_to_bump = false;
368 let label_for_drift: Option<String> = state
369 .windows
370 .iter_mut()
371 .find(|(_, m)| m.hwnd == Some(hwnd))
372 .and_then(|(label, mirror)| {
373 mirror.visible = visible;
374 // Visibility=true at any time clears any deferred hide
375 // — the placement transition completed, no drift needed.
376 if visible {
377 mirror.hidden_since_open_deferred = false;
378 return None;
379 }
380 // Drift-storm cap: HiddenSinceOpen fires AT MOST ONCE per
381 // window per session. The cap flag is monotonic for the
382 // window's lifetime. The placement grace check below is
383 // additive: hides during placement set `hidden_since_open_deferred`
384 // (without arming the cap) so the next reducer call past
385 // the grace via `drain_deferred_hidden_since_open` can
386 // fire the drift. Hides past the grace fire immediately.
387 let past_grace =
388 now_ms.saturating_sub(mirror.opened_at_ms) > HIDDEN_SINCE_OPEN_GRACE_MS;
389 if past_grace
390 && !mirror.foregrounded_since_open
391 && !mirror.hidden_since_open_emitted
392 {
393 mirror.hidden_since_open_emitted = true;
394 mirror.hidden_since_open_deferred = false;
395 version_to_bump = true;
396 Some(label.clone())
397 } else if !past_grace
398 && !mirror.foregrounded_since_open
399 && !mirror.hidden_since_open_emitted
400 {
401 // Suppressed during placement grace. Mark as deferred
402 // so a later reducer call past the grace window can
403 // promote this to a drift if the window is still
404 // hidden (codex P2 PR #725 round 1 — without this,
405 // a stuck-hidden window that gets no further
406 // visibility events permanently loses the signal).
407 mirror.hidden_since_open_deferred = true;
408 None
409 } else {
410 None
411 }
412 });
413 if version_to_bump {
414 let v = state.bump_version();
415 drift = Some(Event::HwndDriftDetected {
416 kind: HwndDriftKind::HiddenSinceOpen,
417 label: label_for_drift,
418 hwnd: Some(hwnd),
419 detail: "Window hidden without ever being foregrounded since open".to_string(),
420 severity: severity_for(HwndDriftKind::HiddenSinceOpen),
421 version: v,
422 });
423 }
424 drift.into_iter().collect()
425}
426
427/// Sweep `hidden_since_open_deferred` mirrors and emit drift for any
428/// that have crossed the placement grace boundary while still hidden
429/// and never foregrounded. Called from `reducer::update` AFTER every
430/// command processes so any recovery event the command itself
431/// dispatched (visible=true / foreground change / window closed) has
432/// a chance to clear the deferred state first. Without the AFTER
433/// ordering, a slow placement whose first post-grace event is the
434/// recovery itself would fire a spurious drift before the recovery
435/// runs (codex P2 PR #725 round 2).
436///
437/// Even with the AFTER ordering, this pass is the heartbeat that
438/// catches stuck-hidden windows whose own `ReportHwndVisibilityChanged`
439/// was suppressed during grace: any subsequent unrelated command
440/// past the grace promotes the deferred state to a fired drift.
441///
442/// (codex P2 PR #725 round 1 — addresses the "no recheck after grace"
443/// concern. Stuck-hidden windows that produce ZERO further commands
444/// are still a hole — we'd need a periodic timer for that — but
445/// realistic launcher traffic generates events constantly, so this
446/// catches the practical cases.)
447pub fn drain_deferred_hidden_since_open(state: &mut State, now_ms: u64) -> Vec<Event> {
448 let stuck: Vec<(String, Option<u64>)> = state
449 .windows
450 .iter()
451 .filter(|(_, m)| m.hidden_since_open_deferred)
452 .filter(|(_, m)| !m.visible)
453 .filter(|(_, m)| !m.foregrounded_since_open)
454 .filter(|(_, m)| !m.hidden_since_open_emitted)
455 .filter(|(_, m)| now_ms.saturating_sub(m.opened_at_ms) > HIDDEN_SINCE_OPEN_GRACE_MS)
456 .map(|(label, m)| (label.clone(), m.hwnd))
457 .collect();
458 let mut events = Vec::with_capacity(stuck.len());
459 for (label, hwnd) in stuck {
460 if let Some(mirror) = state.windows.get_mut(&label) {
461 mirror.hidden_since_open_emitted = true;
462 mirror.hidden_since_open_deferred = false;
463 }
464 let v = state.bump_version();
465 events.push(Event::HwndDriftDetected {
466 kind: HwndDriftKind::HiddenSinceOpen,
467 label: Some(label),
468 hwnd,
469 detail: "Window hidden without ever being foregrounded since open (deferred from placement grace)".to_string(),
470 severity: severity_for(HwndDriftKind::HiddenSinceOpen),
471 version: v,
472 });
473 }
474 events
475}
476
477/// Phase B.9.1 — handle `Command::ReportHwndForegroundChanged`.
478/// Updates the "has been seen" flag. Never emits drift directly
479/// — its role is to suppress future `HiddenSinceOpen` emissions.
480pub fn apply_hwnd_foreground_changed(state: &mut State, hwnd: u64, now_ms: u64) -> Vec<Event> {
481 if let Some((_, mirror)) = state.windows.iter_mut().find(|(_, m)| m.hwnd == Some(hwnd)) {
482 mirror.foregrounded_since_open = true;
483 mirror.last_foreground_at_ms = Some(now_ms);
484 // Foreground = window made it to the user. Clear any deferred
485 // hide so the drain pass doesn't fire spurious drift past the
486 // grace for a window the user actually saw.
487 mirror.hidden_since_open_deferred = false;
488 }
489 vec![]
490}
491
492/// Phase B.9.1 — handle `Command::ReportHwndIconicChanged`. Updates
493/// state. No drift directly — operator can read steady state via
494/// `--diag wrr` (B.9.2) if they want to see who's minimized.
495pub fn apply_hwnd_iconic_changed(state: &mut State, hwnd: u64, iconic: bool) -> Vec<Event> {
496 if let Some((_, mirror)) = state.windows.iter_mut().find(|(_, m)| m.hwnd == Some(hwnd)) {
497 mirror.iconic = iconic;
498 }
499 vec![]
500}
501
502/// Phase B.9.1 — handle `Command::ReportHwndPositionChanged`.
503/// Compares the new rect against `state.monitors`; emits
504/// `OffMonitor` drift if it doesn't intersect any monitor.
505/// Suppressed when `state.monitors` is empty (we don't yet know
506/// the topology — first `ReportMonitorTopologyChanged` will
507/// reconcile every label's `last_rect` against fresh monitors).
508pub fn apply_hwnd_position_changed(state: &mut State, hwnd: u64, new_rect: Rect) -> Vec<Event> {
509 let mut events: Vec<Event> = Vec::new();
510 let monitors = state.monitors.clone();
511
512 // Phase B.9.1 diagnostic — single line per position event so
513 // operators can correlate with host-side hook activity.
514 let linked_label_diag: Option<String> = state
515 .windows
516 .iter()
517 .find(|(_, m)| m.hwnd == Some(hwnd))
518 .map(|(label, _)| label.clone());
519 crate::log(&format!(
520 "[ipc] WRR-POS hwnd={:#x} rect=({},{})-({},{}) linked={:?} monitors={} pending={}",
521 hwnd, new_rect.left, new_rect.top, new_rect.right, new_rect.bottom,
522 linked_label_diag, monitors.len(), state.pending_hwnds.len()
523 ));
524
525 // Sentinel-aware drift suppression: CEF Views creates Win32
526 // top-level windows at the (-32000,-32000) / (-31970,-31970)
527 // hidden-sentinel positions for a brief moment between
528 // CreateWindow and the first SetWindowPos that places them.
529 // Firing OffMonitor on every window's open transient produces
530 // log noise without surfacing a real bug. Suppress drift for
531 // these positions; if the window stays at the sentinel
532 // (genuine bug), the *follow-up* event that lands at a
533 // non-sentinel off-monitor position WILL fire — and if it
534 // never moves, the corrective branch below acts on the FIRST
535 // sentinel report (since the foregrounded_since_open guard
536 // catches it).
537 let is_sentinel = is_win32_hidden_sentinel(&new_rect);
538
539 // Resolve the mirror: collect everything we need from a scoped
540 // borrow, then release it before calling `state.bump_version()`
541 // (rustc E0499 — same trick as `apply_hwnd_opened`).
542 //
543 // Storm-cap snapshot: capture the prior emit-flags so the gate
544 // logic below knows whether each side-effect has fired before.
545 // `apply_hwnd_position_changed` fires per WM_MOVE during a
546 // drag — without the caps, a window dragged across an off-
547 // monitor region storms the renderer with drift + corrective
548 // events.
549 struct Resolved {
550 label: String,
551 off_monitor: bool,
552 foregrounded_since_open: bool,
553 off_monitor_drift_emitted: bool,
554 corrective_window_move_emitted: bool,
555 }
556 let resolved: Option<Resolved> = state
557 .windows
558 .iter_mut()
559 .find(|(_, m)| m.hwnd == Some(hwnd))
560 .map(|(label, mirror)| {
561 mirror.last_rect = Some(new_rect);
562 let off_monitor =
563 !monitors.is_empty() && !rect::intersects_any(&new_rect, &monitors);
564 Resolved {
565 label: label.clone(),
566 off_monitor,
567 foregrounded_since_open: mirror.foregrounded_since_open,
568 off_monitor_drift_emitted: mirror.off_monitor_drift_emitted,
569 corrective_window_move_emitted: mirror.corrective_window_move_emitted,
570 }
571 });
572
573 let Some(r) = resolved else {
574 return events;
575 };
576
577 if !r.off_monitor {
578 return events;
579 }
580
581 // Window is off all monitors. Fire drift unless we're in the
582 // open-transient sentinel state (per above) OR the cap has
583 // already fired for this window.
584 let mut fire_drift = false;
585 let mut fire_corrective = false;
586 if !is_sentinel && !r.off_monitor_drift_emitted {
587 fire_drift = true;
588 }
589
590 // Phase B.9.2 — pure-reducer self-heal. If the window has
591 // never been foregrounded, this off-monitor state is from the
592 // open transition (NOT user action), so we emit a corrective
593 // move. The host's WRR subscriber applies it via SetWindowPos
594 // on the UI thread. The Win32 hidden sentinel is INCLUDED in
595 // the corrective trigger (we always want to move a window off
596 // the sentinel before the user notices), even though it's
597 // suppressed for drift to avoid log noise.
598 //
599 // Compute the corrective target ONCE (reagent P2 PR #722 round 2)
600 // — used both as the gate for `fire_corrective` and as the
601 // event payload below.
602 let corrective_target = if !r.foregrounded_since_open && !r.corrective_window_move_emitted {
603 pick_primary_centered(&monitors)
604 } else {
605 None
606 };
607 if corrective_target.is_some() {
608 fire_corrective = true;
609 }
610
611 if fire_drift {
612 // Mark the cap before bumping the version so re-entrant
613 // event handlers see consistent state.
614 if let Some((_, mirror)) = state
615 .windows
616 .iter_mut()
617 .find(|(_, m)| m.hwnd == Some(hwnd))
618 {
619 mirror.off_monitor_drift_emitted = true;
620 }
621 let v = state.bump_version();
622 events.push(Event::HwndDriftDetected {
623 kind: HwndDriftKind::OffMonitor,
624 label: Some(r.label.clone()),
625 hwnd: Some(hwnd),
626 detail: format!(
627 "Window rect ({},{})-({},{}) does not intersect any of {} monitors",
628 new_rect.left, new_rect.top, new_rect.right, new_rect.bottom,
629 monitors.len()
630 ),
631 severity: severity_for(HwndDriftKind::OffMonitor),
632 version: v,
633 });
634 }
635
636 if let Some(target) = corrective_target.filter(|_| fire_corrective) {
637 if let Some((_, mirror)) = state
638 .windows
639 .iter_mut()
640 .find(|(_, m)| m.hwnd == Some(hwnd))
641 {
642 mirror.corrective_window_move_emitted = true;
643 }
644 let v = state.bump_version();
645 events.push(Event::CorrectiveWindowMove {
646 hwnd,
647 target_rect: target,
648 reason: HwndDriftKind::OffMonitor,
649 version: v,
650 });
651 }
652
653 events
654}
655
656/// Phase B.9.1 — Win32 sentinel positions for "this window is
657/// hidden." CEF parks new windows here briefly between create
658/// and first paint; same value family (`SW_HIDE` analog used by
659/// `ITaskbarList::DeleteTab` removed windows). We suppress drift
660/// emission for these but DO trigger corrective move (the user
661/// shouldn't see the sentinel).
662fn is_win32_hidden_sentinel(r: &Rect) -> bool {
663 // Both classic ((-32000,-32000)) and the (-31970, -31970)
664 // CEF-Views variant. Plus a generous epsilon for either
665 // axis since the bottom-right corner is offset by a default
666 // window size (e.g. (-31840,-31972)).
667 (r.left <= -31000 && r.top <= -31000) || (r.right <= -31000 && r.bottom <= -31000)
668}
669
670/// Phase B.9.2 — pick a corrective target rect: centered on the
671/// first monitor at a sensible default size (1280x800 or 70% of
672/// monitor, whichever is smaller). `None` if the monitor list is
673/// empty (caller suppresses corrective in that case).
674fn pick_primary_centered(monitors: &[Rect]) -> Option<Rect> {
675 let primary = monitors.first()?;
676 let mw = primary.right - primary.left;
677 let mh = primary.bottom - primary.top;
678 if mw <= 0 || mh <= 0 {
679 return None;
680 }
681 let w = std::cmp::min(1280, (mw as f32 * 0.7) as i32);
682 let h = std::cmp::min(800, (mh as f32 * 0.7) as i32);
683 let cx = primary.left + (mw - w) / 2;
684 let cy = primary.top + (mh - h) / 2;
685 Some(Rect {
686 left: cx,
687 top: cy,
688 right: cx + w,
689 bottom: cy + h,
690 })
691}
692
693/// Phase B.9.1 — handle `Command::ReportMonitorTopologyChanged`.
694/// Replaces `state.monitors` wholesale, then re-evaluates every
695/// known mirror's `last_rect` against the new set. Emits
696/// `OffMonitor` for any window that newly falls off (e.g. user
697/// unplugged the external display where the window lived).
698pub fn apply_monitor_topology_changed(state: &mut State, rects: Vec<Rect>) -> Vec<Event> {
699 state.monitors = rects;
700 let monitors = state.monitors.clone();
701 if monitors.is_empty() {
702 // Headless / fully-disconnected — suppress drift; nothing
703 // is "off" if there's no "on".
704 return vec![];
705 }
706 let mut events: Vec<Event> = Vec::new();
707 // Gate emission on `off_monitor_drift_emitted` (codex P2 PR
708 // #722 round 3): without this, repeated topology changes
709 // (display hot-plug or rapid resolution change) re-emit drift
710 // for the same stranded window every event.
711 let stranded: Vec<(String, u64, Rect)> = state
712 .windows
713 .iter()
714 .filter_map(|(label, mirror)| {
715 if mirror.off_monitor_drift_emitted {
716 return None;
717 }
718 let r = mirror.last_rect?;
719 let h = mirror.hwnd?;
720 if rect::intersects_any(&r, &monitors) {
721 None
722 } else {
723 Some((label.clone(), h, r))
724 }
725 })
726 .collect();
727 for (label, hwnd, rect) in stranded {
728 if let Some((_, mirror)) = state
729 .windows
730 .iter_mut()
731 .find(|(l, _)| **l == label)
732 {
733 mirror.off_monitor_drift_emitted = true;
734 }
735 let v = state.bump_version();
736 events.push(Event::HwndDriftDetected {
737 kind: HwndDriftKind::OffMonitor,
738 label: Some(label),
739 hwnd: Some(hwnd),
740 detail: format!(
741 "Monitor topology change stranded window at ({},{})-({},{})",
742 rect.left, rect.top, rect.right, rect.bottom
743 ),
744 severity: severity_for(HwndDriftKind::OffMonitor),
745 version: v,
746 });
747 }
748 events
749}
750
751/// Phase B.9.1 — per-kind severity classifier. The split is
752/// deliberate: `OrphanDestroy` and `HwndWithoutBrowser` indicate a
753/// real divergence between CEF identity and Win32 reality (a CEF
754/// bug or a missed report path) — ERROR. `OffMonitor`,
755/// `HiddenSinceOpen`, `LingeringHwnd` are operationally
756/// significant (user can't see / use the window) but don't
757/// indicate a state-machine bug — WARN. `BrowserWithoutHwnd` is
758/// commonly transient (race window between OS create and host
759/// link) — INFO; only meaningful if it doesn't reconcile.
760pub fn severity_for(kind: HwndDriftKind) -> Severity {
761 match kind {
762 HwndDriftKind::OrphanDestroy => Severity::Error,
763 HwndDriftKind::HwndWithoutBrowser => Severity::Error,
764 HwndDriftKind::OffMonitor => Severity::Warn,
765 HwndDriftKind::HiddenSinceOpen => Severity::Warn,
766 HwndDriftKind::LingeringHwnd => Severity::Warn,
767 HwndDriftKind::BrowserWithoutHwnd => Severity::Info,
768 // Phase B.9.3 — OrphanInstance is operationally significant
769 // (process tree won't quit) but isn't a state-machine bug
770 // per se; it's an observation about cross-process lifecycle.
771 // WARN matches the other "user can't see / use this" kinds.
772 HwndDriftKind::OrphanInstance => Severity::Warn,
773 }
774}