agentmux_launcher\reducer/
pool.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pool-related reducer handlers. Extracted from reducer/mod.rs in
5//! task #182 PR-B for navigability.
6
7use agentmux_common::ipc::{DriftKind, Event};
8
9use crate::state::State;
10
11/// Phase B.4 follow-up — pool-only drift check. Called from
12/// `spawn_pool_window` where the windows dimension is mid-flight
13/// (close path hasn't completed). Compares only the pool dimension;
14/// emits `DriftDetected { kind: Pool, ... }` on mismatch.
15pub(super) fn handle_report_host_pool_count(state: &mut State, host_pool: u32) -> Vec<Event> {
16    let mirror_pool = state.pool.len() as u32;
17    if mirror_pool == host_pool {
18        return vec![];
19    }
20    let v = state.bump_version();
21    vec![Event::DriftDetected {
22        kind: DriftKind::Pool,
23        host_count: host_pool,
24        mirror_count: mirror_pool,
25        version: v,
26    }]
27}
28
29/// Phase B.4 follow-up — record pool inventory growth. Idempotent
30/// on duplicate labels (HashSet semantics) but the event still fires
31/// so subscribers can track add-attempts even if redundant.
32pub(super) fn handle_report_pool_window_added(
33    state: &mut State,
34    label: String,
35    saga_id: Option<u64>,
36) -> Vec<Event> {
37    state.pool.insert(label.clone());
38    let v = state.bump_version();
39    vec![Event::PoolWindowAdded {
40        label,
41        version: v,
42        saga_id,
43    }]
44}
45
46/// Phase B.4 follow-up — record pool inventory shrink (promote or
47/// destroy). Strictly paired with `ReportPoolWindowAdded`: an
48/// unknown-label remove is a silent no-op so subscribers can rely
49/// on add/remove pairing in the broadcast stream. Same gate as
50/// `handle_report_window_closed`. (reagent P2 PR #577 round-3 —
51/// the original "idempotent" comment referenced behavior that was
52/// already removed for `ReportWindowClosed`; pool semantics now
53/// match.)
54pub(super) fn handle_report_pool_window_removed(state: &mut State, label: String) -> Vec<Event> {
55    let was_present = state.pool.remove(&label);
56    if !was_present {
57        return vec![];
58    }
59    let v = state.bump_version();
60    vec![Event::PoolWindowRemoved { label, version: v }]
61}
62
63/// Phase F.5 — host-emitted promote signal. The reducer doesn't mutate
64/// state for this command (the windows/pool transitions are carried by
65/// the surrounding `ReportPoolWindowRemoved` + `ReportWindowOpened`
66/// pair); it just translates the wire command into the corresponding
67/// typed event so subscribers — most importantly the launcher saga
68/// coordinator — can react.
69///
70/// Idempotent / context-free: we don't validate the label is in the
71/// mirror because the host's own ordering may have the
72/// `ReportPoolWindowRemoved` arrive before this command, after this
73/// command, or in either order; the typed event is "host says a
74/// promote happened" — subscribers correlate with the surrounding
75/// add/remove pair if they need stronger invariants.
76///
77/// **WRR side-effect:** records the label in `state.just_promoted_labels`
78/// so the subsequent `ReportWindowOpened` initializes the new mirror
79/// with `foregrounded_since_open: true`. Promote is the user explicitly
80/// tearing off a tab — the open-transient corrective logic in
81/// `apply_hwnd_visibility_changed` MUST stop firing for this label,
82/// otherwise the post-promote reposition (multiple SetWindowPos calls
83/// during HWND placement) re-fires `HiddenSinceOpen` indefinitely.
84/// Each fire is a launcher event broadcast to all renderers; without
85/// this guard the host fans the same drift event out across the
86/// bridge until the renderer's V8 isolate runs out of stack and
87/// crashes (`Crashpad_NotConnectedToHandler`, observed v0.33.655).
88///
89/// The actual host emit order is
90/// `ReportPoolWindowRemoved → ReportPoolWindowPromoted →
91/// ReportWindowOpened` (`agentmux-cef/src/commands/window_pool.rs`).
92/// At promote-time the launcher has NO mirror for this label —
93/// `state.pool.contains(label)` is also false (removed by the
94/// preceding `ReportPoolWindowRemoved`), so we can't gate purely on
95/// pool membership in `handle_report_window_opened` either. The
96/// `just_promoted_labels` set bridges the microsecond gap.
97///
98/// **Order-tolerant:** if the open already arrived (out-of-order
99/// IPC, replay, fuzzed sequence), update the existing mirror
100/// immediately so we don't leak a `just_promoted_labels` entry that
101/// will never be drained. The proptest in tests.rs
102/// (`just_promoted_labels_drained_by_open_or_close`) caught this
103/// gap on PR #709 round 2.
104///
105/// See `docs/specs/ANALYSIS_DRIFT_STORM_RENDERER_CRASH_2026-05-06.md`.
106pub(super) fn handle_report_pool_window_promoted(state: &mut State, label: String) -> Vec<Event> {
107    // Order-tolerant: if the open already created the mirror, mark
108    // it foregrounded directly (don't bother with the bridge set).
109    // Production order has open AFTER promote, so the else-branch
110    // (insert into just_promoted_labels, consumed at open time) is
111    // the common path.
112    if let Some(mirror) = state.windows.get_mut(&label) {
113        mirror.foregrounded_since_open = true;
114    } else {
115        state.just_promoted_labels.insert(label.clone());
116    }
117    let v = state.bump_version();
118    vec![Event::PoolWindowPromoted { label, version: v }]
119}
120
121/// Phase F.6 — host-emitted signal carrying the result of the
122/// post-close drain-pool-if-last decision. Maps `was_last` directly
123/// to the corresponding terminal event for Step 2 of the
124/// window-cleanup-cascade saga:
125/// * `true` → `Event::PoolDrained` (last user-visible window
126///   closed; warm-pool drain initiated)
127/// * `false` → `Event::PoolNotLast` (other windows remain; pool
128///   stays warm)
129///
130/// Pure pass-through (same reasoning as `handle_report_panes_reaped`).
131pub(super) fn handle_report_pool_drain_decision(
132    state: &mut State,
133    label: String,
134    was_last: bool,
135    saga_id: Option<u64>,
136) -> Vec<Event> {
137    // Same rationale as handle_report_panes_reaped: round 4's gate
138    // had an ordering bug; round 5 reverts to emit-unconditionally.
139    //
140    // CPD-1: `saga_id` flows through unchanged.
141    let v = state.bump_version();
142    if was_last {
143        vec![Event::PoolDrained {
144            label,
145            version: v,
146            saga_id,
147        }]
148    } else {
149        vec![Event::PoolNotLast {
150            label,
151            version: v,
152            saga_id,
153        }]
154    }
155}
156