Skip to main content

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;