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 //
323 // Workstream 0 Phase 1 — same background-service exclusion as the
324 // normal close path (see its comment): don't arm the orphan/
325 // teardown-backstop machinery for an intentionally-resting host.
326 if state.windows.is_empty() && !state.background_service_enabled && host_running {
327 let v_drift = state.bump_version();
328 out.push(Event::HwndDriftDetected {
329 kind: HwndDriftKind::OrphanInstance,
330 label: None,
331 hwnd: None,
332 detail:
333 "Last user-visible window destroyed (crash-detected); host still alive (likely holding warm pool)"
334 .to_string(),
335 severity: Severity::Warn,
336 version: v_drift,
337 });
338 let v_quit = state.bump_version();
339 out.push(Event::HostShouldQuit { version: v_quit });
340 }
341
342 return out;
343 }
344
345 // Case 1 (or: HWND was already removed from a mirror by a
346 // prior `WindowClosed`, then this destroy is the natural
347 // follow-up). No drift.
348 vec![]
349}
350
351/// Placement grace window. CEF creates top-level windows hidden,
352/// runs `SetWindowPos` to place them, then shows them. The
353/// intermediate `WM_HIDE` events arrive before `WM_FOREGROUND`,
354/// which would otherwise look like `HiddenSinceOpen` drift on
355/// every fresh window. Hides occurring within this window of the
356/// host's `ReportWindowOpened` are part of normal placement and
357/// don't count.
358const HIDDEN_SINCE_OPEN_GRACE_MS: u64 = 500;
359
360/// Phase B.9.1 — handle `Command::ReportHwndVisibilityChanged`.
361/// Drift fires only on `visible=false` for a known label that has
362/// not been foregrounded since open AND is past the post-open
363/// placement grace window.
364pub fn apply_hwnd_visibility_changed(
365 state: &mut State,
366 hwnd: u64,
367 visible: bool,
368 now_ms: u64,
369) -> Vec<Event> {
370 let mut drift: Option<Event> = None;
371 let mut version_to_bump = false;
372 let label_for_drift: Option<String> = state
373 .windows
374 .iter_mut()
375 .find(|(_, m)| m.hwnd == Some(hwnd))
376 .and_then(|(label, mirror)| {
377 mirror.visible = visible;
378 // Visibility=true at any time clears any deferred hide
379 // — the placement transition completed, no drift needed.
380 if visible {
381 mirror.hidden_since_open_deferred = false;
382 return None;
383 }
384 // Drift-storm cap: HiddenSinceOpen fires AT MOST ONCE per
385 // window per session. The cap flag is monotonic for the
386 // window's lifetime. The placement grace check below is
387 // additive: hides during placement set `hidden_since_open_deferred`
388 // (without arming the cap) so the next reducer call past
389 // the grace via `drain_deferred_hidden_since_open` can
390 // fire the drift. Hides past the grace fire immediately.
391 let past_grace =
392 now_ms.saturating_sub(mirror.opened_at_ms) > HIDDEN_SINCE_OPEN_GRACE_MS;
393 if past_grace
394 && !mirror.foregrounded_since_open
395 && !mirror.hidden_since_open_emitted
396 {
397 mirror.hidden_since_open_emitted = true;
398 mirror.hidden_since_open_deferred = false;
399 version_to_bump = true;
400 Some(label.clone())
401 } else if !past_grace
402 && !mirror.foregrounded_since_open
403 && !mirror.hidden_since_open_emitted
404 {
405 // Suppressed during placement grace. Mark as deferred
406 // so a later reducer call past the grace window can
407 // promote this to a drift if the window is still
408 // hidden (codex P2 PR #725 round 1 — without this,
409 // a stuck-hidden window that gets no further
410 // visibility events permanently loses the signal).
411 mirror.hidden_since_open_deferred = true;
412 None
413 } else {
414 None
415 }
416 });
417 if version_to_bump {
418 let v = state.bump_version();
419 drift = Some(Event::HwndDriftDetected {
420 kind: HwndDriftKind::HiddenSinceOpen,
421 label: label_for_drift,
422 hwnd: Some(hwnd),
423 detail: "Window hidden without ever being foregrounded since open".to_string(),
424 severity: severity_for(HwndDriftKind::HiddenSinceOpen),
425 version: v,
426 });
427 }
428 drift.into_iter().collect()
429}
430
431/// Sweep `hidden_since_open_deferred` mirrors and emit drift for any
432/// that have crossed the placement grace boundary while still hidden
433/// and never foregrounded. Called from `reducer::update` AFTER every
434/// command processes so any recovery event the command itself
435/// dispatched (visible=true / foreground change / window closed) has
436/// a chance to clear the deferred state first. Without the AFTER
437/// ordering, a slow placement whose first post-grace event is the
438/// recovery itself would fire a spurious drift before the recovery
439/// runs (codex P2 PR #725 round 2).
440///
441/// Even with the AFTER ordering, this pass is the heartbeat that
442/// catches stuck-hidden windows whose own `ReportHwndVisibilityChanged`
443/// was suppressed during grace: any subsequent unrelated command
444/// past the grace promotes the deferred state to a fired drift.
445///
446/// (codex P2 PR #725 round 1 — addresses the "no recheck after grace"
447/// concern. Stuck-hidden windows that produce ZERO further commands
448/// are still a hole — we'd need a periodic timer for that — but
449/// realistic launcher traffic generates events constantly, so this
450/// catches the practical cases.)
451pub fn drain_deferred_hidden_since_open(state: &mut State, now_ms: u64) -> Vec<Event> {
452 let stuck: Vec<(String, Option<u64>)> = state
453 .windows
454 .iter()
455 .filter(|(_, m)| m.hidden_since_open_deferred)
456 .filter(|(_, m)| !m.visible)
457 .filter(|(_, m)| !m.foregrounded_since_open)
458 .filter(|(_, m)| !m.hidden_since_open_emitted)
459 .filter(|(_, m)| now_ms.saturating_sub(m.opened_at_ms) > HIDDEN_SINCE_OPEN_GRACE_MS)
460 .map(|(label, m)| (label.clone(), m.hwnd))
461 .collect();
462 let mut events = Vec::with_capacity(stuck.len());
463 for (label, hwnd) in stuck {
464 if let Some(mirror) = state.windows.get_mut(&label) {
465 mirror.hidden_since_open_emitted = true;
466 mirror.hidden_since_open_deferred = false;
467 }
468 let v = state.bump_version();
469 events.push(Event::HwndDriftDetected {
470 kind: HwndDriftKind::HiddenSinceOpen,
471 label: Some(label),
472 hwnd,
473 detail: "Window hidden without ever being foregrounded since open (deferred from placement grace)".to_string(),
474 severity: severity_for(HwndDriftKind::HiddenSinceOpen),
475 version: v,
476 });
477 }
478 events
479}
480
481/// Phase B.9.1 — handle `Command::ReportHwndForegroundChanged`.
482/// Updates the "has been seen" flag. Never emits drift directly
483/// — its role is to suppress future `HiddenSinceOpen` emissions.
484pub fn apply_hwnd_foreground_changed(state: &mut State, hwnd: u64, now_ms: u64) -> Vec<Event> {
485 if let Some((_, mirror)) = state.windows.iter_mut().find(|(_, m)| m.hwnd == Some(hwnd)) {
486 mirror.foregrounded_since_open = true;
487 mirror.last_foreground_at_ms = Some(now_ms);
488 // Foreground = window made it to the user. Clear any deferred
489 // hide so the drain pass doesn't fire spurious drift past the
490 // grace for a window the user actually saw.
491 mirror.hidden_since_open_deferred = false;
492 }
493 vec![]
494}
495
496/// Phase B.9.1 — handle `Command::ReportHwndIconicChanged`. Updates
497/// state. No drift directly — operator can read steady state via
498/// `--diag wrr` (B.9.2) if they want to see who's minimized.
499pub fn apply_hwnd_iconic_changed(state: &mut State, hwnd: u64, iconic: bool) -> Vec<Event> {
500 if let Some((_, mirror)) = state.windows.iter_mut().find(|(_, m)| m.hwnd == Some(hwnd)) {
501 mirror.iconic = iconic;
502 }
503 vec![]
504}
505
506/// Phase B.9.1 — handle `Command::ReportHwndPositionChanged`.
507/// Compares the new rect against `state.monitors`; emits
508/// `OffMonitor` drift if it doesn't intersect any monitor.
509/// Suppressed when `state.monitors` is empty (we don't yet know
510/// the topology — first `ReportMonitorTopologyChanged` will
511/// reconcile every label's `last_rect` against fresh monitors).
512pub fn apply_hwnd_position_changed(state: &mut State, hwnd: u64, new_rect: Rect) -> Vec<Event> {
513 let mut events: Vec<Event> = Vec::new();
514 let monitors = state.monitors.clone();
515
516 // Phase B.9.1 diagnostic — single line per position event so
517 // operators can correlate with host-side hook activity.
518 let linked_label_diag: Option<String> = state
519 .windows
520 .iter()
521 .find(|(_, m)| m.hwnd == Some(hwnd))
522 .map(|(label, _)| label.clone());
523 crate::log(&format!(
524 "[ipc] WRR-POS hwnd={:#x} rect=({},{})-({},{}) linked={:?} monitors={} pending={}",
525 hwnd, new_rect.left, new_rect.top, new_rect.right, new_rect.bottom,
526 linked_label_diag, monitors.len(), state.pending_hwnds.len()
527 ));
528
529 // Sentinel-aware drift suppression: CEF Views creates Win32
530 // top-level windows at the (-32000,-32000) / (-31970,-31970)
531 // hidden-sentinel positions for a brief moment between
532 // CreateWindow and the first SetWindowPos that places them.
533 // Firing OffMonitor on every window's open transient produces
534 // log noise without surfacing a real bug. Suppress drift for
535 // these positions; if the window stays at the sentinel
536 // (genuine bug), the *follow-up* event that lands at a
537 // non-sentinel off-monitor position WILL fire — and if it
538 // never moves, the corrective branch below acts on the FIRST
539 // sentinel report (since the foregrounded_since_open guard
540 // catches it).
541 let is_sentinel = is_win32_hidden_sentinel(&new_rect);
542
543 // Resolve the mirror: collect everything we need from a scoped
544 // borrow, then release it before calling `state.bump_version()`
545 // (rustc E0499 — same trick as `apply_hwnd_opened`).
546 //
547 // Storm-cap snapshot: capture the prior emit-flags so the gate
548 // logic below knows whether each side-effect has fired before.
549 // `apply_hwnd_position_changed` fires per WM_MOVE during a
550 // drag — without the caps, a window dragged across an off-
551 // monitor region storms the renderer with drift + corrective
552 // events.
553 struct Resolved {
554 label: String,
555 off_monitor: bool,
556 foregrounded_since_open: bool,
557 off_monitor_drift_emitted: bool,
558 corrective_window_move_emitted: bool,
559 }
560 let resolved: Option<Resolved> = state
561 .windows
562 .iter_mut()
563 .find(|(_, m)| m.hwnd == Some(hwnd))
564 .map(|(label, mirror)| {
565 mirror.last_rect = Some(new_rect);
566 let off_monitor =
567 !monitors.is_empty() && !rect::intersects_any(&new_rect, &monitors);
568 Resolved {
569 label: label.clone(),
570 off_monitor,
571 foregrounded_since_open: mirror.foregrounded_since_open,
572 off_monitor_drift_emitted: mirror.off_monitor_drift_emitted,
573 corrective_window_move_emitted: mirror.corrective_window_move_emitted,
574 }
575 });
576
577 let Some(r) = resolved else {
578 return events;
579 };
580
581 if !r.off_monitor {
582 return events;
583 }
584
585 // Window is off all monitors. Fire drift unless we're in the
586 // open-transient sentinel state (per above) OR the cap has
587 // already fired for this window.
588 let mut fire_drift = false;
589 let mut fire_corrective = false;
590 if !is_sentinel && !r.off_monitor_drift_emitted {
591 fire_drift = true;
592 }
593
594 // Phase B.9.2 — pure-reducer self-heal. If the window has
595 // never been foregrounded, this off-monitor state is from the
596 // open transition (NOT user action), so we emit a corrective
597 // move. The host's WRR subscriber applies it via SetWindowPos
598 // on the UI thread. The Win32 hidden sentinel is INCLUDED in
599 // the corrective trigger (we always want to move a window off
600 // the sentinel before the user notices), even though it's
601 // suppressed for drift to avoid log noise.
602 //
603 // Compute the corrective target ONCE (reagent P2 PR #722 round 2)
604 // — used both as the gate for `fire_corrective` and as the
605 // event payload below.
606 let corrective_target = if !r.foregrounded_since_open && !r.corrective_window_move_emitted {
607 pick_primary_centered(&monitors)
608 } else {
609 None
610 };
611 if corrective_target.is_some() {
612 fire_corrective = true;
613 }
614
615 if fire_drift {
616 // Mark the cap before bumping the version so re-entrant
617 // event handlers see consistent state.
618 if let Some((_, mirror)) = state
619 .windows
620 .iter_mut()
621 .find(|(_, m)| m.hwnd == Some(hwnd))
622 {
623 mirror.off_monitor_drift_emitted = true;
624 }
625 let v = state.bump_version();
626 events.push(Event::HwndDriftDetected {
627 kind: HwndDriftKind::OffMonitor,
628 label: Some(r.label.clone()),
629 hwnd: Some(hwnd),
630 detail: format!(
631 "Window rect ({},{})-({},{}) does not intersect any of {} monitors",
632 new_rect.left, new_rect.top, new_rect.right, new_rect.bottom,
633 monitors.len()
634 ),
635 severity: severity_for(HwndDriftKind::OffMonitor),
636 version: v,
637 });
638 }
639
640 if let Some(target) = corrective_target.filter(|_| fire_corrective) {
641 if let Some((_, mirror)) = state
642 .windows
643 .iter_mut()
644 .find(|(_, m)| m.hwnd == Some(hwnd))
645 {
646 mirror.corrective_window_move_emitted = true;
647 }
648 let v = state.bump_version();
649 events.push(Event::CorrectiveWindowMove {
650 hwnd,
651 target_rect: target,
652 reason: HwndDriftKind::OffMonitor,
653 version: v,
654 });
655 }
656
657 events
658}
659
660/// Phase B.9.1 — Win32 sentinel positions for "this window is
661/// hidden." CEF parks new windows here briefly between create
662/// and first paint; same value family (`SW_HIDE` analog used by
663/// `ITaskbarList::DeleteTab` removed windows). We suppress drift
664/// emission for these but DO trigger corrective move (the user
665/// shouldn't see the sentinel).
666fn is_win32_hidden_sentinel(r: &Rect) -> bool {
667 // Both classic ((-32000,-32000)) and the (-31970, -31970)
668 // CEF-Views variant. Plus a generous epsilon for either
669 // axis since the bottom-right corner is offset by a default
670 // window size (e.g. (-31840,-31972)).
671 (r.left <= -31000 && r.top <= -31000) || (r.right <= -31000 && r.bottom <= -31000)
672}
673
674/// Phase B.9.2 — pick a corrective target rect: centered on the
675/// first monitor at a sensible default size (1280x800 or 70% of
676/// monitor, whichever is smaller). `None` if the monitor list is
677/// empty (caller suppresses corrective in that case).
678fn pick_primary_centered(monitors: &[Rect]) -> Option<Rect> {
679 let primary = monitors.first()?;
680 let mw = primary.right - primary.left;
681 let mh = primary.bottom - primary.top;
682 if mw <= 0 || mh <= 0 {
683 return None;
684 }
685 let w = std::cmp::min(1280, (mw as f32 * 0.7) as i32);
686 let h = std::cmp::min(800, (mh as f32 * 0.7) as i32);
687 let cx = primary.left + (mw - w) / 2;
688 let cy = primary.top + (mh - h) / 2;
689 Some(Rect {
690 left: cx,
691 top: cy,
692 right: cx + w,
693 bottom: cy + h,
694 })
695}
696
697/// Phase B.9.1 — handle `Command::ReportMonitorTopologyChanged`.
698/// Replaces `state.monitors` wholesale, then re-evaluates every
699/// known mirror's `last_rect` against the new set. Emits
700/// `OffMonitor` for any window that newly falls off (e.g. user
701/// unplugged the external display where the window lived).
702pub fn apply_monitor_topology_changed(state: &mut State, rects: Vec<Rect>) -> Vec<Event> {
703 state.monitors = rects;
704 let monitors = state.monitors.clone();
705 if monitors.is_empty() {
706 // Headless / fully-disconnected — suppress drift; nothing
707 // is "off" if there's no "on".
708 return vec![];
709 }
710 let mut events: Vec<Event> = Vec::new();
711 // Gate emission on `off_monitor_drift_emitted` (codex P2 PR
712 // #722 round 3): without this, repeated topology changes
713 // (display hot-plug or rapid resolution change) re-emit drift
714 // for the same stranded window every event.
715 let stranded: Vec<(String, u64, Rect)> = state
716 .windows
717 .iter()
718 .filter_map(|(label, mirror)| {
719 if mirror.off_monitor_drift_emitted {
720 return None;
721 }
722 let r = mirror.last_rect?;
723 let h = mirror.hwnd?;
724 if rect::intersects_any(&r, &monitors) {
725 None
726 } else {
727 Some((label.clone(), h, r))
728 }
729 })
730 .collect();
731 for (label, hwnd, rect) in stranded {
732 if let Some((_, mirror)) = state
733 .windows
734 .iter_mut()
735 .find(|(l, _)| **l == label)
736 {
737 mirror.off_monitor_drift_emitted = true;
738 }
739 let v = state.bump_version();
740 events.push(Event::HwndDriftDetected {
741 kind: HwndDriftKind::OffMonitor,
742 label: Some(label),
743 hwnd: Some(hwnd),
744 detail: format!(
745 "Monitor topology change stranded window at ({},{})-({},{})",
746 rect.left, rect.top, rect.right, rect.bottom
747 ),
748 severity: severity_for(HwndDriftKind::OffMonitor),
749 version: v,
750 });
751 }
752 events
753}
754
755/// Phase B.9.1 — per-kind severity classifier. The split is
756/// deliberate: `OrphanDestroy` and `HwndWithoutBrowser` indicate a
757/// real divergence between CEF identity and Win32 reality (a CEF
758/// bug or a missed report path) — ERROR. `OffMonitor`,
759/// `HiddenSinceOpen`, `LingeringHwnd` are operationally
760/// significant (user can't see / use the window) but don't
761/// indicate a state-machine bug — WARN. `BrowserWithoutHwnd` is
762/// commonly transient (race window between OS create and host
763/// link) — INFO; only meaningful if it doesn't reconcile.
764pub fn severity_for(kind: HwndDriftKind) -> Severity {
765 match kind {
766 HwndDriftKind::OrphanDestroy => Severity::Error,
767 HwndDriftKind::HwndWithoutBrowser => Severity::Error,
768 HwndDriftKind::OffMonitor => Severity::Warn,
769 HwndDriftKind::HiddenSinceOpen => Severity::Warn,
770 HwndDriftKind::LingeringHwnd => Severity::Warn,
771 HwndDriftKind::BrowserWithoutHwnd => Severity::Info,
772 // Phase B.9.3 — OrphanInstance is operationally significant
773 // (process tree won't quit) but isn't a state-machine bug
774 // per se; it's an observation about cross-process lifecycle.
775 // WARN matches the other "user can't see / use this" kinds.
776 HwndDriftKind::OrphanInstance => Severity::Warn,
777 }
778}