Skip to main content

agentmux_launcher/ipc/
server.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Named-pipe IPC server — accept loop + per-connection handler.
5//
6// Phase B.3: every Command goes through the pure reducer
7// (`crate::reducer::update`) which mutates the shared State and
8// returns a Vec<Event>. The handler then writes those events back
9// over the connection. State is held inside Arc<Mutex<State>> and
10// the mutex is acquired only for the duration of the reducer call —
11// never across an await.
12//
13// What this commit does NOT do:
14//   * Per-subscriber broadcast routing (B.4 splits replies vs broadcasts;
15//     today every event goes back over the originating connection).
16//   * Server-initiated events (no spontaneous emissions yet — only
17//     reducer outputs).
18//   * Persisted client_id (still per-launcher-run).
19//
20// Connection lifecycle: each accepted pipe instance handles one
21// client connection end-to-end. When the client drops, the per-
22// connection task ends and the accept loop continues with a fresh
23// instance.
24
25use std::sync::Arc;
26
27use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
28use tokio::sync::Mutex;
29
30#[cfg(target_os = "windows")]
31use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
32
33use agentmux_common::ipc::{ClientKind, Command, ErrorCode, Event};
34
35use crate::host_pipe::HostPipe;
36use crate::reducer;
37use crate::state::State;
38
39/// State the IPC server shares across connections. Carries the
40/// launcher's identity (for patching into Registered events the
41/// reducer emits with sentinel values) plus the canonical State.
42#[derive(Debug)]
43pub struct ServerCtx {
44    pub launcher_pid: u32,
45    pub launcher_version: String,
46    /// Canonical state owned by the server. Mutex held only during
47    /// reducer dispatch — sub-millisecond.
48    ///
49    /// Phase E.1a — moved from `Mutex<State>` to `Arc<Mutex<State>>`
50    /// so the saga coordinator can share access. The coordinator
51    /// needs `bump_version` when emitting saga lifecycle events and
52    /// will (in E.5) inspect state during saga decisions. Sharing
53    /// via `Arc` keeps the existing single-writer-mutex discipline.
54    pub state: std::sync::Arc<Mutex<State>>,
55    /// Phase B.8 — broadcast bus for reducer-emitted events. Every
56    /// reducer event from `reducer::update` is published here; each
57    /// connection subscribes and writes received events to its own
58    /// pipe. Lets observability clients (`--diag wrr`, future Tools)
59    /// see cross-process activity, not just replies to their own
60    /// commands. Per-connection direct sends (Error replies for
61    /// parse failures, register-first violations) bypass the bus —
62    /// they're response-to-this-client-only by intent. (codex P1
63    /// PR #605.)
64    pub events_tx: tokio::sync::broadcast::Sender<Event>,
65    /// Phase D.2 — event log: in-memory ring of recent reducer
66    /// events (replay source for D.3's `GetEvents`) + disk
67    /// persistence stream for crash forensics. Server appends to
68    /// the in-memory ring synchronously after each reducer
69    /// dispatch; the disk writer is a separate task spawned in
70    /// main.rs that subscribes to the broadcast bus.
71    pub event_log: std::sync::Arc<crate::event_log::EventLog>,
72    /// CPD-2 — launcher → host pipe wrapper. The per-connection
73    /// handler hands the host's writer half to `HostPipe::set_writer`
74    /// once the connecting client registers as `ClientKind::Host`,
75    /// and clears it on disconnect. The host's per-connection event
76    /// fanout task routes events through `HostPipe::send_event`
77    /// instead of `send_event` direct (so frames carry the
78    /// `HostFrame` envelope and traverse the pending-buffer path
79    /// when the host reconnects).
80    pub host_pipe: std::sync::Arc<HostPipe>,
81    /// Startup-stage telemetry sink — `ReportStartupStageBegin`/`End`
82    /// commands from the host are forwarded here (bypassing the
83    /// reducer entirely, see the match arm in `handle_connection`) so
84    /// they flow into the same splash-panel display as the launcher's
85    /// own internal stages. `None` when the splash is disabled or on
86    /// platforms/paths that don't yet wire a sink through (mirrors the
87    /// existing `Option<StartupEventSink>` plumbing in unix.rs/windows.rs).
88    pub startup_sink: Option<crate::startup_events::StartupEventSink>,
89}
90
91/// Bind the first named-pipe instance synchronously.
92///
93/// Phase B.6: the bind is the single-instance signal. Splitting it
94/// out of `run_ipc_server` lets the caller (main.rs) detect a
95/// collision BEFORE spawning srv/host and surface a user-visible
96/// error ("AgentMux is already running"). `ServerOptions::create`
97/// requires a Tokio runtime context for IOCP registration, so this
98/// must be called from inside `#[tokio::main]` (or any task on the
99/// runtime) — not from a plain sync entrypoint.
100///
101/// On Windows, a second launcher hitting the same pipe gets
102/// `ERROR_ACCESS_DENIED` (raw OS error 5); other errors mean the
103/// pipe namespace itself is misconfigured.
104#[cfg(target_os = "windows")]
105pub fn bind_first_pipe_instance(pipe_name: &str) -> std::io::Result<NamedPipeServer> {
106    ServerOptions::new()
107        .first_pipe_instance(true)
108        .create(pipe_name)
109}
110
111/// Run the named-pipe IPC server until cancelled (or task panics).
112///
113/// Returns a JoinHandle the caller (main.rs) holds for the life of
114/// the launcher. The server keeps accepting until the launcher's
115/// Tokio runtime shuts down.
116///
117/// The first pipe instance is passed in pre-bound by the caller (see
118/// `bind_first_pipe_instance`) so a collision can be surfaced
119/// synchronously before any children are spawned (Phase B.6).
120///
121/// Each accepted connection becomes a new tokio task running
122/// `handle_connection`. The accept loop creates a fresh
123/// `NamedPipeServer` instance for the next client BEFORE spawning
124/// the handler — without this, a slow handler could starve the next
125/// connect. Standard Win32 named-pipe pattern.
126#[cfg(target_os = "windows")]
127pub fn run_ipc_server(
128    pipe_name: String,
129    first: NamedPipeServer,
130    ctx: ServerCtx,
131) -> tokio::task::JoinHandle<()> {
132    tokio::spawn(async move {
133        let ctx = Arc::new(ctx);
134        crate::log(&format!("[ipc] server starting on {}", pipe_name));
135
136        let mut current = first;
137
138        loop {
139            // Wait for a client to connect to the existing instance.
140            // On error: log + recreate the instance + retry. Without
141            // the explicit `continue`, the failed (un-connected) pipe
142            // instance below would be moved into `accepted` and
143            // spawned in a handler that immediately fails to read,
144            // wasting a per-connection task slot. (reagent P1 + codex
145            // P1 PR #573 round-1.)
146            if let Err(e) = current.connect().await {
147                crate::log(&format!("[ipc] connect failed: {} — recreating instance", e));
148                current = match ServerOptions::new().create(&pipe_name) {
149                    Ok(s) => s,
150                    Err(create_err) => {
151                        crate::log(&format!(
152                            "[ipc] FATAL: failed to recreate pipe after connect error: {} (server stopping)",
153                            create_err
154                        ));
155                        return;
156                    }
157                };
158                continue;
159            }
160
161            // Hand the accepted instance to a handler task, then
162            // create the NEXT server instance so the next client
163            // doesn't have to wait for the handler to finish.
164            let accepted = current;
165            current = match ServerOptions::new().create(&pipe_name) {
166                Ok(s) => s,
167                Err(e) => {
168                    crate::log(&format!(
169                        "[ipc] FATAL: failed to create next pipe instance: {} (server stopping)",
170                        e
171                    ));
172                    // Drain the accepted client, then bail.
173                    tokio::spawn(handle_connection(accepted, Arc::clone(&ctx)));
174                    return;
175                }
176            };
177
178            tokio::spawn(handle_connection(accepted, Arc::clone(&ctx)));
179        }
180    })
181}
182
183/// Unix counterpart of `bind_first_pipe_instance`: bind a Unix
184/// domain socket. The bind is the single-instance signal — a second
185/// launcher pointing at the same socket path gets `EADDRINUSE`. Caller
186/// is responsible for unlinking a stale socket file first (see the
187/// `connect → ECONNREFUSED → unlink → bind` pattern in `main.rs::run_unix`).
188///
189/// A1.1 of SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.
190#[cfg(unix)]
191pub fn bind_first_unix_socket(socket_path: &str) -> std::io::Result<tokio::net::UnixListener> {
192    tokio::net::UnixListener::bind(socket_path)
193}
194
195/// Run the Unix-domain-socket IPC server until cancelled (or task panics).
196///
197/// Mirrors the Windows accept loop above. The protocol on the wire is
198/// identical (newline-delimited JSON `Command` / `Event`) so
199/// `handle_connection` is shared between platforms via generics.
200///
201/// The listener is passed in pre-bound by the caller so a collision
202/// (a second launcher pointing at the same data dir) can be surfaced
203/// synchronously before any children spawn. (Same Phase B.6 contract
204/// as the Windows path.)
205#[cfg(unix)]
206pub fn run_ipc_server(
207    socket_path: String,
208    listener: tokio::net::UnixListener,
209    ctx: ServerCtx,
210) -> tokio::task::JoinHandle<()> {
211    tokio::spawn(async move {
212        let ctx = Arc::new(ctx);
213        crate::log(&format!("[ipc] server starting on {}", socket_path));
214
215        // Backoff state — guard against a persistent accept error
216        // (e.g. EMFILE/ENFILE under fd exhaustion, EBADF if the
217        // listener fd is closed out from under us) spinning a hot
218        // loop and flooding the launcher log. Reagent P2 on PR #1288.
219        const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 32;
220        let mut consecutive_errors: u32 = 0;
221
222        loop {
223            match listener.accept().await {
224                Ok((stream, _addr)) => {
225                    consecutive_errors = 0;
226                    tokio::spawn(handle_connection(stream, Arc::clone(&ctx)));
227                }
228                Err(e) => {
229                    consecutive_errors = consecutive_errors.saturating_add(1);
230                    if consecutive_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS {
231                        crate::log(&format!(
232                            "[ipc] FATAL: {} consecutive accept errors (last: {}); listener appears permanently broken — stopping IPC server",
233                            consecutive_errors, e
234                        ));
235                        return;
236                    }
237                    // Exponential backoff capped at 1s: 1ms → 2ms → 4ms
238                    // → … → 1024ms → 1024ms. Keeps EMFILE/ENFILE
239                    // recovery responsive (most descriptor pressure
240                    // clears in microseconds) without hot-spinning a
241                    // CPU core on a truly broken listener.
242                    let backoff_ms = 1u64 << consecutive_errors.min(10);
243                    let backoff = std::time::Duration::from_millis(backoff_ms.min(1024));
244                    crate::log(&format!(
245                        "[ipc] accept error #{}: {} — backing off {:?}",
246                        consecutive_errors, e, backoff
247                    ));
248                    tokio::time::sleep(backoff).await;
249                }
250            }
251        }
252    })
253}
254
255/// Drive one connection: read newline-delimited JSON Commands,
256/// write back JSON Events. First message MUST be `Register`.
257///
258/// Phase B.3: every Command goes through `reducer::update`. The
259/// reducer is sync; we hold the state mutex only while it runs.
260/// Events come back from the reducer as Vec<Event>; we patch sentinel
261/// fields (Registered.launcher_pid / launcher_version — the reducer
262/// can't read those) and publish them on the broadcast bus.
263///
264/// Phase B.8 — events from the reducer flow through the broadcast
265/// bus (`ctx.events_tx`); a per-connection fanout task subscribes
266/// and writes events to this connection's pipe. Per-connection
267/// direct writes are reserved for "response-to-this-client-only"
268/// errors (parse failure, register-first violation). (codex P1
269/// PR #605.)
270// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
271//
272// Generic over the duplex stream type so the same body serves Windows
273// `NamedPipeServer` and Unix `tokio::net::UnixStream` without code
274// duplication. Bounds are what `tokio::io::split` + `Box<dyn AsyncWrite>`
275// need: AsyncRead + AsyncWrite + Unpin + Send + 'static.
276async fn handle_connection<S>(stream: S, ctx: Arc<ServerCtx>)
277where
278    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
279{
280    let (read_half, write_half) = tokio::io::split(stream);
281    // CPD-2 — wrap the writer in the HostPipe-compatible
282    // `Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>` shape so the
283    // SAME writer is reachable by:
284    //   1. The per-connection main loop (connection-private error
285    //      replies via `send_event_shared`).
286    //   2. The per-connection fanout task (broadcast-bus events).
287    //   3. (For the host connection only) `HostPipe::send_command` /
288    //      `HostPipe::send_event` after the host registers.
289    // The Mutex serializes writes, so frames can't interleave.
290    let boxed: crate::host_pipe::BoxedWriter = Box::new(write_half);
291    let writer: crate::host_pipe::SharedWriter = crate::host_pipe::make_shared_writer(boxed);
292    let reader = BufReader::new(read_half);
293    let mut lines = reader.lines();
294
295    // Per-connection state the server (not reducer) tracks: have we
296    // seen a Register yet? Reducer-level dedup is keyed by PID across
297    // all connections; this is the per-connection enforcement so a
298    // single connection can't send Ping before Register.
299    let mut registered_kind: Option<ClientKind> = None;
300    let mut registered_pid: Option<u32> = None;
301    // Connection ID is server-allocated (not state-allocated) and
302    // exists only for log correlation; the reducer-allocated
303    // client_id (returned in Registered) is the wire-visible one.
304    let conn_id = NEXT_CONN_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
305
306    // Phase B.8 — fanout task: subscribe to the server-wide event
307    // bus and write each event to THIS connection's pipe. Started
308    // before any commands are processed so a registered client can
309    // start receiving events from concurrent activity immediately.
310    // Aborted when the connection's read loop returns (below).
311    //
312    // CPD-2 design (round 4): events are written DIRECTLY to this
313    // connection's per-connection writer — NOT routed through
314    // `HostPipe`. HostPipe exists for *commands* (saga-issued
315    // launcher → host actions, which CPD-3 will wire). Events stay
316    // on the existing direct-write path because:
317    //   1. Wire format compat — host's parser expects raw `Event`
318    //      JSON, not `HostFrame::Event` envelopes (codex P1 round 3).
319    //      CPD-3 will update host's parser AND swap the fanout to
320    //      HostFrame envelopes together.
321    //   2. No need for HostPipe's pending-buffer / 30s-timeout
322    //      semantics on events: events are broadcast-driven, every
323    //      subscriber gets them, and stale events post-reconnect
324    //      would be wrong anyway.
325    //   3. Each per-connection fanout writes to its OWN writer —
326    //      no global-writer race / stale-fanout issue (which is
327    //      what `host_session_id` would have guarded against, but
328    //      isn't needed when fanouts are connection-local).
329    let fanout_handle = {
330        let writer = Arc::clone(&writer);
331        let ctx = Arc::clone(&ctx);
332        let mut events_rx = ctx.events_tx.subscribe();
333        tokio::spawn(async move {
334            loop {
335                match events_rx.recv().await {
336                    Ok(event) => {
337                        // Identity is patched at the publisher (before
338                        // log + bus). No need to re-patch here.
339                        // Errors here mean the pipe is closed; the
340                        // read loop will detect EOF on the next
341                        // iteration. Swallow + continue to drain
342                        // any remaining buffered events so we don't
343                        // accidentally hold onto channel slots.
344                        if send_event_shared(&writer, event).await.is_err() {
345                            return;
346                        }
347                    }
348                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
349                        // Slow client missed `n` events. Phase D's
350                        // GetSnapshot resync covers this case
351                        // properly; for now the client has to
352                        // reconnect to recover. Log so operators
353                        // can see when this happens.
354                        crate::log(&format!(
355                            "[ipc] conn_id={} lagged event bus, missed {} events",
356                            conn_id, n
357                        ));
358                    }
359                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
360                }
361            }
362        })
363    };
364
365    loop {
366        let line = match lines.next_line().await {
367            Ok(Some(l)) => l,
368            Ok(None) => {
369                crate::log(&format!(
370                    "[ipc] connection conn_id={} closed (kind={:?}, pid={:?})",
371                    conn_id, registered_kind, registered_pid
372                ));
373                // CPD-2 — clear HostPipe writer if this was the host
374                // connection so subsequent saga commands buffer
375                // (up to 64) until the host reconnects (or fail at
376                // the 30s timeout). Idempotent if not the host.
377                if matches!(registered_kind, Some(ClientKind::Host)) {
378                    ctx.host_pipe.clear_writer().await;
379                }
380                // Phase E.1b — synthetic Goodbye on ungraceful
381                // disconnect so the reducer marks the PID Exited;
382                // otherwise reconnect-from-same-PID hits
383                // AlreadyRegistered. (codex P1 #610.)
384                dispatch_synthetic_goodbye(&ctx, conn_id, registered_pid).await;
385                fanout_handle.abort();
386                return;
387            }
388            Err(e) => {
389                crate::log(&format!(
390                    "[ipc] read error conn_id={}: {}",
391                    conn_id, e
392                ));
393                if matches!(registered_kind, Some(ClientKind::Host)) {
394                    ctx.host_pipe.clear_writer().await;
395                }
396                dispatch_synthetic_goodbye(&ctx, conn_id, registered_pid).await;
397                fanout_handle.abort();
398                return;
399            }
400        };
401
402        if line.trim().is_empty() {
403            continue;
404        }
405
406        let cmd = match serde_json::from_str::<Command>(&line) {
407            Ok(c) => c,
408            Err(e) => {
409                // Phase E.1b — parse errors are connection-private
410                // (sent only to the offender, not broadcast, not
411                // appended to the event log). Don't bump the global
412                // event_version: other subscribers would see version
413                // gaps and treat them as missed events. Use 0 as a
414                // sentinel for "not part of the ordered stream."
415                // (codex P2 #610.)
416                let _ = send_event_shared(
417                    &writer,
418                    Event::Error {
419                        code: ErrorCode::InvalidCommand,
420                        message: format!("parse failed: {}", e),
421                        fatal: false,
422                        version: 0,
423                    },
424                )
425                .await;
426                continue;
427            }
428        };
429
430        // Per-connection invariants: enforce here so reducer doesn't
431        // need to know about connection identity. Honor the `fatal`
432        // bit — Ping-before-Register is non-fatal (clients can
433        // recover by sending Register next), Goodbye-before-Register
434        // is fatal (can't recover from a closed-by-them connection).
435        // (reagent P1 + codex P1 PR #574 round-1.)
436        if let Some(reply) = enforce_register_first(&cmd, &registered_kind).await {
437            let close = matches!(&reply, Event::Error { fatal: true, .. });
438            let _ = send_event_shared(&writer, reply).await;
439            if close {
440                fanout_handle.abort();
441                return;
442            }
443            continue;
444        }
445        if let Command::Register { .. } = &cmd {
446            if registered_kind.is_some() {
447                // Phase E.1b — connection-private error; same
448                // version-sentinel rationale as parse-error path
449                // above. (codex P2 #610.)
450                let _ = send_event_shared(
451                    &writer,
452                    Event::Error {
453                        code: ErrorCode::AlreadyRegistered,
454                        message: "Register sent twice on the same connection".into(),
455                        fatal: false,
456                        version: 0,
457                    },
458                )
459                .await;
460                continue;
461            }
462        }
463
464        // Track local registration before dispatch so we can update
465        // our per-connection state if the reducer accepts it. The
466        // reducer's PID-uniqueness check might reject the Register;
467        // we re-check the events for that case below.
468        let pre_register = if let Command::Register { kind, pid, .. } = &cmd {
469            Some((*kind, *pid))
470        } else {
471            None
472        };
473
474        // Phase D.3 — `GetEvents { since }` is handled here, not in
475        // the reducer. The reducer is pure (no I/O); querying the
476        // event log is a non-mutating read against the in-memory
477        // ring + disk fallback.
478        //
479        // Phase E.1b — the reply (`Event::EventList`) is sent
480        // DIRECTLY to the requesting connection, NOT broadcast on
481        // the shared bus. EventList is request/response, not a
482        // state transition; broadcasting it would force every
483        // subscriber to process foreign replay payloads
484        // (potentially treating them as their own catch-up data,
485        // duplicating state application). (codex P1 #610.)
486        if let Command::GetEvents { since } = &cmd {
487            // Phase E.1b — read the current version WITHOUT bumping
488            // (codex P2 #610). EventList is connection-private; the
489            // version it carries is the "as-of" point for the
490            // requester's next resync, not a new state-transition
491            // marker. Bumping would create a global gap that other
492            // subscribers see as missed events.
493            let v = {
494                let state = ctx.state.lock().await;
495                state.event_version
496            };
497            let replay = ctx.event_log.events_since(*since);
498            // Don't log truncation as an error — it's a valid
499            // "subscriber missed events that have already been
500            // evicted" signal; the subscriber's resync logic
501            // handles it (re-fetch a fresh snapshot).
502            if ctx.event_log.replay_truncated(*since) {
503                crate::log(&format!(
504                    "[ipc] conn_id={} GetEvents since={} truncated (oldest retained event > since+1)",
505                    conn_id, since
506                ));
507            }
508            let _ = send_event_shared(
509                &writer,
510                Event::EventList {
511                    events: replay,
512                    version: v,
513                },
514            )
515            .await;
516            continue;
517        }
518
519        // Startup-stage telemetry — forwarded directly into the
520        // launcher's StartupEventSink (same short-circuit-before-the-
521        // reducer pattern as GetEvents above). Not mirrored State, not
522        // broadcast: this is a side-channel purely for the splash
523        // panel, ephemeral for the life of the process. No-op if
524        // startup_sink is None (splash disabled, or a code path that
525        // doesn't wire a sink through).
526        match &cmd {
527            Command::ReportStartupStageBegin { stage, label } => {
528                if let Some(sink) = &ctx.startup_sink {
529                    // StartupEvent::StageBegin's fields are `&'static str` —
530                    // every existing caller passes a compile-time constant.
531                    // The host's stage/label strings are runtime JSON
532                    // values, so leak them to get a 'static lifetime rather
533                    // than widening the shared StartupEvent type (which
534                    // Windows' splash.rs and Linux's splash_linux/ also
535                    // consume, and neither can be built/verified here).
536                    // Bounded: a handful of small strings per process
537                    // lifetime (one launch's worth of stages), not a loop.
538                    let stage: &'static str = Box::leak(stage.clone().into_boxed_str());
539                    let label: &'static str = Box::leak(label.clone().into_boxed_str());
540                    sink.stage_begin(stage, label);
541                }
542                continue;
543            }
544            Command::ReportStartupStageEnd { stage, duration_ms, status, detail } => {
545                if let Some(sink) = &ctx.startup_sink {
546                    let stage: &'static str = Box::leak(stage.clone().into_boxed_str());
547                    let status = match status.as_str() {
548                        "warn" => crate::startup_events::StartupStatus::Warn,
549                        "error" => crate::startup_events::StartupStatus::Error,
550                        _ => crate::startup_events::StartupStatus::Ok,
551                    };
552                    sink.stage_end(stage, *duration_ms, status, detail.clone());
553                }
554                continue;
555            }
556            _ => {}
557        }
558
559        // Dispatch through the reducer. Mutex held briefly — compute
560        // the timestamp BEFORE acquiring so syscalls + string
561        // formatting don't show up in lock-hold time.
562        let now_rfc3339 = chrono::Utc::now().to_rfc3339();
563        // Phase B.9.1 — monotonic ms since launcher start. Used by
564        // the WRR arm for per-window observability ages.
565        // `LAUNCHER_START_INSTANT` is a once-init `Instant`; the
566        // first request seeds it, subsequent ones read its delta.
567        let now_ms = launcher_start_ms();
568        // SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 1 — record UI-liveness
569        // telemetry at the transport layer, before the reducer (whose arm
570        // for this variant is a deliberate no-op: this is host-thread
571        // telemetry, not domain state). Latency logged only for a
572        // nonce-matched reply; any reply updates `last_alive`.
573        if let Command::ReportUiThreadAlive { nonce } = &cmd {
574            match crate::ui_liveness::record_alive(*nonce) {
575                Some(rtt) => crate::logging::log(&format!(
576                    "[ui-liveness] UI thread alive — probe nonce={} rtt={}ms",
577                    nonce,
578                    rtt.as_millis()
579                )),
580                None => crate::logging::log(&format!(
581                    "[ui-liveness] UI thread alive — unmatched/late reply nonce={}",
582                    nonce
583                )),
584            }
585        }
586        let events = {
587            let mut state = ctx.state.lock().await;
588            let rctx = reducer::Ctx {
589                now_rfc3339,
590                conn_id,
591                registered_pid,
592                now_ms,
593            };
594            reducer::update(&mut state, cmd.clone(), &rctx)
595        };
596
597        // SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 2 — arm/disarm the J0
598        // teardown state machine from the VALIDATED reducer output (same
599        // layering as the Phase-1 `ReportUiThreadAlive` intercept above:
600        // process-supervision state about the host, not domain state the
601        // reducer owns — hooking the emitted events keeps the reducer pure
602        // while still only reacting to reports the reducer accepted).
603        //
604        // Arm: `PoolDrained` (host's own "last user window closed, drain
605        // begins" report) or `OrphanInstance` drift (mirror saw the last
606        // user window close with the host still Running). Disarm: any
607        // `WindowOpened` (covers crash-reproject re-opening windows within
608        // the grace, and ordinary re-opens). The supervisor additionally
609        // disarms on every host exit (the crash-restart-gap guard).
610        for ev in &events {
611            match ev {
612                Event::PoolDrained { label, .. } => {
613                    if crate::teardown_backstop::arm() {
614                        crate::logging::log(&format!(
615                            "[teardown-backstop] ARMED (PoolDrained, label={}) — grace {}s, then ≥{} unanswered probes ⇒ teardown",
616                            label,
617                            crate::teardown_backstop::TEARDOWN_GRACE.as_secs(),
618                            crate::teardown_backstop::TEARDOWN_REQUIRED_MISSES,
619                        ));
620                    }
621                }
622                Event::HwndDriftDetected {
623                    kind: agentmux_common::ipc::HwndDriftKind::OrphanInstance,
624                    ..
625                } => {
626                    if crate::teardown_backstop::arm() {
627                        crate::logging::log(
628                            "[teardown-backstop] ARMED (OrphanInstance drift — last user window closed, host still alive)",
629                        );
630                    }
631                }
632                Event::WindowOpened { label, .. } => {
633                    if crate::teardown_backstop::disarm() {
634                        crate::logging::log(&format!(
635                            "[teardown-backstop] disarmed — user window opened (label={})",
636                            label
637                        ));
638                    }
639                }
640                _ => {}
641            }
642        }
643
644        // If the reducer accepted the Register (no AlreadyRegistered
645        // error in the output), commit the local connection state.
646        if let Some((kind, pid)) = pre_register {
647            let rejected = events
648                .iter()
649                .any(|e| matches!(e, Event::Error { code: ErrorCode::AlreadyRegistered, .. }));
650            if !rejected {
651                registered_kind = Some(kind);
652                registered_pid = Some(pid);
653                // CPD-2 — host registration: install this connection's
654                // writer into the launcher's HostPipe wrapper and flip
655                // the fanout task to route through it. Subsequent
656                // events for this connection traverse
657                // `HostPipe::send_event` (HostFrame::Event envelope +
658                // pending-buffer-on-disconnect semantics) instead of
659                // the legacy direct write. Drains any frames buffered
660                // since the prior host disconnect (FIFO).
661                if kind == ClientKind::Host {
662                    // Install the host's writer half into HostPipe so
663                    // saga-issued commands (CPD-3+) can be transmitted
664                    // and so any pending Command frames buffered during
665                    // a prior host disconnect drain in FIFO order.
666                    // Returns a session_id we don't need today (events
667                    // bypass HostPipe — see fanout task above), but the
668                    // counter is in place for future code that does.
669                    let session = ctx
670                        .host_pipe
671                        .set_writer(std::sync::Arc::clone(&writer))
672                        .await;
673                    crate::log(&format!(
674                        "[ipc] conn_id={} host registered (session={}) — HostPipe writer installed",
675                        conn_id, session
676                    ));
677                }
678            }
679        }
680
681        // Phase B.8 — publish reducer events on the broadcast bus
682        // instead of writing them directly to this connection. The
683        // per-connection fanout task (spawned above) subscribes and
684        // writes them to its own pipe — including this connection,
685        // which sees its own events back. Drift events still log at
686        // the launcher level so operators see them regardless of
687        // subscriber wiring. (codex P1 PR #605.)
688        let goodbye = matches!(cmd, Command::Goodbye);
689        for event in events {
690            if let Event::DriftDetected {
691                kind,
692                host_count,
693                mirror_count,
694                ..
695            } = &event
696            {
697                crate::log(&format!(
698                    "[ipc] DRIFT {:?}: host={} mirror={} (conn_id={})",
699                    kind, host_count, mirror_count, conn_id
700                ));
701            }
702            if let Event::HwndDriftDetected {
703                kind,
704                label,
705                hwnd,
706                detail,
707                severity,
708                ..
709            } = &event
710            {
711                crate::log(&format!(
712                    "[ipc] WRR-DRIFT [{:?}] {:?} label={:?} hwnd={:?}: {} (conn_id={})",
713                    severity, kind, label, hwnd, detail, conn_id
714                ));
715            }
716            // Phase E.1a (codex P2 #608) — patch sentinel identity
717            // BEFORE appending to the log. Reducer emits
718            // `Event::Registered { launcher_pid: 0, launcher_version: "" }`
719            // because it doesn't know the launcher's identity; the
720            // server fills it in. Pre-fix, the patch happened only at
721            // per-connection write, so `GetEvents` replay returned
722            // stored sentinels, inconsistent with live broadcast.
723            let event = patch_launcher_identity(event, &ctx);
724
725            // Phase D.2 — append to the in-memory ring BEFORE
726            // broadcasting so a connection's GetEvents query that
727            // races a just-published event sees consistent
728            // results. Disk persistence (separate task) is best-
729            // effort and may lag. Snapshot / EventList variants
730            // are NOT appended — they're meta-events about the
731            // event stream itself; including them would create
732            // recursive replay (an EventList containing EventLists
733            // is meaningless). Errors are also skipped — they're
734            // per-client diagnostics, not state transitions.
735            if !matches!(event, Event::Snapshot { .. } | Event::EventList { .. } | Event::Error { .. }) {
736                ctx.event_log.append(event.clone());
737            }
738            // Send may fail when no receivers exist (e.g., during
739            // shutdown). That's fine — events are advisory in that
740            // window and the per-connection fanout tasks own retry
741            // semantics via subscribe().
742            let _ = ctx.events_tx.send(event);
743        }
744        if goodbye {
745            crate::log(&format!(
746                "[ipc] goodbye from conn_id={} kind={:?} pid={:?}",
747                conn_id, registered_kind, registered_pid
748            ));
749            // CPD-2 — clear HostPipe writer on graceful goodbye too
750            // so a host that re-registers via a fresh connection can
751            // re-install cleanly. Idempotent if not host.
752            if matches!(registered_kind, Some(ClientKind::Host)) {
753                ctx.host_pipe.clear_writer().await;
754            }
755            fanout_handle.abort();
756            return;
757        }
758    }
759}
760
761/// Per-connection counter for log-correlation IDs (NOT the wire
762/// client_id — that comes from the reducer). Allocated even for
763/// pre-Register failures so log lines can be correlated.
764static NEXT_CONN_ID: std::sync::atomic::AtomicU64 =
765    std::sync::atomic::AtomicU64::new(1);
766
767/// Phase E.1b — synthetic Goodbye dispatch for ungraceful disconnects
768/// (EOF / read error before the client sent an explicit Goodbye).
769/// Without this, the reducer's process record stays Running and a
770/// reconnect from the same live PID hits AlreadyRegistered.
771/// (codex P1 #610.)
772// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
773async fn dispatch_synthetic_goodbye(
774    ctx: &Arc<ServerCtx>,
775    conn_id: u64,
776    registered_pid: Option<u32>,
777) {
778    let Some(pid) = registered_pid else {
779        return;
780    };
781    let now_rfc3339 = chrono::Utc::now().to_rfc3339();
782    let now_ms = launcher_start_ms();
783    let events = {
784        let mut state = ctx.state.lock().await;
785        let rctx = reducer::Ctx {
786            now_rfc3339,
787            conn_id,
788            registered_pid: Some(pid),
789            now_ms,
790        };
791        reducer::update(&mut state, Command::Goodbye, &rctx)
792    };
793    for event in events {
794        let event = patch_launcher_identity(event, ctx);
795        if !matches!(
796            event,
797            Event::Snapshot { .. } | Event::EventList { .. } | Event::Error { .. }
798        ) {
799            ctx.event_log.append(event.clone());
800        }
801        let _ = ctx.events_tx.send(event);
802    }
803}
804
805/// Phase B.9.1 — milliseconds since the launcher's IPC server
806/// started (first call seeds the epoch). Used as the monotonic
807/// clock for WRR observability timestamps in `reducer::Ctx::now_ms`.
808/// Distinct from `chrono::Utc::now()` because the WRR arm wants
809/// elapsed time, not wall clock — and we don't want clock-skew
810/// jitter (NTP adjustment, DST) showing up as drift.
811fn launcher_start_ms() -> u64 {
812    static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
813    let start = START.get_or_init(std::time::Instant::now);
814    start.elapsed().as_millis() as u64
815}
816
817/// Enforce the "first message must be Register" invariant. Returns
818/// `Some(Event::Error)` if the command violates the contract; the
819/// caller sends it and closes the connection.
820// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
821async fn enforce_register_first(
822    cmd: &Command,
823    registered_kind: &Option<ClientKind>,
824) -> Option<Event> {
825    // F.7 cleanup audit: prior signature accepted `ctx: &Arc<ServerCtx>`
826    // for symmetry with neighboring helpers, but no body site read it.
827    // Dropped to silence the unused-variable warning without an allow.
828    if registered_kind.is_some() {
829        return None;
830    }
831    let (msg, fatal) = match cmd {
832        Command::Register { .. } => return None,
833        Command::Ping { .. } => ("Ping before Register".to_string(), false),
834        // Liveness telemetry arriving pre-Register is harmless timing skew
835        // (host answered a probe before its Register round-trip completed);
836        // non-fatal, same treatment as Ping.
837        Command::ReportUiThreadAlive { .. } => {
838            ("ReportUiThreadAlive before Register".to_string(), false)
839        }
840        Command::ProbeUiThread { .. } => {
841            ("ProbeUiThread before Register (wrong direction)".to_string(), false)
842        }
843        Command::NotifySrvLatency { .. } => {
844            ("NotifySrvLatency before Register (wrong direction)".to_string(), false)
845        }
846        Command::Goodbye => ("Goodbye before Register".to_string(), true),
847        Command::ReportWindowOpened { .. } => {
848            ("ReportWindowOpened before Register".to_string(), true)
849        }
850        Command::ReportBackgroundServiceEnabled { .. } => {
851            ("ReportBackgroundServiceEnabled before Register".to_string(), true)
852        }
853        Command::ReportWindowClosed { .. } => {
854            ("ReportWindowClosed before Register".to_string(), true)
855        }
856        Command::ReportPoolWindowAdded { .. } => {
857            ("ReportPoolWindowAdded before Register".to_string(), true)
858        }
859        Command::ReportPoolWindowRemoved { .. } => {
860            ("ReportPoolWindowRemoved before Register".to_string(), true)
861        }
862        // Phase F.5 — host-only report; gate matches the other pool-
863        // mirror reports above.
864        Command::ReportPoolWindowPromoted { .. } => {
865            ("ReportPoolWindowPromoted before Register".to_string(), true)
866        }
867        // Phase F.5 — `SpawnPoolWindow` is a launcher→host direction
868        // command. Sent to the launcher pipe before Register: same
869        // soft-error treatment as the srv-pipe misroutes (the client
870        // can recover by registering or routing correctly).
871        Command::SpawnPoolWindow { .. } => {
872            ("SpawnPoolWindow is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
873        }
874        // Phase F.6 — host-only reports; same fatal-before-Register
875        // treatment as the other window-mirror reports above.
876        Command::ReportPanesReaped { .. } => {
877            ("ReportPanesReaped before Register".to_string(), true)
878        }
879        Command::ReportPoolDrainDecision { .. } => {
880            ("ReportPoolDrainDecision before Register".to_string(), true)
881        }
882        // Phase F.6 — launcher→host direction commands. Same
883        // soft-error treatment as `SpawnPoolWindow` above.
884        Command::ReapPanes { .. } => {
885            ("ReapPanes is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
886        }
887        Command::DrainPoolIfLast { .. } => {
888            ("DrainPoolIfLast is a launcher→host command; sent to launcher pipe by mistake".to_string(), false)
889        }
890        // Phase CPD-1 — host-only saga-action-failed report. Same
891        // fatal-before-Register treatment as the other Report*
892        // commands above.
893        Command::ReportSagaActionFailed { .. } => {
894            ("ReportSagaActionFailed before Register".to_string(), true)
895        }
896        // Startup-stage telemetry — host-only reports, same
897        // fatal-before-Register treatment as the other Report*
898        // commands. Unreachable in practice: connect_to_launcher's
899        // handshake always sends Register first.
900        Command::ReportStartupStageBegin { .. } => {
901            ("ReportStartupStageBegin before Register".to_string(), true)
902        }
903        Command::ReportStartupStageEnd { .. } => {
904            ("ReportStartupStageEnd before Register".to_string(), true)
905        }
906        Command::ReportHostCounts { .. } => {
907            ("ReportHostCounts before Register".to_string(), true)
908        }
909        Command::ReportHostPoolCount { .. } => {
910            ("ReportHostPoolCount before Register".to_string(), true)
911        }
912        Command::ReportBackendWindowIdRegistered { .. } => {
913            (
914                "ReportBackendWindowIdRegistered before Register".to_string(),
915                true,
916            )
917        }
918        Command::ReportBackendWindowIdUnregistered { .. } => {
919            (
920                "ReportBackendWindowIdUnregistered before Register".to_string(),
921                true,
922            )
923        }
924        // Phase B.9.1 (WRR) — host-only Win32 reality reports.
925        Command::ReportHwndOpened { .. } => {
926            ("ReportHwndOpened before Register".to_string(), true)
927        }
928        Command::ReportHwndDestroyed { .. } => {
929            ("ReportHwndDestroyed before Register".to_string(), true)
930        }
931        Command::ReportHwndVisibilityChanged { .. } => {
932            (
933                "ReportHwndVisibilityChanged before Register".to_string(),
934                true,
935            )
936        }
937        Command::ReportHwndForegroundChanged { .. } => {
938            (
939                "ReportHwndForegroundChanged before Register".to_string(),
940                true,
941            )
942        }
943        Command::ReportHwndIconicChanged { .. } => {
944            ("ReportHwndIconicChanged before Register".to_string(), true)
945        }
946        Command::ReportHwndPositionChanged { .. } => {
947            (
948                "ReportHwndPositionChanged before Register".to_string(),
949                true,
950            )
951        }
952        Command::ReportMonitorTopologyChanged { .. } => {
953            (
954                "ReportMonitorTopologyChanged before Register".to_string(),
955                true,
956            )
957        }
958        // Phase D.1 — GetSnapshot before Register is non-fatal: any
959        // sane diagnostic client can fix it by retrying with Register
960        // first. (Same Ping-before-Register precedent — soft error.)
961        Command::GetSnapshot => ("GetSnapshot before Register".to_string(), false),
962        // Phase D.3 — GetEvents before Register: same non-fatal
963        // semantics as GetSnapshot.
964        Command::GetEvents { .. } => ("GetEvents before Register".to_string(), false),
965        // Phase E.1b — GetSrvSnapshot is a srv-pipe command; if a
966        // client sends it to the launcher pipe by mistake, soft
967        // error: not the launcher's command.
968        Command::GetSrvSnapshot => (
969            "GetSrvSnapshot is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
970            false,
971        ),
972        // Phase E.2 — srv-pipe commands sent to launcher pipe by
973        // mistake. Soft error — clients can recover by routing to
974        // the right pipe.
975        Command::CreateWorkspace { .. } => (
976            "CreateWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
977            false,
978        ),
979        Command::DeleteWorkspace { .. } => (
980            "DeleteWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
981            false,
982        ),
983        // Phase E.2b — Tab arms are also srv-pipe commands.
984        Command::CreateTab { .. } => (
985            "CreateTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
986            false,
987        ),
988        Command::DeleteTab { .. } => (
989            "DeleteTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
990            false,
991        ),
992        Command::SetActiveTab { .. } => (
993            "SetActiveTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
994            false,
995        ),
996        Command::ReorderTab { .. } => (
997            "ReorderTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
998            false,
999        ),
1000        // Phase E.5 — window↔workspace mapping commands are srv-pipe.
1001        Command::CreateWindow { .. } => (
1002            "CreateWindow is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1003            false,
1004        ),
1005        Command::CloseWindowInternal { .. } => (
1006            "CloseWindowInternal is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1007            false,
1008        ),
1009        Command::SwitchWorkspace { .. } => (
1010            "SwitchWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1011            false,
1012        ),
1013        // Phase E.5.3 — atomic single-step domain commands are srv-pipe.
1014        Command::ReorderTabsBulk { .. } => (
1015            "ReorderTabsBulk is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1016            false,
1017        ),
1018        Command::RenameWorkspace { .. } => (
1019            "RenameWorkspace is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1020            false,
1021        ),
1022        Command::RenameTab { .. } => (
1023            "RenameTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1024            false,
1025        ),
1026        Command::UpdateWorkspaceMeta { .. } => (
1027            "UpdateWorkspaceMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1028            false,
1029        ),
1030        Command::UpdateTabMeta { .. } => (
1031            "UpdateTabMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1032            false,
1033        ),
1034        Command::UpdateBlockMeta { .. } => (
1035            "UpdateBlockMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1036            false,
1037        ),
1038        // Phase E.3 — Block arms are also srv-pipe commands.
1039        Command::CreateBlock { .. } => (
1040            "CreateBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1041            false,
1042        ),
1043        Command::DeleteBlock { .. } => (
1044            "DeleteBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1045            false,
1046        ),
1047        // Phase E.5.5 — saga-driven move commands are srv-pipe.
1048        Command::MoveTab { .. } => (
1049            "MoveTab is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1050            false,
1051        ),
1052        Command::MoveBlock { .. } => (
1053            "MoveBlock is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1054            false,
1055        ),
1056        // Phase E.4 (Option A) — layout focused/magnified setters are srv-pipe.
1057        Command::SetFocusedNode { .. } => (
1058            "SetFocusedNode is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1059            false,
1060        ),
1061        Command::SetMagnifiedNode { .. } => (
1062            "SetMagnifiedNode is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1063            false,
1064        ),
1065        // Phase E.4.B — all layout-tree commands are srv-pipe only.
1066        Command::LayoutInsertNode { .. }
1067        | Command::LayoutInsertNodeAtIndex { .. }
1068        | Command::LayoutDeleteNode { .. }
1069        | Command::LayoutDeleteNodeByBlock { .. }
1070        | Command::LayoutStackPush { .. }
1071        | Command::LayoutStackActivate { .. }
1072        | Command::CreateBlockInStack { .. }
1073        | Command::LayoutStackMove { .. }
1074        | Command::LayoutQueueBackendActions { .. }
1075        | Command::LayoutMoveNode { .. }
1076        | Command::LayoutSwapNodes { .. }
1077        | Command::LayoutResizeNodes { .. }
1078        | Command::LayoutReplaceNode { .. }
1079        | Command::LayoutSplitHorizontal { .. }
1080        | Command::LayoutSplitVertical { .. }
1081        | Command::LayoutClear { .. }
1082        | Command::LayoutSetTree { .. } => (
1083            "Layout command is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1084            false,
1085        ),
1086        Command::UpdateWindowMeta { .. } => (
1087            "UpdateWindowMeta is a srv-pipe command; sent to launcher pipe by mistake".to_string(),
1088            false,
1089        ),
1090    };
1091    // Phase E.1b — connection-private error; sentinel version=0
1092    // (codex P2 #610). See parse-error path for rationale.
1093    let v = 0;
1094    Some(Event::Error {
1095        code: ErrorCode::NotRegistered,
1096        message: msg,
1097        fatal,
1098        version: v,
1099    })
1100}
1101
1102/// Patch `launcher_pid` + `launcher_version` into `Event::Registered`.
1103/// The reducer leaves these as sentinels (it can't read env without
1104/// breaking determinism) — the server fills them in here, just
1105/// before serializing to the wire.
1106fn patch_launcher_identity(event: Event, ctx: &Arc<ServerCtx>) -> Event {
1107    if let Event::Registered {
1108        client_id, version, ..
1109    } = event
1110    {
1111        Event::Registered {
1112            client_id,
1113            launcher_pid: ctx.launcher_pid,
1114            launcher_version: ctx.launcher_version.clone(),
1115            version,
1116        }
1117    } else {
1118        event
1119    }
1120}
1121
1122/// Serialize an Event as one JSON line + `\n` and write atomically
1123/// (under the per-connection writer mutex). Returns Err if the
1124/// connection died mid-write.
1125///
1126/// CPD-2 — generalized from `Arc<Mutex<WriteHalf<NamedPipeServer>>>`
1127/// to `crate::host_pipe::SharedWriter` so the launcher's IPC server
1128/// + the host_pipe wrapper share one writer-handle representation.
1129/// Wire shape is unchanged: a non-host client sees a raw `Event` JSON
1130/// line (legacy schema), a host client sees a `HostFrame::Event`
1131/// envelope only when the frame goes through `HostPipe::send_event`.
1132/// Connection-private error replies in this file still emit raw
1133/// `Event` JSON to preserve backwards compat with existing host
1134/// versions that haven't adopted the envelope yet — CPD-1 lands the
1135/// host-side schema migration.
1136// (gate removed — platform-neutral body, accessible from cfg(unix) too — A1.1)
1137async fn send_event_shared(
1138    writer: &crate::host_pipe::SharedWriter,
1139    event: Event,
1140) -> std::io::Result<()> {
1141    let mut buf = serde_json::to_vec(&event).map_err(|e| {
1142        std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1143    })?;
1144    buf.push(b'\n');
1145    let mut w = writer.lock().await;
1146    w.write_all(&buf).await?;
1147    w.flush().await?;
1148    Ok(())
1149}