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