Skip to main content

meerkat_runtime/
member_live.rs

1//! Member-host live seam (multi-host mobs §16, DEC-P6B-L2 / ADJ-P6B-1).
2//!
3//! The member runtime's comms drain serves four supervisor bridge commands
4//! (`OpenMemberLiveChannel` / `CloseMemberLiveChannel` /
5//! `MemberLiveChannelStatus` / `ControlMemberLiveChannel`) but the live
6//! open/close/control pipeline lives ABOVE this crate, in the `meerkat`
7//! facade (`LiveOrchestrator`). This module defines the injected host trait
8//! the drain resolves — the `MemberObservationHost` precedent — speaking
9//! contracts vocabulary only: no `meerkat-live` type crosses the seam, so
10//! `meerkat-mob`'s local branch consumes the same trait while staying
11//! live-feature-free (ADJ-P6B-1).
12//!
13//! Cause parity across placements: the ONE total
14//! [`MemberLiveError::to_bridge_rejection`] conversion is shared by the
15//! member-side drain arms and the controlling-side local branch, so a given
16//! pipeline failure surfaces as the SAME `BridgeRejectionCause` regardless
17//! of where the member session lives (pinned by T-C11 + T-L19).
18
19use meerkat_contracts::wire::supervisor_bridge::{
20    BridgeLiveControlOutcome, BridgeLiveControlVerb, BridgeRejectionCause,
21};
22use meerkat_contracts::{
23    LiveCloseStatus, LiveOpenResult, LiveOpenTransport, RealtimeTurningMode, WireLiveAdapterStatus,
24};
25use meerkat_core::time_compat::Duration;
26use meerkat_core::types::SessionId;
27
28/// Mechanical custody for the machine-owned member-live lifecycle boundary.
29///
30/// The guard is minted by [`crate::MeerkatMachine`] and deliberately exposes
31/// no mutation API.  Holding it means a session-scoped `live/open` and a
32/// lifecycle owner cannot cross: opens retain the lease through their full
33/// provider/transport materialization, while disposal retains it from the
34/// absence proof through the durable retire/archive marker.
35pub struct MemberLiveLifecycleLease {
36    #[cfg(not(feature = "live"))]
37    _uninhabited: std::convert::Infallible,
38    #[cfg(feature = "live")]
39    session_id: SessionId,
40    #[cfg(feature = "live")]
41    gate: std::sync::Arc<crate::tokio::sync::Mutex<()>>,
42    #[cfg(feature = "live")]
43    _guard: crate::tokio::sync::OwnedMutexGuard<()>,
44}
45
46#[cfg(feature = "live")]
47impl MemberLiveLifecycleLease {
48    pub(crate) fn new(
49        session_id: SessionId,
50        gate: std::sync::Arc<crate::tokio::sync::Mutex<()>>,
51        guard: crate::tokio::sync::OwnedMutexGuard<()>,
52    ) -> Self {
53        Self {
54            session_id,
55            gate,
56            _guard: guard,
57        }
58    }
59
60    pub(crate) fn session_id(&self) -> &SessionId {
61        &self.session_id
62    }
63
64    pub(crate) fn matches_gate(
65        &self,
66        gate: &std::sync::Arc<crate::tokio::sync::Mutex<()>>,
67    ) -> bool {
68        std::sync::Arc::ptr_eq(&self.gate, gate)
69    }
70}
71
72/// Member-side ceiling for one detached live open (ADJ-P6B-3).
73///
74/// The controlling host's `OpenMemberLiveChannel` round-trip runs under
75/// `LIVE_OPEN_BRIDGE_TIMEOUT = 30s`. The member-side open is enforced
76/// STRICTLY INSIDE that budget — 25s < 30s — so on the slow-provider path
77/// the member fails closed (abort + `close_live_channel_after_open_failure`
78/// eviction of any partial binding) and replies typed BEFORE the
79/// controller's deadline fires. The ceiling deliberately bounds only the
80/// OPEN — the fail-closed cleanup after an abort runs un-time-boxed,
81/// because cutting cleanup off at a deadline could leak a half-installed
82/// binding (the worse failure). Under a pathologically slow post-abort
83/// cleanup the typed reply may therefore land after the controller's 30s;
84/// that reply-loss — like the successful-open variant — resolves via the
85/// caller-driven probe→close reconciliation primitives (DEC-P6B-C9):
86/// `status(None)` discovers, `close(id)` clears, a fresh open succeeds.
87pub const MEMBER_LIVE_OPEN_CEILING: Duration = Duration::from_secs(25);
88
89/// Ceiling for each status/close step in runtime-owned member disposal.
90/// Release and host-revoke retain their pre-terminal retry anchor when either
91/// step times out; no session/archive or host receipt may publish first.
92pub const MEMBER_LIVE_DISPOSAL_CEILING: Duration = Duration::from_secs(15);
93
94/// Typed failure vocabulary for member live serving (DEC-P6B-L3).
95///
96/// Mirrors the six landed V4 live rejection causes plus the ADJ-P4-7
97/// generic pair (`Unavailable` / `Internal`). Every facade pipeline failure
98/// maps onto exactly one variant — never a stringly bucket — and every
99/// variant maps totally onto a wire cause via
100/// [`Self::to_bridge_rejection`].
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102pub enum MemberLiveError {
103    /// The member's resolved model has no realtime capability (B19).
104    #[error("model {model} (provider {provider}) does not support realtime")]
105    ModelNotRealtime { model: String, provider: String },
106    /// No live adapter is available for the member's provider (B18 / #302
107    /// provider-known adapter absence).
108    #[error("provider {provider} has no live adapter wired on this host")]
109    AdapterUnavailable { provider: String },
110    /// The host serves no live transport for this open (no factory, no
111    /// configured websocket transport).
112    #[error("member host has no live transport configured")]
113    TransportUnavailable,
114    /// The member session already has an active live channel.
115    #[error("member session already has an active live channel")]
116    ChannelAlreadyBound,
117    /// The addressed live channel does not exist for this member session.
118    #[error("live channel not found")]
119    ChannelNotFound,
120    /// The requested live transport is not supported by this host.
121    #[error("live transport {requested} is not supported by this host")]
122    TransportUnsupported { requested: String },
123    /// The live substrate cannot serve this member right now (session not
124    /// resident, host not ready). Transient — degrade typed, never quiet.
125    #[error("member live substrate unavailable: {reason}")]
126    Unavailable { reason: String },
127    /// An invariant was violated while serving. Maps to `Internal`.
128    #[error("member live internal fault: {reason}")]
129    Internal { reason: String },
130}
131
132impl MemberLiveError {
133    /// The ONE total `MemberLiveError → BridgeRejectionCause` conversion
134    /// (ADJ-P6B-1). Both the member drain arms and the controlling-side
135    /// local branch project through this impl — cause parity across
136    /// placements is exactly this match. Exhaustive by construction: a new
137    /// variant forces an arm here.
138    #[must_use]
139    pub fn to_bridge_rejection(&self) -> BridgeRejectionCause {
140        match self {
141            Self::ModelNotRealtime { model, provider } => BridgeRejectionCause::ModelNotRealtime {
142                model: model.clone(),
143                provider: provider.clone(),
144            },
145            Self::AdapterUnavailable { provider } => BridgeRejectionCause::LiveAdapterUnavailable {
146                provider: provider.clone(),
147            },
148            Self::TransportUnavailable => BridgeRejectionCause::LiveTransportUnavailable,
149            Self::ChannelAlreadyBound => BridgeRejectionCause::LiveChannelAlreadyBound,
150            Self::ChannelNotFound => BridgeRejectionCause::LiveChannelNotFound,
151            Self::TransportUnsupported { requested } => {
152                BridgeRejectionCause::LiveTransportUnsupported {
153                    requested: requested.clone(),
154                }
155            }
156            Self::Unavailable { .. } => BridgeRejectionCause::Unavailable,
157            Self::Internal { .. } => BridgeRejectionCause::Internal,
158        }
159    }
160}
161
162/// One member live-channel point read — the reply shape the drain emits
163/// (`BridgeReply::MemberLiveChannelStatusReport { channel_id, status }`),
164/// carried losslessly so the serving arm does zero semantic interpretation.
165/// `channel_id` echoes the RESOLVED channel: for a `status(None)` probe it
166/// is the member's active channel discovered via
167/// `live_active_channel_by_session` (ADJ-P6B-2 — the reply-loss
168/// reconciliation discovery primitive).
169#[derive(Debug, Clone, PartialEq)]
170pub struct MemberLiveStatus {
171    /// The resolved channel the status describes.
172    pub channel_id: String,
173    /// Wire status projected from generated live-channel status authority.
174    pub status: WireLiveAdapterStatus,
175}
176
177/// Machine-wide injected member live host (ADJ-P6B-1). Implemented by the
178/// facade (`meerkat::surface::ServiceMemberLiveHost`) over the ONE extracted
179/// live pipeline; installed via `MeerkatMachine::set_member_live_host` by
180/// the composing surface (the mob host daemon, live-capable `rkat-rpc`).
181/// Absent host ⇒ the live drain arms reply typed `LiveTransportUnavailable`
182/// (the live-specific sibling of the observation host's `Unavailable`: the
183/// landed cause names precisely "this host serves no live substrate").
184///
185/// Session-id-addressed and object-safe; vocabulary is contracts-owned so
186/// `meerkat-mob`'s local branch consumes the identical trait without live
187/// features.
188#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
189#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
190pub trait MemberLiveHost: Send + Sync {
191    /// Run the full S1-S12 open pipeline for the member session.
192    /// `turning_mode: None` ⇒ `ProviderManaged` (the pipeline owns the
193    /// default, DEC-P6B-L15). Returns the owning host's `LiveOpenResult`
194    /// VERBATIM — absolute advertised-URL bootstrap + machine-minted token.
195    async fn open(
196        &self,
197        session: &SessionId,
198        turning_mode: Option<RealtimeTurningMode>,
199        transport: Option<LiveOpenTransport>,
200    ) -> Result<LiveOpenResult, MemberLiveError>;
201
202    /// Close the named channel (close-what-you-name; an unknown channel is
203    /// typed [`MemberLiveError::ChannelNotFound`], idempotent-safe for the
204    /// caller-driven reconciliation loop).
205    async fn close(
206        &self,
207        session: &SessionId,
208        channel_id: &str,
209    ) -> Result<LiveCloseStatus, MemberLiveError>;
210
211    /// Read-only point read. `channel_id: None` ⇒ resolve the member's
212    /// active channel via `live_active_channel_by_session`; no active
213    /// channel ⇒ typed [`MemberLiveError::ChannelNotFound`] (ADJ-P6B-2).
214    async fn status(
215        &self,
216        session: &SessionId,
217        channel_id: Option<String>,
218    ) -> Result<MemberLiveStatus, MemberLiveError>;
219
220    /// Drive one turn-level control verb (CommitInput / Interrupt /
221    /// Truncate / Refresh — DL10's closed verb set) against the named
222    /// channel, pinned to the member session pre-effect (DEC-P6B-L6).
223    async fn control(
224        &self,
225        session: &SessionId,
226        channel_id: &str,
227        verb: BridgeLiveControlVerb,
228    ) -> Result<BridgeLiveControlOutcome, MemberLiveError>;
229}
230
231#[cfg(test)]
232#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
233mod tests {
234    use super::*;
235
236    /// T-L2: variant completeness against the six landed live causes plus
237    /// the generic pair — compile-exhaustive: a new `MemberLiveError`
238    /// variant forces an arm in `to_bridge_rejection` AND a row here.
239    #[test]
240    fn every_variant_maps_to_its_landed_cause() {
241        let rows: Vec<(MemberLiveError, BridgeRejectionCause)> = vec![
242            (
243                MemberLiveError::ModelNotRealtime {
244                    model: "gpt-5.4".to_string(),
245                    provider: "openai".to_string(),
246                },
247                BridgeRejectionCause::ModelNotRealtime {
248                    model: "gpt-5.4".to_string(),
249                    provider: "openai".to_string(),
250                },
251            ),
252            (
253                MemberLiveError::AdapterUnavailable {
254                    provider: "anthropic".to_string(),
255                },
256                BridgeRejectionCause::LiveAdapterUnavailable {
257                    provider: "anthropic".to_string(),
258                },
259            ),
260            (
261                MemberLiveError::TransportUnavailable,
262                BridgeRejectionCause::LiveTransportUnavailable,
263            ),
264            (
265                MemberLiveError::ChannelAlreadyBound,
266                BridgeRejectionCause::LiveChannelAlreadyBound,
267            ),
268            (
269                MemberLiveError::ChannelNotFound,
270                BridgeRejectionCause::LiveChannelNotFound,
271            ),
272            (
273                MemberLiveError::TransportUnsupported {
274                    requested: "webrtc".to_string(),
275                },
276                BridgeRejectionCause::LiveTransportUnsupported {
277                    requested: "webrtc".to_string(),
278                },
279            ),
280            (
281                MemberLiveError::Unavailable {
282                    reason: "session not resident".to_string(),
283                },
284                BridgeRejectionCause::Unavailable,
285            ),
286            (
287                MemberLiveError::Internal {
288                    reason: "invariant".to_string(),
289                },
290                BridgeRejectionCause::Internal,
291            ),
292        ];
293        for (error, expected) in rows {
294            assert_eq!(
295                error.to_bridge_rejection(),
296                expected,
297                "cause mapping drifted for {error:?}"
298            );
299        }
300    }
301
302    /// T-L2: display text is the reply `reason`; keep the human-facing
303    /// strings stable and reason-bearing.
304    #[test]
305    fn display_carries_the_reason_material() {
306        assert_eq!(
307            MemberLiveError::ModelNotRealtime {
308                model: "m".to_string(),
309                provider: "p".to_string(),
310            }
311            .to_string(),
312            "model m (provider p) does not support realtime"
313        );
314        assert_eq!(
315            MemberLiveError::TransportUnavailable.to_string(),
316            "member host has no live transport configured"
317        );
318        assert_eq!(
319            MemberLiveError::Unavailable {
320                reason: "why".to_string()
321            }
322            .to_string(),
323            "member live substrate unavailable: why"
324        );
325    }
326
327    /// ADJ-P6B-3: the member open ceiling nests strictly inside the
328    /// controlling bridge deadline (30s).
329    #[test]
330    fn open_ceiling_nests_inside_bridge_open_timeout() {
331        assert!(MEMBER_LIVE_OPEN_CEILING < Duration::from_secs(30));
332    }
333}