Skip to main content

agentmux_launcher/host_pipe/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// CPD-2 — launcher → host pipe wrapper.
5//
6// This module wraps the writer half of the launcher's named-pipe
7// connection to the host process and exposes a stable interface for
8// the rest of the launcher to push **Commands** (saga-issued, future
9// CPD-3 wiring) and **Events** (existing reducer fanout) down to the
10// host as `HostFrame` envelopes.
11//
12// CPD-2 is **infrastructure-only**: the saga coordinator's
13// `apply_action` for `IssueCmd::Host` STAYS log-only. CPD-3 replaces
14// the log with a `HostPipe::send_command` call.
15//
16// See `docs/specs/SPEC_CROSS_PROCESS_DISPATCH_2026-05-01.md` §3.5 +
17// §3.9 for the design + spec acceptance criteria.
18//
19// ## Lifecycle / connection model
20//
21// The IPC server's per-connection fanout task hands the host's writer
22// half to `HostPipe::set_writer` once the connecting client has
23// registered as `ClientKind::Host`. On disconnect (read EOF or write
24// failure), the same task calls `HostPipe::clear_writer`, which arms
25// the disconnect timer (30s — see §3.9). Reconnect re-arms via
26// `set_writer` again.
27//
28// While disconnected, `send_command` BUFFERS into a bounded
29// `pending_buffer` (cap 64 frames). On reconnect, frames drain in
30// FIFO order. On overflow OR after 30s of disconnection, the
31// pending buffer is flushed and **`Event::SagaActionFailed` is
32// emitted on the broadcast bus for every Command frame whose saga
33// got dropped.** Event frames are silently dropped on overflow —
34// they're already broadcast to other clients via the per-connection
35// fanout, so the host missing one isn't a saga-correctness issue.
36//
37// Until CPD-1 (schema PR) lands, `Event::SagaActionFailed` does not
38// exist on the agentmux-common Event enum. To avoid a hard dep on
39// CPD-1's merge order, this module emits failures via the existing
40// `Event::SagaFailed { saga_id, reason, version }` event with a
41// `reason` prefix of `"host pipe ..."` so a downstream observer can
42// filter. CPD-3 + CPD-1 will tighten the variant surface.
43//
44// ## HostFrame envelope
45//
46// Per spec §3.1, the launcher → host wire eventually carries a
47// tagged union of Event vs Command. Until CPD-1 lands the envelope
48// in agentmux-common, we declare `HostFrame` here locally. Both
49// CPD-1 and CPD-2 can land independently; whichever lands first
50// owns the envelope, the second PR migrates over.
51
52use std::collections::VecDeque;
53use std::io;
54use std::sync::Arc;
55use std::time::{Duration, Instant};
56
57use agentmux_common::ipc::{Command, Event};
58pub use agentmux_common::ipc::HostFrame;
59use tokio::io::{AsyncWrite, AsyncWriteExt};
60use tokio::sync::Mutex;
61
62mod connection;
63#[cfg(test)]
64mod tests;
65
66/// Maximum number of frames buffered while the host is disconnected.
67/// On overflow, the oldest frame is dropped; if it was a Command,
68/// `Event::SagaFailed` is emitted for the saga whose dispatch was
69/// lost. See spec §3.9.
70pub const PENDING_BUFFER_CAP: usize = 64;
71
72/// Maximum disconnect duration before the pending buffer is flushed.
73/// After this, every buffered Command's saga is failed with
74/// `"host unreachable"`. See spec §3.9.
75pub const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(30);
76
77/// Reasons `send_command` / `send_event` can fail synchronously.
78///
79/// `HostNotConnected` is reserved for cases where the caller wants to
80/// **fail-fast** instead of buffering (CPD-3 will choose whichever
81/// fits per command kind). The default `send_command` code path
82/// buffers; see `try_send_command_no_buffer` for the fail-fast variant.
83#[derive(Debug)]
84pub enum HostPipeError {
85    /// No host is currently connected and the caller asked to fail
86    /// rather than buffer (`try_send_command_no_buffer`; first caller is
87    /// the UI-liveness prober, SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 1).
88    HostNotConnected,
89    /// Underlying write to the pipe failed.
90    WriteFailed(io::Error),
91    /// Frame failed to serialize. Should be impossible for well-
92    /// formed Command / Event variants but kept as a result variant
93    /// for defense-in-depth.
94    #[allow(dead_code)] // defense-in-depth; serde_json::to_vec on owned types should not fail
95    Serialize(serde_json::Error),
96    /// Pending buffer is full and we couldn't drop the oldest entry
97    /// to make room (e.g. caller asked for no-overflow semantics).
98    /// Default `send_command` does drop-oldest + emit-failed, so
99    /// callers won't see this from the public API today.
100    #[allow(dead_code)] // reserved for stricter callers
101    BufferOverflow,
102}
103
104impl std::fmt::Display for HostPipeError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            HostPipeError::HostNotConnected => write!(f, "host not connected"),
108            HostPipeError::WriteFailed(e) => write!(f, "write to host pipe failed: {}", e),
109            HostPipeError::Serialize(e) => write!(f, "frame serialize failed: {}", e),
110            HostPipeError::BufferOverflow => write!(f, "host pipe pending buffer overflow"),
111        }
112    }
113}
114
115impl std::error::Error for HostPipeError {}
116
117/// Trait-object writer reference the HostPipe holds when connected.
118///
119/// `Arc<Mutex<Box<dyn AsyncWrite + Unpin + Send>>>` so the same
120/// physical write half is reachable both by HostPipe (event fanout +
121/// saga command dispatch) AND by the IPC server's per-connection
122/// handler (connection-private error replies — Event::Error on parse
123/// failures + register-first violations). The mutex serializes
124/// writes so frames can't interleave on the wire.
125///
126/// On set, HostPipe stores a clone of the same Arc the per-connection
127/// handler uses for its main loop. On clear, HostPipe drops its
128/// clone; the handler still has its own Arc so error replies (if
129/// any) keep working through the disconnect path.
130pub type SharedWriter = Arc<Mutex<BoxedWriter>>;
131
132/// Test-only convenience: own a Box directly. Tests construct one
133/// via `make_shared_writer(Box::new(duplex_a))`.
134pub type BoxedWriter = Box<dyn AsyncWrite + Unpin + Send>;
135
136/// Wrap a boxed writer into a SharedWriter. Used by tests +
137/// `ipc::server` to coerce a concrete WriteHalf into the trait
138/// object the HostPipe stores.
139///
140/// Implementation note: we go through an intermediate
141/// `Arc<Mutex<Box<dyn AsyncWrite + ...>>>` and then move the inner
142/// Box out at write time. This avoids relying on
143/// `Arc<Mutex<Concrete>>`-to-`Arc<Mutex<dyn>>` unsizing coercion,
144/// which is unstable across MSRVs for `tokio::sync::Mutex`.
145pub fn make_shared_writer(writer: BoxedWriter) -> SharedWriter {
146    Arc::new(Mutex::new(writer))
147}
148
149/// One frame waiting to be written to the host. Tracks the saga_id
150/// when known so we can emit a meaningful `SagaFailed` if the frame
151/// is dropped on overflow / timeout.
152#[derive(Debug)]
153struct PendingFrame {
154    frame: HostFrame,
155    /// `Some(id)` for Command frames whose Command variant carries
156    /// a saga_id. `None` for Event frames (the host is not the only
157    /// subscriber for events — they also flow to the renderer / Tool
158    /// clients via the broadcast bus, so a missed event-frame is not
159    /// a saga-level failure) and for Command frames whose variant
160    /// has no saga_id field yet (the schema additions land in CPD-1).
161    saga_id: Option<u64>,
162}
163
164struct HostPipeInner {
165    writer: Option<SharedWriter>,
166    pending_buffer: VecDeque<PendingFrame>,
167    /// Set when the writer transitions from Some -> None. Cleared on
168    /// reconnect. Used to enforce the 30s disconnect timeout.
169    host_disconnected_at: Option<Instant>,
170    /// Monotonic generation incremented on every `set_writer` call.
171    /// Prevents a stale fanout (from an old host connection that
172    /// hasn't fully torn down yet) from writing into a freshly-
173    /// registered host's writer. (codex P1 PR #642 round 2.)
174    host_session_id: u64,
175}
176
177impl HostPipeInner {
178    fn new() -> Self {
179        Self {
180            writer: None,
181            pending_buffer: VecDeque::with_capacity(PENDING_BUFFER_CAP),
182            host_disconnected_at: None,
183            host_session_id: 0,
184        }
185    }
186}
187
188/// Public wrapper around the launcher → host pipe.
189///
190/// Cheap to clone (one `Arc`); shared by the IPC server's per-
191/// connection task (set/clear writer) and the saga coordinator
192/// (send_command, CPD-3) and the per-host fanout task (send_event,
193/// refactored in this PR).
194///
195/// Manual `Debug` (rather than derive) so we don't have to require
196/// `Debug` on the boxed writer trait object.
197#[derive(Clone)]
198pub struct HostPipe {
199    inner: Arc<Mutex<HostPipeInner>>,
200    /// Broadcast bus reference — used to emit `Event::SagaFailed`
201    /// when a buffered Command is dropped (overflow or 30s timeout).
202    events_tx: tokio::sync::broadcast::Sender<Event>,
203    /// Reference to the launcher's reducer state for `bump_version`
204    /// when emitting saga lifecycle events. Mirrors the saga
205    /// coordinator's pattern so emitted events get a fresh,
206    /// monotonic global version.
207    state: Arc<Mutex<crate::state::State>>,
208}
209
210impl std::fmt::Debug for HostPipe {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.debug_struct("HostPipe").finish_non_exhaustive()
213    }
214}
215
216impl HostPipe {
217    pub fn new(
218        events_tx: tokio::sync::broadcast::Sender<Event>,
219        state: Arc<Mutex<crate::state::State>>,
220    ) -> Self {
221        Self {
222            inner: Arc::new(Mutex::new(HostPipeInner::new())),
223            events_tx,
224            state,
225        }
226    }
227
228    /// Hand the host's writer half to the pipe. Called by the IPC
229    /// server's per-connection task once the connecting client
230    /// registers as `ClientKind::Host`. Drains any pending frames
231    /// in FIFO order before returning.
232    ///
233    /// If a writer is already set (a prior host registered and this
234    /// is a second), the prior writer is replaced. The drain logic
235    /// still runs against the new writer.
236    ///
237    /// **Locking:** the inner mutex is held across the per-frame
238    /// `write_all().await`. That's intentional — concurrent
239    /// `send_frame` calls during drain would race FIFO ordering
240    /// against the freshly-installed writer. Holding the lock keeps
241    /// the drain-then-install transition atomic from the caller's
242    /// perspective. Hold time is bounded by `pending_buffer` size
243    /// (cap 64) × per-frame write latency (microseconds on a healthy
244    /// pipe), well under the existing `state.lock()` discipline.
245    /// Returns the new `host_session_id`.
246    pub async fn set_writer(&self, writer: SharedWriter) -> u64 {
247        let mut inner = self.inner.lock().await;
248        inner.host_disconnected_at = None;
249        inner.host_session_id = inner.host_session_id.wrapping_add(1);
250        let session_id = inner.host_session_id;
251        // Drain pending frames against the new writer in FIFO order.
252        while let Some(p) = inner.pending_buffer.pop_front() {
253            let mut wlock = writer.lock().await;
254            let res = write_frame_async(&mut **wlock, &p.frame).await;
255            drop(wlock);
256            if let Err(e) = res {
257                crate::log(&format!(
258                    "[host_pipe] drain failed mid-flight: {} — re-buffering rest",
259                    e
260                ));
261                inner.pending_buffer.push_front(p);
262                inner.host_disconnected_at = Some(Instant::now());
263                return session_id;
264            }
265        }
266        inner.writer = Some(writer);
267        session_id
268    }
269
270    /// Mark the host as disconnected. Arms the 30s timer; subsequent
271    /// `send_command` calls buffer up to `PENDING_BUFFER_CAP`.
272    pub async fn clear_writer(&self) {
273        let mut inner = self.inner.lock().await;
274        if inner.writer.is_some() {
275            inner.writer = None;
276            inner.host_disconnected_at = Some(Instant::now());
277            crate::log("[host_pipe] host disconnected — buffering subsequent frames (30s budget)");
278        }
279    }
280
281    /// Remove every buffered frame whose `saga_id` matches `saga_id`.
282    /// Returns the count of removed frames.
283    ///
284    /// Called from `SagaCoordinator::claim_terminal` when a saga
285    /// terminates (Done / Failed / timeout / eviction / etc.) — the
286    /// terminal event closes the saga bracket, so any frames the
287    /// saga left in the pending buffer would, if drained on reconnect,
288    /// produce orphan side effects after the bracket is already
289    /// closed (and risk a second `SagaFailed` via the 30s expiry
290    /// path's `emit_drop_failure`). Purging here keeps the saga's
291    /// terminal slot atomic across both the bus and the wire.
292    /// (codex + reagent P1 PR #644 round 7.)
293    pub async fn cancel_saga(&self, saga_id: u64) -> usize {
294        let mut inner = self.inner.lock().await;
295        let before = inner.pending_buffer.len();
296        inner.pending_buffer.retain(|f| f.saga_id != Some(saga_id));
297        let removed = before - inner.pending_buffer.len();
298        if removed > 0 {
299            crate::log(&format!(
300                "[host_pipe] cancel_saga(saga_id={}) purged {} buffered frame(s)",
301                saga_id, removed
302            ));
303        }
304        removed
305    }
306
307    /// Drain `pending_buffer` through the given writer in FIFO order.
308    /// Used by `send_frame`'s ptr_eq-mismatch path to flush a frame
309    /// that landed in the buffer after a writer-replacement race
310    /// (codex P1 PR #642 round 4). Symmetric with `set_writer`'s
311    /// drain logic but operates on an existing writer rather than
312    /// installing a new one. On per-frame failure, re-buffers the
313    /// remaining frames at the front and arms the disconnect timer.
314    async fn drain_pending_to(&self, writer: &SharedWriter) {
315        let mut inner = self.inner.lock().await;
316        while let Some(p) = inner.pending_buffer.pop_front() {
317            let mut wlock = writer.lock().await;
318            let res = write_frame_async(&mut **wlock, &p.frame).await;
319            drop(wlock);
320            if let Err(e) = res {
321                crate::log(&format!(
322                    "[host_pipe] mid-flight drain failed: {} — re-buffering rest",
323                    e
324                ));
325                inner.pending_buffer.push_front(p);
326                // Best-effort: clear the writer slot if it's still ours
327                // (caller is expected to have already raced past clear)
328                // and arm the disconnect timer so the next reconnect
329                // resumes the drain.
330                let same_writer = inner
331                    .writer
332                    .as_ref()
333                    .is_some_and(|cur| Arc::ptr_eq(cur, writer));
334                if same_writer {
335                    inner.writer = None;
336                    inner.host_disconnected_at = Some(Instant::now());
337                }
338                return;
339            }
340        }
341    }
342
343    /// Push a Command down to the host. On disconnect, buffers up to
344    /// `PENDING_BUFFER_CAP`; on overflow or 30s timeout, drops + emits
345    /// `Event::SagaFailed` for the dropped Command's saga.
346    ///
347    /// CPD-3 — called from the saga coordinator's `apply_action` when
348    /// dispatching `IssueCmd::Host` actions.
349    pub async fn send_command(&self, cmd: &Command) -> Result<(), HostPipeError> {
350        let saga_id = saga_id_of(cmd);
351        let frame = HostFrame::Command(cmd.clone());
352        self.send_frame(frame, saga_id).await
353    }
354
355    /// Fail-fast variant of `send_command`: when no host writer is
356    /// installed, returns `HostNotConnected` immediately instead of
357    /// buffering (the variant the `HostPipeError` docs reserved for
358    /// exactly this).
359    ///
360    /// For frames where DELAYED delivery is meaningless or misleading —
361    /// the UI-liveness probe (SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 1) is
362    /// the first caller: a probe buffered while the host is down would
363    /// either expire silently in the pending buffer (probes carry no
364    /// saga_id, so the drop paths can't even report the loss) or be
365    /// delivered to a FUTURE host, and in both cases the caller's
366    /// outstanding-probe bookkeeping would age into a false "UI thread
367    /// did not pump" miss (reagent P1, PR #2106 round 2 — the routine
368    /// disconnected-send during every crash-restart gap hit this).
369    ///
370    /// Residual race, accepted and bounded: a disconnect landing between
371    /// this check and the delegated write can still buffer the frame —
372    /// that window is hair-width (same lock, adjacent awaits) versus the
373    /// routine seconds-long disconnected spans this closes, and if it ever
374    /// fires the resulting one-tick miss coincides with the pipe
375    /// supervision's own loud disconnect logs for the same instant, so
376    /// the telemetry stays attributable.
377    pub async fn try_send_command_no_buffer(
378        &self,
379        cmd: &Command,
380    ) -> Result<(), HostPipeError> {
381        {
382            let inner = self.inner.lock().await;
383            if inner.writer.is_none() {
384                return Err(HostPipeError::HostNotConnected);
385            }
386        }
387        let res = self.send_command(cmd).await;
388        if res.is_err() {
389            // reagent P1 (PR #2106 round 3): a WRITE failure on an installed
390            // writer — the routine first detection of a dead connection —
391            // takes `send_frame`'s error branch, which pushes the frame into
392            // `pending_buffer` as part of its clear-writer handling even
393            // though it returns Err. That would break this method's contract
394            // via a second path (the stale probe drains to a future host on
395            // reconnect). Enforce the contract post-hoc: scrub any probe
396            // frames the delegated send may have parked. Probe-specific by
397            // design — one outstanding probe exists at a time, so ANY
398            // buffered ProbeUiThread frame is stale; a future non-probe
399            // no-buffer caller extends this per command kind.
400            self.purge_pending_probe_frames().await;
401        }
402        res
403    }
404
405    /// Remove every buffered `ProbeUiThread` frame. See
406    /// `try_send_command_no_buffer`'s error branch for why these can appear
407    /// despite the fail-fast contract, and why any such frame is stale by
408    /// construction.
409    async fn purge_pending_probe_frames(&self) {
410        let mut inner = self.inner.lock().await;
411        inner.pending_buffer.retain(|p| {
412            !matches!(
413                p.frame,
414                HostFrame::Command(Command::ProbeUiThread { .. })
415            )
416        });
417    }
418
419    /// Send an event without a session check — only safe in tests
420    /// or single-session contexts.
421    /// Writes RAW `Event` JSON (no `HostFrame` envelope). The host's
422    /// parser (`crates/cef/src/launcher_ipc/mod.rs`) tries a `HostFrame`
423    /// first and still accepts a raw `Event` line as a legacy fallback.
424    #[cfg(test)]
425    pub async fn send_event(&self, event: &Event) -> Result<(), HostPipeError> {
426        let writer = {
427            let inner = self.inner.lock().await;
428            match inner.writer.as_ref() {
429                Some(w) => Arc::clone(w),
430                None => return Ok(()),
431            }
432        };
433        let mut bytes =
434            serde_json::to_vec(event).map_err(HostPipeError::Serialize)?;
435        bytes.push(b'\n');
436        let mut wlock = writer.lock().await;
437        wlock
438            .write_all(&bytes)
439            .await
440            .map_err(HostPipeError::WriteFailed)?;
441        wlock
442            .flush()
443            .await
444            .map_err(HostPipeError::WriteFailed)?;
445        Ok(())
446    }
447
448    async fn send_frame(
449        &self,
450        frame: HostFrame,
451        saga_id: Option<u64>,
452    ) -> Result<(), HostPipeError> {
453        // First, check the disconnect timer. If we've been disconnected
454        // > 30s, drain the pending buffer + emit failures BEFORE we
455        // queue a fresh frame on top.
456        self.expire_pending_if_timed_out().await;
457
458        // Atomic decision: under the inner lock, either pull a writer
459        // clone (then release lock and write) OR push to pending buffer
460        // (lock held to keep the buffer-vs-writer state consistent).
461        // Splitting this into two lock acquisitions creates the TOCTOU
462        // window where set_writer can land between "see None" and
463        // "push to buffer", causing frames to wedge in the buffer
464        // while the pipe is actually connected. (codex P1 + reagent P1
465        // PR #642 round 3.)
466        enum Decision {
467            Write(SharedWriter),
468            Buffered { dropped: Option<PendingFrame> },
469        }
470        let decision = {
471            let mut inner = self.inner.lock().await;
472            match inner.writer.as_ref() {
473                Some(w) => Decision::Write(Arc::clone(w)),
474                None => {
475                    let dropped = if inner.pending_buffer.len() >= PENDING_BUFFER_CAP {
476                        inner.pending_buffer.pop_front()
477                    } else {
478                        None
479                    };
480                    inner
481                        .pending_buffer
482                        .push_back(PendingFrame { frame: frame.clone(), saga_id });
483                    Decision::Buffered { dropped }
484                }
485            }
486        };
487
488        match decision {
489            Decision::Write(writer) => {
490                let mut wlock = writer.lock().await;
491                match write_frame_async(&mut **wlock, &frame).await {
492                    Ok(()) => Ok(()),
493                    Err(e) => {
494                        drop(wlock);
495                        crate::log(&format!(
496                            "[host_pipe] direct write failed: {} — clearing writer + buffering",
497                            e
498                        ));
499                        // Arc::ptr_eq guards against clobbering a freshly-
500                        // installed writer if set_writer raced in between
501                        // the failed write and this clear. (reagent P1
502                        // PR #642 round 3.)
503                        let mut inner = self.inner.lock().await;
504                        let same_writer = inner
505                            .writer
506                            .as_ref()
507                            .is_some_and(|cur| Arc::ptr_eq(cur, &writer));
508                        if same_writer {
509                            inner.writer = None;
510                            inner.host_disconnected_at = Some(Instant::now());
511                            inner
512                                .pending_buffer
513                                .push_back(PendingFrame { frame, saga_id });
514                            Err(HostPipeError::WriteFailed(e))
515                        } else {
516                            // Writer was concurrently replaced (set_writer
517                            // raced and already drained whatever buffer
518                            // existed at install time). Push to the new
519                            // writer's buffer + immediately retry through
520                            // the new writer; otherwise this frame sits
521                            // until the next reconnect, even though the
522                            // pipe is healthy. (codex P1 PR #642 round 4.)
523                            let new_writer = inner.writer.as_ref().map(Arc::clone);
524                            // Push the failed frame to buffer FIRST so
525                            // the drain below picks it up in FIFO order
526                            // alongside any other in-flight pending.
527                            inner
528                                .pending_buffer
529                                .push_back(PendingFrame { frame, saga_id });
530                            drop(inner);
531                            if let Some(new_w) = new_writer {
532                                self.drain_pending_to(&new_w).await;
533                            }
534                            // We still surface the original write failure
535                            // to the caller — they asked for THIS frame
536                            // to be sent on the pipe they had at call
537                            // time. The drain attempt is best-effort
538                            // recovery; saga-level retry should kick in
539                            // if the drain fails too.
540                            Err(HostPipeError::WriteFailed(e))
541                        }
542                    }
543                }
544            }
545            Decision::Buffered { dropped } => {
546                if let Some(dropped) = dropped {
547                    self.emit_drop_failure(dropped, "host pipe backpressure overflow")
548                        .await;
549                }
550                Ok(())
551            }
552        }
553    }
554
555    /// If the disconnect timer has elapsed, flush the pending buffer
556    /// and emit `Event::SagaFailed` for every buffered Command.
557    async fn expire_pending_if_timed_out(&self) {
558        let now = Instant::now();
559        let drained: Vec<PendingFrame> = {
560            let mut inner = self.inner.lock().await;
561            let Some(start) = inner.host_disconnected_at else {
562                return;
563            };
564            if now.duration_since(start) < DISCONNECT_TIMEOUT {
565                return;
566            }
567            // Reset the timer so we don't re-drain on the next call;
568            // the next disconnect transition rearms it.
569            inner.host_disconnected_at = None;
570            inner.pending_buffer.drain(..).collect()
571        };
572        if !drained.is_empty() {
573            crate::log(&format!(
574                "[host_pipe] disconnect exceeded {:?}; dropping {} buffered frames",
575                DISCONNECT_TIMEOUT,
576                drained.len()
577            ));
578        }
579        for f in drained {
580            self.emit_drop_failure(f, "host unreachable").await;
581        }
582    }
583
584    async fn emit_drop_failure(&self, dropped: PendingFrame, reason: &str) {
585        let Some(saga_id) = dropped.saga_id else {
586            // Event frames + Command frames without a saga_id are
587            // silently dropped — they don't gate any saga's progress.
588            return;
589        };
590        let v = {
591            let mut state = self.state.lock().await;
592            state.bump_version()
593        };
594        let evt = Event::SagaFailed {
595            saga_id,
596            reason: reason.to_string(),
597            version: v,
598        };
599        let _ = self.events_tx.send(evt);
600    }
601
602    /// Test-only inspection of the current pending buffer length.
603    #[cfg(test)]
604    pub async fn pending_len(&self) -> usize {
605        self.inner.lock().await.pending_buffer.len()
606    }
607
608    /// Whether a writer is currently set — i.e. a CEF host has called
609    /// `set_writer` (registered as `ClientKind::Host` over the IPC pipe)
610    /// and hasn't since disconnected. Originally test-only inspection;
611    /// also used in production by the splash's delayed "first run can
612    /// take longer" hint (`supervisor/windows.rs`) to distinguish the
613    /// pre-registration gap (host process exists, zero code executed —
614    /// where that message belongs) from ordinary post-registration
615    /// CEF-init/first-paint time (where it would be misleading). reagent
616    /// P2, PR #2967 — was a separate `has_registered_host` method with an
617    /// identical body; merged to avoid keeping two copies in sync.
618    #[cfg(any(target_os = "windows", test))]
619    pub async fn is_connected(&self) -> bool {
620        self.inner.lock().await.writer.is_some()
621    }
622
623    /// Test-only: force the disconnect timer back by `delta` so the
624    /// 30s timeout fires without sleeping in tests.
625    #[cfg(test)]
626    pub async fn rewind_disconnect_timer(&self, delta: Duration) {
627        let mut inner = self.inner.lock().await;
628        if let Some(t) = inner.host_disconnected_at.as_mut() {
629            *t = t.checked_sub(delta).unwrap_or(*t);
630        }
631    }
632}
633
634/// Pull the saga_id off a Command if its variant carries one.
635///
636/// Used by `send_frame`'s pending-buffer machinery: when a buffered
637/// command is dropped (overflow or 30s host-disconnect timeout),
638/// `emit_drop_failure` emits `Event::SagaFailed { saga_id }` so the
639/// saga's bracket closes cleanly instead of waiting forever.
640/// CPD-1 added `saga_id` fields on host-bound variants; CPD-3 made
641/// `send_command` live from the saga coordinator, so the drop
642/// machinery is now saga-correctness-relevant.
643fn saga_id_of(cmd: &Command) -> Option<u64> {
644    // CPD-1 added saga_id fields to host-bound Commands; CPD-3 made
645    // send_command live from the saga coordinator. Returning the real
646    // id here means HostPipe's drop-on-overflow + 30s-timeout-flush
647    // paths can emit `Event::SagaFailed` for the right saga, instead
648    // of orphaning the buffered command. (reagent P1 PR #644 round 5.)
649    match cmd {
650        Command::SpawnPoolWindow { saga_id } => Some(*saga_id),
651        Command::ReapPanes { saga_id, .. } => Some(*saga_id),
652        Command::DrainPoolIfLast { saga_id, .. } => Some(*saga_id),
653        _ => None,
654    }
655}
656
657/// Serialize a `HostFrame` as newline-delimited JSON and write it
658/// to a writer. Used both for direct writes (connected) and for
659/// pending-buffer drain (post-reconnect).
660async fn write_frame_async<W: AsyncWrite + Unpin + ?Sized>(
661    writer: &mut W,
662    frame: &HostFrame,
663) -> io::Result<()> {
664    let mut buf = serde_json::to_vec(frame)
665        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
666    buf.push(b'\n');
667    writer.write_all(&buf).await?;
668    writer.flush().await?;
669    Ok(())
670}