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}