agentmux_launcher/reducer/mod.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.3 — pure reducer.
5//
6// Shares its DISCIPLINE with `agentmux_srv::reducer` — pure, total,
7// deterministic, no I/O — and its wire types: both import the same
8// `agentmux_common::ipc::{Command, Event}`. What differs is `State` (each
9// crate's own) and the command/event variants each `update` actually
10// handles — this one drives the window/process state machine, not
11// workspaces/tabs/blocks. Two reducers by design; see
12// docs/architecture/DECISION_SAGA_REDUCER_TWO_FRAMEWORKS_2026_09_07.md.
13//
14// Per `docs/specs/SPEC_WINDOW_PROCESS_STATE_MACHINE_2026_04_27.md` §5.1:
15//
16// pub fn update(state: &State, cmd: Command) -> (State, Vec<Event>);
17//
18// We deviate slightly: the function takes `&mut State` (mutating in
19// place rather than returning a new value) because cloning a HashMap
20// per-command in the IPC hot path is wasteful and not needed for
21// the testability properties we want — `proptest` works equally well
22// against a mutating-reducer (apply commands one by one, assert
23// invariants hold after each).
24//
25// Strict properties of `update`:
26// 1. **Total**. Every (cmd, state) combination produces a result;
27// never panics on input. (Panics ARE used to enforce internal
28// invariants — those are bugs in the reducer or upstream code,
29// not user input. See spec §7 / §8.)
30// 2. **Deterministic**. Same (state, cmd, conn) → same (state',
31// events). No clocks, no UUIDs, no env reads inside update —
32// injected via the `Reducer` context if needed (B.4 will need
33// it for spawned_at timestamps; for B.3 the conn carries one).
34// 3. **No I/O**. update never blocks or awaits. Mutex is held for
35// the duration of update — must stay sub-millisecond.
36//
37// Connection context: every command arrives over a specific
38// connection and the resulting events that are *replies* (Registered,
39// Pong) belong to that connection only, while *broadcasts*
40// (ProcessSpawned, LifecyclePhaseChanged) belong to all subscribers.
41// Phase B.3 ships in a simplified model where every event goes over
42// the originating connection; B.4 splits the routing.
43//
44// Invariants checked:
45// * Register on a PID that's already registered → AlreadyRegistered
46// error. (Connection-level enforcement that "Register sent twice"
47// also lives in the server; this is the cross-connection guard.)
48// * Lifecycle transitions: Starting → Running on first Host
49// register. Running → Quitting on Quit (B.3 placeholder).
50// Quitting → Dead on cleanup-done (B.3 placeholder). No skipping.
51
52use agentmux_common::ipc::{Command, DriftKind, Event};
53
54use crate::state::State;
55
56/// Context the reducer needs but can't read from State (clocks,
57/// connection identity). Passed in per-call so update remains pure.
58#[derive(Debug, Clone)]
59pub struct Ctx {
60 /// RFC3339 timestamp to stamp on new ProcessRecords. Injected
61 /// (rather than read from chrono::Utc::now() inside update) to
62 /// keep the function deterministic for tests.
63 pub now_rfc3339: String,
64 /// The connection on which this command arrived. Currently
65 /// just used for log correlation; B.4+ will use it to route
66 /// per-connection replies.
67 pub conn_id: u64,
68 /// PID the connection has Registered under, if any. The server
69 /// tracks this server-side and passes it on every command after
70 /// the initial Register so the reducer can mark the right
71 /// process Exited on Goodbye. None for the very first command
72 /// on a connection (which MUST be Register, enforced server-
73 /// side). (codex P1 + gemini HIGH PR #574 round-1.)
74 pub registered_pid: Option<u32>,
75 /// Phase B.9.1 — monotonic milliseconds since some reference
76 /// epoch (the IPC server uses launcher start as the epoch).
77 /// Used by the WRR arm for per-window observability timestamps
78 /// (`last_foreground_at_ms`, `pending_hwnds[hwnd].arrived_at_ms`).
79 /// Distinct from `now_rfc3339` (wall clock) because operators
80 /// reading `--diag wrr` want age, not absolute time.
81 pub now_ms: u64,
82}
83
84mod pool;
85mod window;
86mod saga;
87mod connection;
88
89/// Apply one Command to State, returning the resulting Events. State
90/// is mutated in place. Total function — never panics on input
91/// (panics are reserved for internal invariant violations).
92pub fn update(state: &mut State, cmd: Command, ctx: &Ctx) -> Vec<Event> {
93 let _ = ctx.conn_id; // reserved for B.4 routing
94
95 let mut cmd_events = match cmd {
96 Command::Register {
97 kind,
98 pid,
99 version,
100 } => connection::handle_register(state, ctx, kind, pid, version),
101 Command::Ping { nonce } => {
102 let v = state.bump_version();
103 vec![Event::Pong { nonce, version: v }]
104 }
105 // SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 1 — UI-liveness telemetry is
106 // recorded at the transport layer (`ipc/server.rs` →
107 // `crate::ui_liveness`), NOT here: it's thread/process telemetry
108 // about the host, not domain state this reducer owns. This arm
109 // exists only for match exhaustiveness — a defensive no-op.
110 Command::ReportUiThreadAlive { .. } => Vec::new(),
111 // The launcher SENDS ProbeUiThread (over the host pipe); receiving
112 // one on its own IPC server is a wrong-direction message — ignore
113 // (the reducer is pure and does not log; the pre-Register table in
114 // ipc/server.rs already names this case for unregistered senders).
115 Command::ProbeUiThread { .. } => Vec::new(),
116 // Launcher → host only, like ProbeUiThread; a client sending it is a no-op.
117 Command::NotifySrvLatency { .. } => Vec::new(),
118 Command::Goodbye => connection::handle_goodbye(state, ctx.registered_pid.unwrap_or(0)),
119 Command::ReportWindowOpened {
120 label,
121 kind,
122 parent_label,
123 } => {
124 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportWindowOpened") {
125 return vec![err];
126 }
127 window::handle_report_window_opened(state, ctx, label, kind, parent_label)
128 }
129 Command::ReportWindowClosed { label } => {
130 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportWindowClosed") {
131 return vec![err];
132 }
133 window::handle_report_window_closed(state, label)
134 }
135 Command::ReportBackgroundServiceEnabled { enabled } => {
136 if let Some(err) =
137 connection::enforce_host_only(state, ctx, "ReportBackgroundServiceEnabled")
138 {
139 return vec![err];
140 }
141 window::handle_report_background_service_enabled(state, enabled)
142 }
143 Command::ReportPoolWindowAdded { label, saga_id } => {
144 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportPoolWindowAdded") {
145 return vec![err];
146 }
147 pool::handle_report_pool_window_added(state, label, saga_id)
148 }
149 Command::ReportPoolWindowRemoved { label } => {
150 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportPoolWindowRemoved") {
151 return vec![err];
152 }
153 pool::handle_report_pool_window_removed(state, label)
154 }
155 // Phase F.5 — host-only signal that a pool→window promote is
156 // happening. Sent BETWEEN the matching `ReportPoolWindowRemoved`
157 // and `ReportWindowOpened` so the launcher can disambiguate
158 // promote from pre-promote destroy. Pure-reducer arm: emits
159 // the typed event; saga side-effect (start the pool-respawn
160 // saga) lives in the saga coordinator's bus subscription.
161 Command::ReportPoolWindowPromoted { label } => {
162 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportPoolWindowPromoted") {
163 return vec![err];
164 }
165 pool::handle_report_pool_window_promoted(state, label)
166 }
167 // Phase F.5 — `SpawnPoolWindow` is a launcher→host direction
168 // command, NOT a host→launcher report. If a registered client
169 // sends it to the launcher pipe by mistake, return a non-fatal
170 // error so the client knows the dispatch was wrong (vs silently
171 // appearing successful with no reply). Same misrouted-error
172 // pattern as the srv-pipe commands below.
173 Command::SpawnPoolWindow { .. } => {
174 let v = state.bump_version();
175 vec![Event::Error {
176 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
177 message: "SpawnPoolWindow is a launcher→host command; sent to launcher pipe by mistake".into(),
178 fatal: false,
179 version: v,
180 }]
181 }
182 // Phase F.6 — host-only signal that all browser-pane HWNDs
183 // belonging to a closing top-level window have been reaped.
184 // Pure-reducer arm: emits the typed event so the
185 // window-cleanup-cascade saga can advance from Step 1
186 // (ReapingPanes) to Step 2 (DrainingPool). State is
187 // unchanged — pane bookkeeping lives in the host's session
188 // structures (lifecycle entries, pane HWND map), not the
189 // launcher's mirror; the launcher just narrates the
190 // transition for subscribers.
191 Command::ReportPanesReaped { label, saga_id } => {
192 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportPanesReaped") {
193 return vec![err];
194 }
195 saga::handle_report_panes_reaped(state, label, saga_id)
196 }
197 // Phase F.6 — host reports the result of the post-close
198 // drain-pool-if-last decision. `was_last == true` →
199 // `Event::PoolDrained`; `was_last == false` →
200 // `Event::PoolNotLast`. Both are terminal alternatives for
201 // the window-cleanup-cascade saga's Step 2.
202 Command::ReportPoolDrainDecision {
203 label,
204 was_last,
205 saga_id,
206 } => {
207 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportPoolDrainDecision") {
208 return vec![err];
209 }
210 pool::handle_report_pool_drain_decision(state, label, was_last, saga_id)
211 }
212 // Phase F.6 — `ReapPanes` and `DrainPoolIfLast` are
213 // launcher→host direction commands, NOT host→launcher
214 // reports. Same misrouted-error pattern as `SpawnPoolWindow`
215 // above.
216 Command::ReapPanes { .. } => {
217 let v = state.bump_version();
218 vec![Event::Error {
219 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
220 message: "ReapPanes is a launcher→host command; sent to launcher pipe by mistake".into(),
221 fatal: false,
222 version: v,
223 }]
224 }
225 Command::DrainPoolIfLast { .. } => {
226 let v = state.bump_version();
227 vec![Event::Error {
228 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
229 message: "DrainPoolIfLast is a launcher→host command; sent to launcher pipe by mistake".into(),
230 fatal: false,
231 version: v,
232 }]
233 }
234 // Phase CPD-1 — host-emitted report that a saga-issued action
235 // failed. Pure pass-through arm: translates the wire command
236 // into `Event::SagaActionFailed`. The saga coordinator's bus
237 // loop (CPD-3) will treat this as a terminal signal for the
238 // matching `saga_id` and emit `Event::SagaFailed`. Host-only
239 // gate same as other Report* arms.
240 Command::ReportSagaActionFailed { saga_id, reason } => {
241 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportSagaActionFailed") {
242 return vec![err];
243 }
244 saga::handle_report_saga_action_failed(state, saga_id, reason)
245 }
246 // Startup-stage telemetry is NOT mirrored State — it's forwarded
247 // directly into the launcher's StartupEventSink by a short-circuit
248 // in ipc/server.rs BEFORE the command ever reaches this reducer
249 // (same pattern as GetEvents). These arms exist only to satisfy
250 // exhaustiveness; they are unreachable at runtime.
251 Command::ReportStartupStageBegin { .. } | Command::ReportStartupStageEnd { .. } => {
252 vec![]
253 }
254 Command::ReportHostCounts { windows, pool } => {
255 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHostCounts") {
256 return vec![err];
257 }
258 handle_report_host_counts(state, windows, pool)
259 }
260 Command::ReportHostPoolCount { count } => {
261 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHostPoolCount") {
262 return vec![err];
263 }
264 pool::handle_report_host_pool_count(state, count)
265 }
266 Command::ReportBackendWindowIdRegistered { label, window_id } => {
267 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportBackendWindowIdRegistered") {
268 return vec![err];
269 }
270 window::handle_report_backend_window_id_registered(state, label, window_id)
271 }
272 Command::ReportBackendWindowIdUnregistered { label } => {
273 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportBackendWindowIdUnregistered") {
274 return vec![err];
275 }
276 window::handle_report_backend_window_id_unregistered(state, label)
277 }
278 // Phase B.9.1 (WRR) — Win32 reality events. Host-only
279 // because only the host installs the SetWinEventHook /
280 // wndproc wrapper. Same enforce-host pattern as the other
281 // observability reports.
282 Command::ReportHwndOpened { hwnd, class_name, title, label_hint } => {
283 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHwndOpened") {
284 return vec![err];
285 }
286 crate::wrr::apply_hwnd_opened(state, hwnd, class_name, title, label_hint, ctx.now_ms)
287 }
288 Command::ReportHwndDestroyed { hwnd } => {
289 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHwndDestroyed") {
290 return vec![err];
291 }
292 let host_running = connection::host_is_running(state);
293 crate::wrr::apply_hwnd_destroyed(state, hwnd, host_running)
294 }
295 Command::ReportHwndVisibilityChanged { hwnd, visible } => {
296 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHwndVisibilityChanged") {
297 return vec![err];
298 }
299 crate::wrr::apply_hwnd_visibility_changed(state, hwnd, visible, ctx.now_ms)
300 }
301 Command::ReportHwndForegroundChanged { hwnd } => {
302 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHwndForegroundChanged") {
303 return vec![err];
304 }
305 crate::wrr::apply_hwnd_foreground_changed(state, hwnd, ctx.now_ms)
306 }
307 Command::ReportHwndIconicChanged { hwnd, iconic } => {
308 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHwndIconicChanged") {
309 return vec![err];
310 }
311 crate::wrr::apply_hwnd_iconic_changed(state, hwnd, iconic)
312 }
313 Command::ReportHwndPositionChanged { hwnd, rect } => {
314 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportHwndPositionChanged") {
315 return vec![err];
316 }
317 crate::wrr::apply_hwnd_position_changed(state, hwnd, rect)
318 }
319 Command::ReportMonitorTopologyChanged { rects } => {
320 if let Some(err) = connection::enforce_host_only(state, ctx, "ReportMonitorTopologyChanged") {
321 return vec![err];
322 }
323 crate::wrr::apply_monitor_topology_changed(state, rects)
324 }
325 // Phase D.1 — read-only snapshot of canonical state. Any
326 // registered client can ask; no host-only gate. Reducer state
327 // is unchanged by this command (it's a query, not a mutation),
328 // but we still bump `event_version` so the snapshot's version
329 // is monotonically distinct from prior events — a subscriber
330 // applying snapshot + delta events knows the snapshot's
331 // version is the "as-of" point.
332 Command::GetSnapshot => connection::handle_get_snapshot(state),
333 // Phase D.3 — `GetEvents` is intercepted by the IPC server's
334 // dispatch path BEFORE reaching the reducer (it's a non-
335 // mutating read against the event log, which is I/O-adjacent
336 // — keeping it out of the pure reducer preserves the
337 // "reducer never blocks" invariant). This arm exists only
338 // to satisfy the exhaustive match; in practice it's
339 // unreachable. Returning empty Vec is the safe no-op.
340 Command::GetEvents { .. } => Vec::new(),
341 // Phase E.2 — srv-pipe domain commands routed to launcher
342 // pipe by mistake.
343 Command::CreateWorkspace { .. } => {
344 let v = state.bump_version();
345 vec![Event::Error {
346 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
347 message: "CreateWorkspace is a srv-pipe command; sent to launcher pipe by mistake".into(),
348 fatal: false,
349 version: v,
350 }]
351 }
352 Command::DeleteWorkspace { .. } => {
353 let v = state.bump_version();
354 vec![Event::Error {
355 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
356 message: "DeleteWorkspace is a srv-pipe command; sent to launcher pipe by mistake".into(),
357 fatal: false,
358 version: v,
359 }]
360 }
361 // Phase E.2b — Tab arms are also srv-pipe commands.
362 Command::CreateTab { .. } => {
363 let v = state.bump_version();
364 vec![Event::Error {
365 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
366 message: "CreateTab is a srv-pipe command; sent to launcher pipe by mistake".into(),
367 fatal: false,
368 version: v,
369 }]
370 }
371 Command::DeleteTab { .. } => {
372 let v = state.bump_version();
373 vec![Event::Error {
374 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
375 message: "DeleteTab is a srv-pipe command; sent to launcher pipe by mistake".into(),
376 fatal: false,
377 version: v,
378 }]
379 }
380 Command::SetActiveTab { .. } => {
381 let v = state.bump_version();
382 vec![Event::Error {
383 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
384 message: "SetActiveTab is a srv-pipe command; sent to launcher pipe by mistake".into(),
385 fatal: false,
386 version: v,
387 }]
388 }
389 Command::ReorderTab { .. } => {
390 let v = state.bump_version();
391 vec![Event::Error {
392 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
393 message: "ReorderTab is a srv-pipe command; sent to launcher pipe by mistake".into(),
394 fatal: false,
395 version: v,
396 }]
397 }
398 // Phase E.5 — window↔workspace mapping commands are srv-pipe.
399 Command::CreateWindow { .. } => {
400 let v = state.bump_version();
401 vec![Event::Error {
402 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
403 message: "CreateWindow is a srv-pipe command; sent to launcher pipe by mistake".into(),
404 fatal: false,
405 version: v,
406 }]
407 }
408 Command::CloseWindowInternal { .. } => {
409 let v = state.bump_version();
410 vec![Event::Error {
411 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
412 message: "CloseWindowInternal is a srv-pipe command; sent to launcher pipe by mistake".into(),
413 fatal: false,
414 version: v,
415 }]
416 }
417 Command::SwitchWorkspace { .. } => {
418 let v = state.bump_version();
419 vec![Event::Error {
420 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
421 message: "SwitchWorkspace is a srv-pipe command; sent to launcher pipe by mistake".into(),
422 fatal: false,
423 version: v,
424 }]
425 }
426 // Phase E.5.3 — atomic single-step domain commands are srv-pipe.
427 Command::ReorderTabsBulk { .. } => {
428 let v = state.bump_version();
429 vec![Event::Error {
430 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
431 message: "ReorderTabsBulk is a srv-pipe command; sent to launcher pipe by mistake".into(),
432 fatal: false,
433 version: v,
434 }]
435 }
436 Command::RenameWorkspace { .. } => {
437 let v = state.bump_version();
438 vec![Event::Error {
439 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
440 message: "RenameWorkspace is a srv-pipe command; sent to launcher pipe by mistake".into(),
441 fatal: false,
442 version: v,
443 }]
444 }
445 Command::RenameTab { .. } => {
446 let v = state.bump_version();
447 vec![Event::Error {
448 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
449 message: "RenameTab is a srv-pipe command; sent to launcher pipe by mistake".into(),
450 fatal: false,
451 version: v,
452 }]
453 }
454 Command::UpdateWorkspaceMeta { .. } => {
455 let v = state.bump_version();
456 vec![Event::Error {
457 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
458 message: "UpdateWorkspaceMeta is a srv-pipe command; sent to launcher pipe by mistake".into(),
459 fatal: false,
460 version: v,
461 }]
462 }
463 Command::UpdateTabMeta { .. } => {
464 let v = state.bump_version();
465 vec![Event::Error {
466 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
467 message: "UpdateTabMeta is a srv-pipe command; sent to launcher pipe by mistake".into(),
468 fatal: false,
469 version: v,
470 }]
471 }
472 Command::UpdateBlockMeta { .. } => {
473 let v = state.bump_version();
474 vec![Event::Error {
475 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
476 message: "UpdateBlockMeta is a srv-pipe command; sent to launcher pipe by mistake".into(),
477 fatal: false,
478 version: v,
479 }]
480 }
481 // Phase E.3 — Block arms are also srv-pipe commands.
482 Command::CreateBlock { .. } => {
483 let v = state.bump_version();
484 vec![Event::Error {
485 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
486 message: "CreateBlock is a srv-pipe command; sent to launcher pipe by mistake".into(),
487 fatal: false,
488 version: v,
489 }]
490 }
491 Command::DeleteBlock { .. } => {
492 let v = state.bump_version();
493 vec![Event::Error {
494 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
495 message: "DeleteBlock is a srv-pipe command; sent to launcher pipe by mistake".into(),
496 fatal: false,
497 version: v,
498 }]
499 }
500 // Phase E.5.5 — saga-driven move commands are srv-pipe.
501 Command::MoveTab { .. } => {
502 let v = state.bump_version();
503 vec![Event::Error {
504 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
505 message: "MoveTab is a srv-pipe command; sent to launcher pipe by mistake".into(),
506 fatal: false,
507 version: v,
508 }]
509 }
510 Command::MoveBlock { .. } => {
511 let v = state.bump_version();
512 vec![Event::Error {
513 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
514 message: "MoveBlock is a srv-pipe command; sent to launcher pipe by mistake".into(),
515 fatal: false,
516 version: v,
517 }]
518 }
519 // Phase E.4 (Option A) — layout-focused/magnified setters are
520 // srv-pipe commands. Misrouted to the launcher pipe, return
521 // a non-fatal error so the client knows the dispatch was wrong.
522 Command::SetFocusedNode { .. } => {
523 let v = state.bump_version();
524 vec![Event::Error {
525 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
526 message: "SetFocusedNode is a srv-pipe command; sent to launcher pipe by mistake".into(),
527 fatal: false,
528 version: v,
529 }]
530 }
531 Command::SetMagnifiedNode { .. } => {
532 let v = state.bump_version();
533 vec![Event::Error {
534 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
535 message: "SetMagnifiedNode is a srv-pipe command; sent to launcher pipe by mistake".into(),
536 fatal: false,
537 version: v,
538 }]
539 }
540 // Phase E.1b — `GetSrvSnapshot` is a srv-pipe command. If a
541 // registered client misroutes it to the launcher pipe, return
542 // an explicit error so the client knows the dispatch was
543 // wrong (vs silently appearing successful with no reply).
544 // Pre-Register, `enforce_register_first` already returns a
545 // soft error. (codex P2 #610.)
546 Command::GetSrvSnapshot => {
547 let v = state.bump_version();
548 vec![Event::Error {
549 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
550 message: "GetSrvSnapshot is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
551 fatal: false,
552 version: v,
553 }]
554 }
555 // Phase E.4.B — layout-tree commands are srv-pipe only; same
556 // soft-error treatment as GetSrvSnapshot above.
557 Command::LayoutInsertNode { .. }
558 | Command::LayoutInsertNodeAtIndex { .. }
559 | Command::LayoutDeleteNode { .. }
560 | Command::LayoutDeleteNodeByBlock { .. }
561 | Command::LayoutStackPush { .. }
562 | Command::LayoutStackActivate { .. }
563 | Command::CreateBlockInStack { .. }
564 | Command::LayoutStackMove { .. }
565 | Command::LayoutQueueBackendActions { .. }
566 | Command::LayoutMoveNode { .. }
567 | Command::LayoutSwapNodes { .. }
568 | Command::LayoutResizeNodes { .. }
569 | Command::LayoutReplaceNode { .. }
570 | Command::LayoutSplitHorizontal { .. }
571 | Command::LayoutSplitVertical { .. }
572 | Command::LayoutClear { .. }
573 | Command::LayoutSetTree { .. }
574 | Command::UpdateWindowMeta { .. } => {
575 let v = state.bump_version();
576 vec![Event::Error {
577 code: agentmux_common::ipc::ErrorCode::InvalidCommand,
578 message: "Srv-pipe command (Layout/UpdateWindowMeta) sent to launcher pipe by mistake".to_string(),
579 fatal: false,
580 version: v,
581 }]
582 }
583 };
584
585 // Heartbeat-via-traffic: drain any deferred hidden-since-open
586 // drifts AFTER the command processes. Running this AFTER (not
587 // before) is critical — the command itself may legitimately clear
588 // the deferred state (visible=true / foreground / window closed),
589 // and a pre-command drain would fire spurious drift on an event
590 // whose own purpose is the recovery (codex P2 PR #725 round 2).
591 //
592 // Catches windows that hid during placement and produced no
593 // further visibility events: any subsequent unrelated command
594 // past the grace promotes the deferred state to a fired drift.
595 let mut deferred = crate::wrr::drain_deferred_hidden_since_open(state, ctx.now_ms);
596 cmd_events.append(&mut deferred);
597 cmd_events
598}
599
600
601
602/// Phase B.4 follow-up — drift check. Compares host-reported counts
603/// to launcher mirror counts; emits `DriftDetected` for each
604/// disagreeing dimension. Returns `[]` when both counts match (the
605/// happy path; mirrors are in sync).
606///
607/// Two events possible (windows + pool) when both diverge in the
608/// same report — emitted in a stable order (windows first) so test
609/// assertions don't depend on HashSet iteration order.
610fn handle_report_host_counts(state: &mut State, host_windows: u32, host_pool: u32) -> Vec<Event> {
611 let mut out = Vec::new();
612 let mirror_windows = state.windows.len() as u32;
613 let mirror_pool = state.pool.len() as u32;
614 if mirror_windows != host_windows {
615 let v = state.bump_version();
616 out.push(Event::DriftDetected {
617 kind: DriftKind::Windows,
618 host_count: host_windows,
619 mirror_count: mirror_windows,
620 version: v,
621 });
622 }
623 if mirror_pool != host_pool {
624 let v = state.bump_version();
625 out.push(Event::DriftDetected {
626 kind: DriftKind::Pool,
627 host_count: host_pool,
628 mirror_count: mirror_pool,
629 version: v,
630 });
631 }
632 out
633}
634
635
636
637
638
639
640
641
642
643
644#[cfg(test)]
645mod tests;