agentmux_cef\reducer/
pool.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pool state (Phase H.4) reducer handlers. Extracted from reducer/mod.rs in
5//! task #182 PR-F-2 for navigability.
6
7
8use crate::state::*;
9
10use super::{DispatchOutput, HostEvent, HostState, PoolLeaveReason};
11
12// ── H.4 — pool state ─────────────────────────────────────────────────────
13
14pub(super) fn handle_pool_spawn_start(state: &mut HostState, label: String) -> DispatchOutput {
15    // PR #5 H.4 — single-flight semaphore. The legacy
16    // `window_pool_respawn_in_flight.swap(true)` returns the prior
17    // value; if true, caller skips. We replicate that here: prior
18    // in-flight OR non-Running quit state both suppress the spawn.
19    if state.quit_state != QuitState::Running {
20        return DispatchOutput {
21            pool_spawn_proceeding: false,
22            ..Default::default()
23        };
24    }
25    if state.pool.respawn_in_flight {
26        return DispatchOutput {
27            pool_spawn_proceeding: false,
28            ..Default::default()
29        };
30    }
31    state.pool.unpromoted.insert(label);
32    state.pool.respawn_in_flight = true;
33    DispatchOutput {
34        pool_spawn_proceeding: true,
35        ..Default::default()
36    }
37}
38
39pub(super) fn handle_pool_ready(state: &mut HostState, label: String) -> DispatchOutput {
40    if !state.pool.unpromoted.remove(&label) {
41        // Not in unpromoted (race or duplicate signal); idempotent.
42        return DispatchOutput {
43            pool_size_after: Some(state.pool.queue.len()),
44            ..Default::default()
45        };
46    }
47    if !state.pool.queue.iter().any(|l| l == &label) {
48        state.pool.queue.push_back(label.clone());
49    }
50    state.pool.respawn_in_flight = false;
51    let queue_len_after = state.pool.queue.len();
52    let v = state.bump_version();
53    DispatchOutput {
54        events: vec![HostEvent::PoolWindowEntered {
55            label,
56            queue_len_after,
57            version: v,
58        }],
59        pool_size_after: Some(queue_len_after),
60        ..Default::default()
61    }
62}
63
64pub(super) fn handle_pool_destroyed_before_promote(state: &mut HostState, label: String) -> DispatchOutput {
65    // Pool windows can be destroyed in two states (codex P1 PR #654 round 2):
66    //   1. Still in `unpromoted` — never reached renderer-ready.
67    //   2. Already in `queue` — passed renderer-ready, awaiting promotion,
68    //      then closed externally before promote.
69    // Both must be cleaned up; otherwise the queue retains a dead label
70    // and a later `PromotePoolWindow` operates on stale inventory.
71    let was_unpromoted = state.pool.unpromoted.remove(&label);
72    let queue_len_before = state.pool.queue.len();
73    state.pool.queue.retain(|l| l != &label);
74    let was_in_queue = state.pool.queue.len() < queue_len_before;
75    state.pool.respawn_in_flight = false;
76    let queue_len_after = state.pool.queue.len();
77    if !was_unpromoted && !was_in_queue {
78        return DispatchOutput {
79            pool_size_after: Some(queue_len_after),
80            ..Default::default()
81        };
82    }
83    let v = state.bump_version();
84    DispatchOutput {
85        events: vec![HostEvent::PoolWindowLeft {
86            label,
87            queue_len_after,
88            reason: PoolLeaveReason::DestroyedBeforePromote,
89            version: v,
90        }],
91        pool_destroyed_was_unpromoted: was_unpromoted,
92        pool_size_after: Some(queue_len_after),
93        ..Default::default()
94    }
95}
96
97pub(super) fn handle_pop_and_promote_front_pool_window(state: &mut HostState) -> DispatchOutput {
98    let label = match state.pool.queue.pop_front() {
99        Some(l) => l,
100        None => return DispatchOutput::default(),
101    };
102    state.pool.unpromoted.remove(&label);
103    if let Some(handle) = state.browsers.get_mut(&label) {
104        if let BrowserKind::TopLevel { is_pool } = &mut handle.kind {
105            *is_pool = false;
106        }
107    }
108    let queue_len_after = state.pool.queue.len();
109    let v = state.bump_version();
110    DispatchOutput {
111        events: vec![HostEvent::PoolWindowLeft {
112            label: label.clone(),
113            queue_len_after,
114            reason: PoolLeaveReason::Promoted,
115            version: v,
116        }],
117        promoted_pool_label: Some(label),
118        pool_size_after: Some(queue_len_after),
119        ..Default::default()
120    }
121}
122
123pub(super) fn handle_promote_pool_window(state: &mut HostState, label: String) -> DispatchOutput {
124    // Idempotent no-op for truly unknown labels (reagent P2 PR #654 round 3).
125    // Symmetric with `handle_pool_destroyed_before_promote`'s pattern: only
126    // emit `PoolWindowLeft` if we actually removed the label from one of
127    // the pool sets. Without this, a stale promote command (e.g., from a
128    // race between PromotePoolWindow and PoolWindowDestroyedBeforePromote)
129    // would emit a phantom `PoolWindowLeft` event that observers might act on.
130    let queue_len_before = state.pool.queue.len();
131    state.pool.queue.retain(|l| l != &label);
132    let was_in_queue = state.pool.queue.len() < queue_len_before;
133    let was_in_unpromoted = state.pool.unpromoted.remove(&label);
134    if !was_in_queue && !was_in_unpromoted {
135        return DispatchOutput::default();
136    }
137    // Mark the corresponding browser handle as no-longer-pool.
138    if let Some(handle) = state.browsers.get_mut(&label) {
139        if let BrowserKind::TopLevel { is_pool } = &mut handle.kind {
140            *is_pool = false;
141        }
142    }
143    let v = state.bump_version();
144    DispatchOutput {
145        events: vec![HostEvent::PoolWindowLeft {
146            label,
147            queue_len_after: state.pool.queue.len(),
148            reason: PoolLeaveReason::Promoted,
149            version: v,
150        }],
151        ..Default::default()
152    }
153}
154
155pub(super) fn handle_pool_drain_all(state: &mut HostState) -> DispatchOutput {
156    let drained: Vec<String> = state
157        .pool
158        .queue
159        .drain(..)
160        .chain(state.pool.unpromoted.drain())
161        .collect();
162    state.pool.respawn_in_flight = false;
163    let mut events = Vec::new();
164    for label in drained {
165        let v = state.bump_version();
166        events.push(HostEvent::PoolWindowLeft {
167            label,
168            queue_len_after: 0,
169            reason: PoolLeaveReason::DrainedOnShutdown,
170            version: v,
171        });
172    }
173    let v = state.bump_version();
174    events.push(HostEvent::PoolEmpty { version: v });
175    DispatchOutput {
176        events,
177        ..Default::default()
178    }
179}
180