Skip to main content

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}