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}