Skip to main content

meerkat_mobkit/unified_runtime/
mod.rs

1//! Unified runtime — combines mob lifecycle, module management, and operational subsystems.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::sync::atomic::AtomicBool;
8use std::time::Duration;
9
10use futures::stream::{BoxStream, SelectAll, StreamExt};
11use meerkat_core::comms::EventStream;
12use meerkat_core::event::{AgentEvent, agent_event_type};
13use meerkat_mob::{
14    AgentIdentity, AgentRuntimeId, AttributedEvent, FenceToken, MobError, MobHandle,
15    MobMemberStatus, ProfileName, SpawnMemberSpec,
16};
17use tokio::sync::mpsc::{Receiver, Sender};
18use tokio::task::JoinHandle;
19
20pub(crate) use self::console_events::ConsoleEventStore;
21use self::mob_events::MobEventsStore;
22use crate::console_aggregator::{ConsoleLogStore, InMemoryConsoleLogStore};
23use crate::mob_handle_runtime::{MobBootstrapSpec, MobRuntime, MobRuntimeError};
24use crate::runtime::{
25    InMemoryMetadataStore, MetadataScope, MobkitRuntimeHandle, PersistentMetadataStore,
26    RuntimeMetadataTable, RuntimeOptions, start_mobkit_runtime_with_options,
27};
28use crate::types::{
29    AgentDiscoverySpec, EventEnvelope, MobKitConfig, MobStructuralEventEnvelope, UnifiedEvent,
30};
31
32pub mod builder;
33pub(crate) mod console_events;
34pub mod cross_mob;
35pub mod edge_reconcile;
36pub mod edge_types;
37pub mod event_log;
38pub mod http;
39pub(crate) mod implicit_delegate_retirement;
40pub mod lifecycle;
41pub mod mob_events;
42pub mod mob_ops;
43pub mod module_ops;
44pub mod types;
45
46pub use builder::{IdentityBootstrapMode, UnifiedRuntimeBuilder};
47pub use edge_types::{
48    DesiredPeerEdge, DesiredPeerEdgeError, Discovery, EdgeDiscovery, EdgeReconcileFailure,
49    PreSpawnContext, PreSpawnHook,
50};
51pub use event_log::{EventLogConfig, EventLogError, EventLogStore, EventQuery, PersistedEvent};
52pub use http::DEFAULT_REFERENCE_APP_MAX_CONCURRENT_REQUESTS;
53pub use types::{
54    ErrorEvent, RediscoverReport, ShutdownDrainReport, UnifiedRuntimeBootstrapError,
55    UnifiedRuntimeBuilderError, UnifiedRuntimeBuilderField, UnifiedRuntimeError,
56    UnifiedRuntimeReconcileEdgesReport, UnifiedRuntimeReconcileError,
57    UnifiedRuntimeReconcileReport, UnifiedRuntimeReconcileRoutingReport, UnifiedRuntimeRunReport,
58    UnifiedRuntimeShutdownReport,
59};
60
61/// Called after members are spawned. Receives the list of spawned member IDs.
62pub type PostSpawnHook =
63    Arc<dyn Fn(Vec<String>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
64
65/// Called after reconcile completes. Receives the reconcile report.
66pub type PostReconcileHook = Arc<
67    dyn Fn(UnifiedRuntimeReconcileReport) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
68>;
69
70/// Called when a runtime operation fails. Fire-and-forget — the hook's
71/// result is not checked and a failing hook cannot break the runtime.
72pub type ErrorHook =
73    Arc<dyn Fn(ErrorEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
74
75const ROSTER_ROUTE_PREFIX: &str = "mob.member.";
76const ROSTER_ROUTE_CHANNEL: &str = "notification";
77const ROSTER_ROUTE_SINK: &str = "mob_member";
78const ROSTER_ROUTE_TARGET_MODULE: &str = "delivery";
79
80const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
81
82/// Map an [`AgentDiscoverySpec`] to a [`SpawnMemberSpec`] for spawning.
83///
84/// `additional_instructions` maps directly to `SpawnMemberSpec.additional_instructions`,
85/// which flows through Meerkat's build pipeline to `AgentBuildConfig.additional_instructions`.
86pub fn discovery_spec_to_spawn_spec(spec: &AgentDiscoverySpec) -> SpawnMemberSpec {
87    let resume_session_id = spec
88        .resume_session_id
89        .as_deref()
90        .and_then(|s| meerkat_core::types::SessionId::parse(s).ok());
91    let additional_instructions = if spec.additional_instructions.is_empty() {
92        None
93    } else {
94        Some(spec.additional_instructions.clone())
95    };
96    let mut spawn = SpawnMemberSpec::new(
97        meerkat_mob::ProfileName::from(spec.profile.as_str()),
98        // The spec stays in the public alias space: the hook-aware
99        // `UnifiedRuntime::spawn`/`spawn_many` own the encode to the
100        // comms-safe roster id (meerkat 0.7 MemberCommsName), and the encode
101        // is deliberately not idempotent (`mk--` is a reserved marker), so
102        // encoding here too would double-encode `:`-bearing identities.
103        meerkat_mob::ids::AgentIdentity::from(spec.meerkat_id.as_str()),
104    );
105    if let Some(context) = spec.context.clone() {
106        spawn = spawn.with_context(context);
107    }
108    if let Some(labels) = spec.labels.clone() {
109        spawn = spawn.with_labels(labels);
110    }
111    if let Some(sid) = resume_session_id {
112        spawn = spawn.with_resume_bridge_session_id(sid);
113    }
114    if let Some(instructions) = additional_instructions {
115        spawn = spawn.with_additional_instructions(instructions);
116    }
117    spawn
118}
119
120pub struct UnifiedRuntime {
121    // Immutable after construction — &self access
122    mob_runtime: MobRuntime,
123    post_spawn_hook: Option<PostSpawnHook>,
124    post_reconcile_hook: Option<PostReconcileHook>,
125    error_hook: Option<ErrorHook>,
126    drain_timeout: Duration,
127    discovery: Option<Box<dyn Discovery>>,
128    edge_discovery: Option<Box<dyn EdgeDiscovery>>,
129
130    // Fine-grained interior mutability
131    module_runtime: Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>,
132    managed_dynamic_edges: tokio::sync::RwLock<BTreeSet<(String, String)>>,
133    shutting_down: AtomicBool,
134    mob_event_ingress: tokio::sync::Mutex<Option<MobEventIngress>>,
135    bootstrap_edges_report: tokio::sync::RwLock<Option<UnifiedRuntimeReconcileEdgesReport>>,
136    event_log: Option<event_log::EventLogHandle>,
137    console_log_store: Arc<dyn ConsoleLogStore>,
138    console_events: ConsoleEventStore,
139    mob_events: MobEventsStore,
140    mob_events_subscriber_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
141    implicit_delegate_retirement_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
142    identity_lease_renewal_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
143
144    // Cross-mob communication
145    contact_directory: Option<crate::contact_directory::ContactDirectory>,
146    peer_mob_handles: tokio::sync::RwLock<BTreeMap<String, MobHandle>>,
147    /// Long-lived Ed25519 signing identity for cross-process peering.
148    /// `None` is the default for inproc-only deployments and tests;
149    /// production gateways set this via
150    /// [`UnifiedRuntime::set_gateway_peer_keys`] during bootstrap so the
151    /// `mobkit/peer_pubkey` RPC and non-inproc `wire_*` paths can stamp
152    /// a real pubkey on outbound descriptors.
153    gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
154
155    // Identity-first session bridge
156    session_bridge: Option<Arc<dyn crate::identity_first::bridge::SessionBridge>>,
157    identity_first_context: Option<Arc<crate::identity_first::IdentityFirstRuntimeContext>>,
158
159    // Optional ABAC enforcement shared by the console/SSE surfaces.
160    access_controller: Option<crate::access::AccessController>,
161
162    // Optional bundled-store handle backing the console Memory panel's
163    // read-only RPCs (§9.3). Interior-mutable so gateways can wire it after
164    // the runtime is shared (`Arc`), wherever the store is constructed.
165    memory_panel_store:
166        std::sync::RwLock<Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>>,
167
168    // Mobkit-side label sidecar for mob- and run-scoped metadata
169    metadata_table: Arc<RuntimeMetadataTable>,
170
171    // Persistent metadata adapter (currently used for the structural-events
172    // subscription cursor). Falls back to `InMemoryMetadataStore` when not
173    // explicitly configured — see `UnifiedRuntimeBuilder::persistent_metadata`.
174    persistent_metadata: Arc<dyn PersistentMetadataStore>,
175}
176
177enum MobEventIngress {
178    Forwarder(MobEventForwarder),
179}
180
181struct MobEventForwarder {
182    event_rx: Receiver<EventEnvelope<UnifiedEvent>>,
183    task: JoinHandle<()>,
184}
185
186impl UnifiedRuntime {
187    pub fn builder() -> UnifiedRuntimeBuilder {
188        UnifiedRuntimeBuilder::default()
189    }
190
191    pub(crate) async fn from_parts(
192        mob_runtime: MobRuntime,
193        module_runtime: MobkitRuntimeHandle,
194        persistent_metadata: Arc<dyn PersistentMetadataStore>,
195    ) -> Self {
196        // Construct the metadata table first so the structural-events store
197        // can be wired with it — every projected envelope picks up the
198        // matching mob/run labels at projection time.
199        let metadata_table = Arc::new(RuntimeMetadataTable::new());
200        let mob_events_store = MobEventsStore::new().with_metadata_table(metadata_table.clone());
201        let mob_event_ingress = Some(Self::create_event_ingress(
202            mob_runtime.handle(),
203            mob_runtime.agent_mob_mcp_state(),
204            mob_events_store.clone(),
205        ));
206        let mob_events_task = Self::spawn_mob_events_subscriber(
207            mob_runtime.handle(),
208            mob_events_store.clone(),
209            persistent_metadata.clone(),
210        );
211        let console_events = ConsoleEventStore::new();
212        // Agent-tool spawns (mob_spawn_member/delegate) project their members
213        // into this runtime's console event store so spawned workers are
214        // visible in the console without embedder-side workarounds.
215        mob_runtime.install_console_spawn_sink(crate::console_spawn::ConsoleSpawnSink::new(
216            console_events.clone(),
217        ));
218        Self {
219            mob_runtime,
220            post_spawn_hook: None,
221            post_reconcile_hook: None,
222            error_hook: None,
223            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
224            discovery: None,
225            edge_discovery: None,
226            module_runtime: Arc::new(tokio::sync::Mutex::new(module_runtime)),
227            managed_dynamic_edges: tokio::sync::RwLock::new(BTreeSet::new()),
228            shutting_down: AtomicBool::new(false),
229            mob_event_ingress: tokio::sync::Mutex::new(mob_event_ingress),
230            bootstrap_edges_report: tokio::sync::RwLock::new(None),
231            event_log: None,
232            console_log_store: Arc::new(InMemoryConsoleLogStore::new()),
233            console_events,
234            mob_events: mob_events_store,
235            mob_events_subscriber_task: tokio::sync::Mutex::new(mob_events_task),
236            implicit_delegate_retirement_task: tokio::sync::Mutex::new(None),
237            identity_lease_renewal_task: tokio::sync::Mutex::new(None),
238            contact_directory: None,
239            peer_mob_handles: tokio::sync::RwLock::new(BTreeMap::new()),
240            gateway_peer_keys: None,
241            session_bridge: None,
242            identity_first_context: None,
243            access_controller: None,
244            memory_panel_store: std::sync::RwLock::new(None),
245            metadata_table,
246            persistent_metadata,
247        }
248    }
249
250    /// Spawn a background task that opens a streaming subscription to
251    /// the meerkat mob event ledger and projects each [`MobEvent`] into
252    /// the runtime's [`MobEventsStore`]. The task resumes from the
253    /// last-projected cursor recorded in `persistent_metadata`, so the
254    /// SDK-side cursor is durable across mobkit restarts on
255    /// SQLite-backed deployments.
256    ///
257    /// Returns `None` when there is no current tokio runtime (e.g. unit
258    /// tests outside an async context); in that case the store is still
259    /// usable via direct projection.
260    fn spawn_mob_events_subscriber(
261        handle: MobHandle,
262        store: MobEventsStore,
263        persistent_metadata: Arc<dyn PersistentMetadataStore>,
264    ) -> Option<JoinHandle<()>> {
265        let runtime_handle = tokio::runtime::Handle::try_current().ok()?;
266        Some(runtime_handle.spawn(run_mob_events_subscription(
267            handle,
268            store,
269            persistent_metadata,
270        )))
271    }
272
273    pub async fn bootstrap(
274        mob_spec: MobBootstrapSpec,
275        module_config: MobKitConfig,
276        timeout: Duration,
277    ) -> Result<Self, UnifiedRuntimeBootstrapError> {
278        Box::pin(Self::bootstrap_with_options(
279            mob_spec,
280            module_config,
281            Vec::new(),
282            timeout,
283            RuntimeOptions::default(),
284            Arc::new(InMemoryMetadataStore::new()),
285        ))
286        .await
287    }
288
289    pub async fn bootstrap_with_options(
290        mob_spec: MobBootstrapSpec,
291        module_config: MobKitConfig,
292        module_agent_events: Vec<EventEnvelope<UnifiedEvent>>,
293        timeout: Duration,
294        options: RuntimeOptions,
295        persistent_metadata: Arc<dyn PersistentMetadataStore>,
296    ) -> Result<Self, UnifiedRuntimeBootstrapError> {
297        let mob_runtime = MobRuntime::bootstrap(mob_spec)
298            .await
299            .map_err(UnifiedRuntimeBootstrapError::Mob)?;
300        let runtime_options = options.clone();
301        let module_start_result = std::thread::spawn(move || {
302            start_mobkit_runtime_with_options(module_config, module_agent_events, timeout, options)
303        })
304        .join();
305
306        match module_start_result {
307            Ok(Ok(module_runtime)) => {
308                let runtime =
309                    Self::from_parts(mob_runtime, module_runtime, persistent_metadata).await;
310                runtime
311                    .configure_implicit_delegate_retirement(&runtime_options)
312                    .await;
313                Ok(runtime)
314            }
315            Ok(Err(error)) => {
316                let startup_error = UnifiedRuntimeBootstrapError::Module(error);
317                Self::rollback_mob_runtime(mob_runtime, startup_error).await
318            }
319            Err(_) => {
320                let startup_error = UnifiedRuntimeBootstrapError::ModuleStartupThreadPanicked;
321                Self::rollback_mob_runtime(mob_runtime, startup_error).await
322            }
323        }
324    }
325
326    /// Bootstrap edge reconciliation report, if edge discovery was configured.
327    ///
328    /// Inspect after `build()` to detect incomplete startup topology.
329    /// Returns `None` if no edge discovery was configured.
330    pub async fn bootstrap_edges_report(&self) -> Option<UnifiedRuntimeReconcileEdgesReport> {
331        self.bootstrap_edges_report.read().await.clone()
332    }
333
334    /// Register an error hook after construction. Useful when the runtime
335    /// is built via `bootstrap()` rather than the builder.
336    pub fn set_error_hook(&mut self, hook: ErrorHook) {
337        self.error_hook = Some(hook.clone());
338        if let Some(identity_runtime) = self.identity_runtime() {
339            identity_runtime.set_error_hook(Some(hook));
340        }
341    }
342
343    /// Start the event log ingestion engine. Must be called after
344    /// construction (the builder calls this automatically when event_log
345    /// config is provided).
346    pub fn start_event_log(&mut self, config: EventLogConfig) {
347        let handle = event_log::start_event_log(config, self.error_hook.clone());
348        self.event_log = Some(handle);
349    }
350
351    pub(crate) fn console_events(&self) -> ConsoleEventStore {
352        self.console_events.clone()
353    }
354
355    /// A §9.3 memory-event sink projecting typed memory-plane events onto
356    /// the console timeline (standard `ConsoleIdentityEventEnvelope`,
357    /// `event_type = "memory.*"`). Must be called from async context — the
358    /// sink captures the current runtime handle so sync emitters
359    /// (store/taint/guard code) can fire-and-forget.
360    pub fn memory_event_sink(&self) -> Arc<dyn crate::memory::events::MemoryEventSink> {
361        Arc::new(ConsoleMemoryEventSink {
362            store: self.console_events(),
363            handle: tokio::runtime::Handle::current(),
364        })
365    }
366
367    /// Register an observer for gating pending-entry resolutions
368    /// (decisions and timeout fallbacks) — the seam the memory steward's
369    /// gated promotions commit through (§10.2).
370    pub async fn register_gating_resolution_observer(
371        &self,
372        observer: Arc<dyn crate::runtime::GatingResolutionObserver>,
373    ) {
374        self.module_runtime
375            .lock()
376            .await
377            .register_gating_resolution_observer(observer);
378    }
379
380    /// Internal accessor used by console-facing RPC routers to share the
381    /// in-memory structural mob events store without holding a full
382    /// runtime reference.
383    pub(crate) fn mob_events_store(&self) -> MobEventsStore {
384        self.mob_events.clone()
385    }
386
387    pub fn binary_blob_store(&self) -> Option<Arc<dyn crate::blob_store::BinaryBlobStore>> {
388        self.mob_runtime.binary_blob_store()
389    }
390
391    pub(crate) fn module_runtime_handle(&self) -> Arc<tokio::sync::Mutex<MobkitRuntimeHandle>> {
392        Arc::clone(&self.module_runtime)
393    }
394
395    pub(crate) fn mobpack_runtime_catalog_state_snapshot(
396        &self,
397    ) -> crate::mobpack::MobpackRuntimeCatalogState {
398        let loaded_modules = self
399            .module_runtime
400            .try_lock()
401            .map(|runtime| runtime.loaded_modules())
402            .unwrap_or_default();
403        let has_peer_mob_handles = self
404            .peer_mob_handles
405            .try_read()
406            .map(|handles| !handles.is_empty())
407            .unwrap_or(false);
408        let mut runtime_methods = vec![
409            "mobkit/capabilities".to_string(),
410            "mobkit/models/catalog".to_string(),
411            "mobkit/spawn_member".to_string(),
412            "mobkit/list_members".to_string(),
413            "mobkit/get_member".to_string(),
414            "mobkit/run_flow".to_string(),
415            "mobkit/list_flows".to_string(),
416            "mobkit/list_runs".to_string(),
417        ];
418        runtime_methods.extend(
419            crate::rpc::MOBPACK_AUTHORING_METHODS
420                .iter()
421                .map(std::string::ToString::to_string),
422        );
423        if self.has_contact_directory() {
424            runtime_methods.push("mobkit/cross_mob/directory".to_string());
425        }
426        if has_peer_mob_handles && self.has_inproc_contacts() {
427            runtime_methods.extend([
428                "mobkit/cross_mob/wire".to_string(),
429                "mobkit/cross_mob/unwire".to_string(),
430                "mobkit/cross_mob/send".to_string(),
431            ]);
432        }
433        crate::mobpack::MobpackRuntimeCatalogState {
434            loaded_modules,
435            runtime_methods,
436            has_contact_directory: self.has_contact_directory(),
437            has_peer_mob_handles,
438            has_inproc_contacts: self.has_inproc_contacts(),
439            runtime_flow_rows: crate::mobpack::runtime_flow_registry_rows_from_definition(
440                self.mob_handle().definition(),
441            ),
442            runtime_agent_definition_sources:
443                crate::mobpack::runtime_agent_definition_sources_from_definition(
444                    self.mob_handle().definition(),
445                ),
446            runtime_skill_realms: crate::mobpack::runtime_skill_realms_from_definition(
447                self.mob_handle().definition(),
448            ),
449        }
450    }
451
452    /// Return the session bridge for identity-first operations, if configured.
453    pub fn session_bridge(&self) -> Option<&Arc<dyn crate::identity_first::bridge::SessionBridge>> {
454        self.session_bridge.as_ref()
455    }
456
457    pub fn identity_first_context(
458        &self,
459    ) -> Option<&Arc<crate::identity_first::IdentityFirstRuntimeContext>> {
460        self.identity_first_context.as_ref()
461    }
462
463    pub fn identity_runtime(&self) -> Option<&Arc<crate::identity_first::IdentityRuntime>> {
464        self.identity_first_context.as_ref().map(|ctx| &ctx.runtime)
465    }
466
467    pub async fn remember_agent_memory(
468        &self,
469        realm: &str,
470        identity: &crate::identity_first::AgentIdentity,
471        memory: crate::identity_first::NewAgentMemory,
472    ) -> Result<crate::identity_first::AgentMemoryRecord, crate::identity_first::AgentMemoryError>
473    {
474        let runtime = self.identity_runtime().ok_or_else(|| {
475            crate::identity_first::AgentMemoryError::InvalidConfig(
476                "identity-first runtime is not configured".to_string(),
477            )
478        })?;
479        runtime.remember_agent_memory(realm, identity, memory).await
480    }
481
482    pub async fn recall_agent_memory(
483        &self,
484        request: crate::identity_first::AgentMemoryRecallRequest,
485    ) -> Result<
486        Vec<crate::identity_first::AgentMemoryRecord>,
487        crate::identity_first::AgentMemoryError,
488    > {
489        let runtime = self.identity_runtime().ok_or_else(|| {
490            crate::identity_first::AgentMemoryError::InvalidConfig(
491                "identity-first runtime is not configured".to_string(),
492            )
493        })?;
494        runtime.recall_agent_memory(request).await
495    }
496
497    pub async fn forget_agent_memory(
498        &self,
499        realm: &str,
500        identity: &crate::identity_first::AgentIdentity,
501        memory_id: &str,
502    ) -> Result<
503        crate::identity_first::AgentMemoryForgetResult,
504        crate::identity_first::AgentMemoryError,
505    > {
506        let runtime = self.identity_runtime().ok_or_else(|| {
507            crate::identity_first::AgentMemoryError::InvalidConfig(
508                "identity-first runtime is not configured".to_string(),
509            )
510        })?;
511        runtime
512            .forget_agent_memory(realm, identity, memory_id)
513            .await
514    }
515
516    pub fn attach_identity_first_context(
517        &mut self,
518        context: Arc<crate::identity_first::IdentityFirstRuntimeContext>,
519    ) {
520        self.identity_first_context = Some(context);
521    }
522
523    pub async fn refresh_desired_topology(
524        &self,
525    ) -> Result<
526        Option<crate::identity_first::RestoreFlowResult>,
527        crate::identity_first::IdentityRuntimeError,
528    > {
529        match self.identity_first_context.as_ref() {
530            Some(ctx) => ctx.refresh_desired_topology().await.map(Some),
531            None => Ok(None),
532        }
533    }
534
535    /// Hydrate identity-first lazy members before handing control to concrete
536    /// mob APIs that operate on already-materialized runtime members.
537    pub async fn materialize_identity_first_for_flow(
538        &self,
539    ) -> Result<
540        Vec<crate::identity_first::ContinuityRecord>,
541        crate::identity_first::IdentityRuntimeError,
542    > {
543        match self.identity_runtime() {
544            Some(runtime) => runtime.materialize_all_required().await,
545            None => Ok(Vec::new()),
546        }
547    }
548
549    /// Return the mob/run label sidecar table.
550    ///
551    /// Mobkit owns this table — meerkat-mob has no concept of mob- or
552    /// run-level labels. Apps use it to attach external context (repo,
553    /// branch, customer, deployment, environment) to a mob or a flow run.
554    pub fn metadata_table(&self) -> &Arc<RuntimeMetadataTable> {
555        &self.metadata_table
556    }
557
558    /// Install the shared access controller. Console routers built after
559    /// this call enforce (and live-serve) the ABAC configuration.
560    pub fn set_access_controller(&mut self, controller: crate::access::AccessController) {
561        self.access_controller = Some(controller);
562    }
563
564    /// Wire the bundled sqlite memory store into the console Memory panel
565    /// (§9.3). `&self` deliberately: gateways construct the store next to
566    /// the memory subsystem wiring, which may run after the runtime is
567    /// `Arc`-shared. Routers built *after* this call serve the panel RPCs.
568    pub fn set_memory_panel_store(
569        &self,
570        store: crate::memory::sqlite_store::SqliteAgentMemoryStore,
571    ) {
572        *self
573            .memory_panel_store
574            .write()
575            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(store);
576    }
577
578    pub fn memory_panel_store(
579        &self,
580    ) -> Option<crate::memory::sqlite_store::SqliteAgentMemoryStore> {
581        self.memory_panel_store
582            .read()
583            .unwrap_or_else(std::sync::PoisonError::into_inner)
584            .clone()
585    }
586
587    /// Borrow the shared access controller if one was installed.
588    pub fn access_controller(&self) -> Option<&crate::access::AccessController> {
589        self.access_controller.as_ref()
590    }
591
592    /// Return the persistent metadata adapter — used by the
593    /// structural-events subscription to checkpoint its last-projected
594    /// cursor. Tests and integration code that need to inspect the
595    /// persisted cursor reach through this accessor.
596    pub fn persistent_metadata(&self) -> &Arc<dyn PersistentMetadataStore> {
597        &self.persistent_metadata
598    }
599
600    /// Replace the label set associated with this mob.
601    ///
602    /// An empty `labels` map clears the entry. Replacement is wholesale —
603    /// existing labels not present in `labels` are dropped. To merge,
604    /// read first via [`Self::get_mob_labels`] and combine.
605    pub async fn set_mob_labels(&self, labels: BTreeMap<String, String>) {
606        self.metadata_table
607            .set_labels(MetadataScope::Mob(self.mob_id()), labels)
608            .await;
609    }
610
611    /// Return the label set associated with this mob, or an empty map.
612    pub async fn get_mob_labels(&self) -> BTreeMap<String, String> {
613        self.metadata_table
614            .get_labels(&MetadataScope::Mob(self.mob_id()))
615            .await
616    }
617
618    /// Remove the label set associated with this mob.
619    pub async fn delete_mob_labels(&self) {
620        let _ = self
621            .metadata_table
622            .delete_labels(&MetadataScope::Mob(self.mob_id()))
623            .await;
624    }
625
626    /// Replace the label set for `run_id` under this mob.
627    pub async fn set_run_labels(&self, run_id: &str, labels: BTreeMap<String, String>) {
628        self.metadata_table
629            .set_labels(
630                MetadataScope::Run(self.mob_id(), run_id.to_string()),
631                labels,
632            )
633            .await;
634    }
635
636    /// Return the label set for `run_id` under this mob, or an empty map.
637    pub async fn get_run_labels(&self, run_id: &str) -> BTreeMap<String, String> {
638        self.metadata_table
639            .get_labels(&MetadataScope::Run(self.mob_id(), run_id.to_string()))
640            .await
641    }
642
643    /// Remove the label set for `run_id` under this mob.
644    pub async fn delete_run_labels(&self, run_id: &str) {
645        let _ = self
646            .metadata_table
647            .delete_labels(&MetadataScope::Run(self.mob_id(), run_id.to_string()))
648            .await;
649    }
650
651    /// Return the underlying event log store if one is configured.
652    ///
653    /// Used to share the store with sub-handlers (e.g. console RPC) that
654    /// don't hold a full `UnifiedRuntime` reference.
655    pub fn event_log_store(&self) -> Option<std::sync::Arc<dyn event_log::EventLogStore>> {
656        self.event_log
657            .as_ref()
658            .map(event_log::EventLogHandle::store)
659    }
660
661    pub fn console_log_store(&self) -> Arc<dyn ConsoleLogStore> {
662        self.console_log_store.clone()
663    }
664
665    pub fn set_console_log_store(&mut self, store: Arc<dyn ConsoleLogStore>) {
666        self.console_log_store = store;
667    }
668
669    /// Query structural mob events from the meerkat ledger.
670    ///
671    /// Returns events filtered by [`EventQuery`] in cursor-ascending
672    /// order. `EventQuery::after_seq` acts as the pagination cursor: the
673    /// caller passes the highest `cursor` seen so far to receive only
674    /// strictly-newer events. Without `after_seq` the call returns the
675    /// **latest** matching events up to `limit` (default 256), scanning
676    /// the ledger backwards from `latest_cursor`.
677    ///
678    /// Errors propagate the typed [`mob_events::MobEventsQueryError`]
679    /// so the JSON-RPC handler can surface `StaleEventCursor` as code
680    /// `-32010`.
681    pub async fn query_mob_events(
682        &self,
683        query: &EventQuery,
684    ) -> Result<Vec<MobStructuralEventEnvelope>, mob_events::MobEventsQueryError> {
685        let events = self.mob_runtime.handle().events();
686        mob_events::query_ledger_with_filter(&events, &self.mob_events, query).await
687    }
688
689    /// Subscribe to live structural mob events. Returns a broadcast
690    /// receiver that yields each newly-projected envelope. The receiver
691    /// will report `RecvError::Lagged` if it falls behind the in-memory
692    /// channel cap.
693    pub fn subscribe_mob_events(
694        &self,
695    ) -> tokio::sync::broadcast::Receiver<MobStructuralEventEnvelope> {
696        self.mob_events.subscribe()
697    }
698
699    /// Ingest an event into the event log (if configured). Non-blocking.
700    pub(crate) fn ingest_event(&self, event: &EventEnvelope<UnifiedEvent>) {
701        if let Some(ref log) = self.event_log {
702            log.ingest(event.clone());
703        }
704    }
705
706    pub(crate) async fn record_console_lifecycle(
707        &self,
708        identity: &str,
709        event_type: &str,
710        data: serde_json::Value,
711    ) {
712        self.console_events
713            .record_lifecycle(identity, event_type, data)
714            .await;
715    }
716
717    pub async fn reserve_identity_interaction(
718        &self,
719        identity: &str,
720        runtime_member_id: Option<&str>,
721        interaction_id: &str,
722        origin: &str,
723        content: serde_json::Value,
724    ) -> Result<(), &'static str> {
725        self.console_events
726            .reserve_interaction_value(identity, runtime_member_id, interaction_id, origin, content)
727            .await
728    }
729
730    pub(crate) async fn project_console_event_from_unified(
731        &self,
732        event: &EventEnvelope<UnifiedEvent>,
733    ) {
734        self.console_events.project_unified_event(event).await;
735    }
736
737    /// Fire an error event to the registered hook, if any.
738    /// Truly fire-and-forget — spawns a detached task so slow hooks
739    /// (HTTP to Slack, PagerDuty) never block the runtime operation.
740    pub(crate) fn fire_error(&self, event: ErrorEvent) {
741        if let Some(ref hook) = self.error_hook {
742            let hook = hook.clone();
743            tokio::spawn(async move {
744                let () = hook(event).await;
745            });
746        }
747    }
748
749    fn create_event_ingress(
750        mob_handle: MobHandle,
751        agent_mob_mcp_state: Option<Arc<meerkat_mob_mcp::MobMcpState>>,
752        mob_events: MobEventsStore,
753    ) -> MobEventIngress {
754        // Keep forwarding bounded to avoid unbounded memory growth under sustained ingress.
755        let (event_tx, event_rx) = tokio::sync::mpsc::channel(256);
756        let task = tokio::spawn(run_resilient_mob_agent_event_forwarder(
757            mob_handle,
758            agent_mob_mcp_state,
759            event_tx,
760            mob_events,
761        ));
762        MobEventIngress::Forwarder(MobEventForwarder { event_rx, task })
763    }
764
765    async fn rollback_mob_runtime(
766        mob_runtime: MobRuntime,
767        startup_error: UnifiedRuntimeBootstrapError,
768    ) -> Result<Self, UnifiedRuntimeBootstrapError> {
769        match mob_runtime.handle().stop().await {
770            Ok(()) => Err(startup_error),
771            Err(err) => Err(UnifiedRuntimeBootstrapError::ModuleStartupRollbackFailed {
772                startup_error: Box::new(startup_error),
773                rollback_error: MobRuntimeError::from(err),
774            }),
775        }
776    }
777}
778
779type TaggedAgentEvent = (
780    AgentRuntimeId,
781    FenceToken,
782    ProfileName,
783    meerkat_core::event::EventEnvelope<AgentEvent>,
784);
785
786enum ForwardedAgentEvent {
787    Event(Box<TaggedAgentEvent>),
788    Closed(TrackedAgentEventStream),
789}
790
791type TrackedAgentEventStream = (String, AgentIdentity, AgentRuntimeId, FenceToken);
792type TaggedAgentEventStream = BoxStream<'static, ForwardedAgentEvent>;
793
794/// Per-member subscribe-failure backoff for the console agent-event
795/// forwarder. The forwarder reconciles every 250ms; without backoff a
796/// member that keeps failing `subscribe_agent_events` is retried 4×/s
797/// indefinitely and floods the log (observed: ~49k "failed to subscribe"
798/// warnings over 3.4h on a single wedged-retiring alias). We retry with
799/// exponential backoff and warn only on the first failure.
800struct SubscribeBackoff {
801    next_attempt: tokio::time::Instant,
802    consecutive_failures: u32,
803}
804
805/// First retry waits one reconcile tick; subsequent retries double up to a
806/// cap so a persistently-unsubscribable member costs at most ~1 attempt per
807/// `SUBSCRIBE_BACKOFF_MAX` instead of one per tick.
808const SUBSCRIBE_BACKOFF_BASE: Duration = Duration::from_millis(250);
809const SUBSCRIBE_BACKOFF_MAX: Duration = Duration::from_secs(30);
810
811fn subscribe_backoff_delay(consecutive_failures: u32) -> Duration {
812    SUBSCRIBE_BACKOFF_BASE
813        .saturating_mul(1u32 << consecutive_failures.min(7))
814        .min(SUBSCRIBE_BACKOFF_MAX)
815}
816
817/// Whether the console forwarder should hold a live agent-event subscription
818/// for a member in this lifecycle state. Only `Active` members have a live
819/// runtime delta stream; subscribing a `Retiring`/`Broken`/`Completed` member
820/// (which can still carry stale binding atoms) fails every reconcile tick.
821fn forwarder_should_subscribe(status: MobMemberStatus) -> bool {
822    matches!(status, MobMemberStatus::Active)
823}
824
825async fn run_resilient_mob_agent_event_forwarder(
826    handle: MobHandle,
827    agent_mob_mcp_state: Option<Arc<meerkat_mob_mcp::MobMcpState>>,
828    event_tx: Sender<EventEnvelope<UnifiedEvent>>,
829    mob_events: MobEventsStore,
830) {
831    let mut streams: SelectAll<TaggedAgentEventStream> = SelectAll::new();
832    let mut tracked = HashSet::new();
833    let mut subscribe_failures: HashMap<TrackedAgentEventStream, SubscribeBackoff> = HashMap::new();
834    let mut reconcile_interval = tokio::time::interval(Duration::from_millis(250));
835    #[cfg(not(target_arch = "wasm32"))]
836    reconcile_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
837
838    Box::pin(reconcile_agent_event_streams(
839        &handle,
840        &agent_mob_mcp_state,
841        &mut tracked,
842        &mut subscribe_failures,
843        &mut streams,
844    ))
845    .await;
846
847    loop {
848        tokio::select! {
849            Some(forwarded) = streams.next() => {
850                match forwarded {
851                    ForwardedAgentEvent::Event(event) => {
852                        let (source, source_fence_token, role, envelope) = *event;
853                        let attributed_event = AttributedEvent {
854                            source,
855                            source_fence_token,
856                            role,
857                            envelope,
858                        };
859                        // Fan out to the structural mob events store. Today this is a
860                        // no-op for attributed agent events (they don't carry mob/run/
861                        // step fields), but the projection seam keeps the surface
862                        // symmetric with the structural `MobEvent` subscriber and lets
863                        // future code add attribution without touching this shape.
864                        let _ = mob_events.project_attributed_event(&attributed_event).await;
865                        if event_tx
866                            .send(attributed_event_to_unified(attributed_event))
867                            .await
868                            .is_err()
869                        {
870                            break;
871                        }
872                    }
873                    ForwardedAgentEvent::Closed(tracked_key) => {
874                        tracked.remove(&tracked_key);
875                    }
876                }
877            }
878            _ = reconcile_interval.tick() => {
879                Box::pin(reconcile_agent_event_streams(&handle, &agent_mob_mcp_state, &mut tracked, &mut subscribe_failures, &mut streams)).await;
880            }
881        }
882    }
883}
884
885async fn reconcile_agent_event_streams(
886    handle: &MobHandle,
887    agent_mob_mcp_state: &Option<Arc<meerkat_mob_mcp::MobMcpState>>,
888    tracked: &mut HashSet<TrackedAgentEventStream>,
889    subscribe_failures: &mut HashMap<TrackedAgentEventStream, SubscribeBackoff>,
890    streams: &mut SelectAll<TaggedAgentEventStream>,
891) {
892    let mut handles = vec![handle.clone()];
893    if let Some(state) = agent_mob_mcp_state {
894        let primary_mob_id = handle.mob_id().to_string();
895        handles.extend(
896            Box::pin(state.mob_handles_snapshot())
897                .await
898                .unwrap_or_default()
899                .into_iter()
900                .filter_map(|(mob_id, child_handle)| {
901                    if mob_id.as_str() == primary_mob_id {
902                        None
903                    } else {
904                        Some(child_handle)
905                    }
906                }),
907        );
908    }
909
910    let mut current: HashSet<TrackedAgentEventStream> = HashSet::new();
911    for handle in &handles {
912        let mob_id = handle.mob_id().to_string();
913        for entry in handle.list_members_including_retiring().await {
914            // Members without current machine-supplied binding atoms have no
915            // live runtime stream to track; their stale streams age out.
916            let Some((runtime_id, fence_token)) = entry.binding_atoms() else {
917                continue;
918            };
919            current.insert((
920                mob_id.clone(),
921                entry.agent_identity.clone(),
922                runtime_id,
923                fence_token,
924            ));
925        }
926    }
927
928    tracked.retain(|tracked_key| current.contains(tracked_key));
929    // Drop backoff bookkeeping for members that have left the roster so the
930    // map can't grow without bound across the runtime's lifetime.
931    subscribe_failures.retain(|key, _| current.contains(key));
932
933    for handle in handles {
934        let mob_id = handle.mob_id().to_string();
935        for entry in handle.list_members_including_retiring().await {
936            let identity = entry.agent_identity.clone();
937            // No binding atoms means no live runtime to subscribe to.
938            let Some((runtime_id, fence_token)) = entry.binding_atoms() else {
939                continue;
940            };
941            let tracked_key = (
942                mob_id.clone(),
943                identity.clone(),
944                runtime_id.clone(),
945                fence_token,
946            );
947            if tracked.contains(&tracked_key) {
948                continue;
949            }
950
951            // Only Active members have a live runtime delta stream to attach
952            // to. A Retiring/Broken/Completed member can still carry stale
953            // binding atoms (so `binding_atoms()` is Some) while its session
954            // injector is already gone, which makes `subscribe_agent_events`
955            // fail every reconcile tick — the source of the 4×/s forwarder
956            // hot-loop. Such members are skipped here; their final events
957            // arrive via the structural ledger / session-history backfill and
958            // their streams age out through `tracked.retain`.
959            if !forwarder_should_subscribe(entry.status) {
960                subscribe_failures.remove(&tracked_key);
961                continue;
962            }
963
964            // Back off an Active member that keeps failing to subscribe (a
965            // genuinely stuck injector), so even that case can't spin the log.
966            let now = tokio::time::Instant::now();
967            if let Some(backoff) = subscribe_failures.get(&tracked_key)
968                && now < backoff.next_attempt
969            {
970                continue;
971            }
972
973            let role = entry.role.clone();
974
975            match subscribe_agent_events_for_console_forwarder(&handle, &identity).await {
976                Ok(stream) => {
977                    let close_key = tracked_key.clone();
978                    subscribe_failures.remove(&tracked_key);
979                    tracked.insert(tracked_key);
980                    let mapped = stream
981                        .map(move |envelope| {
982                            ForwardedAgentEvent::Event(Box::new((
983                                runtime_id.clone(),
984                                fence_token,
985                                role.clone(),
986                                envelope,
987                            )))
988                        })
989                        .chain(futures::stream::once(async move {
990                            ForwardedAgentEvent::Closed(close_key)
991                        }))
992                        .boxed();
993                    streams.push(mapped);
994                }
995                Err(error) => {
996                    // Usually a short-lived spawn/resume race while Meerkat
997                    // finishes installing the session event injector. Retry
998                    // with exponential backoff and warn only on the first
999                    // failure so a persistent failure can't flood the log.
1000                    let backoff =
1001                        subscribe_failures
1002                            .entry(tracked_key)
1003                            .or_insert(SubscribeBackoff {
1004                                next_attempt: now,
1005                                consecutive_failures: 0,
1006                            });
1007                    if backoff.consecutive_failures == 0 {
1008                        tracing::warn!(
1009                            mob_id = %mob_id,
1010                            identity = %identity,
1011                            error = %error,
1012                            "mobkit agent event forwarder: failed to subscribe; will retry with backoff"
1013                        );
1014                    } else {
1015                        tracing::debug!(
1016                            mob_id = %mob_id,
1017                            identity = %identity,
1018                            error = %error,
1019                            consecutive_failures = backoff.consecutive_failures,
1020                            "mobkit agent event forwarder: subscribe still failing; backing off"
1021                        );
1022                    }
1023                    backoff.next_attempt =
1024                        now + subscribe_backoff_delay(backoff.consecutive_failures);
1025                    backoff.consecutive_failures = backoff.consecutive_failures.saturating_add(1);
1026                }
1027            }
1028        }
1029    }
1030}
1031
1032async fn subscribe_agent_events_for_console_forwarder(
1033    handle: &MobHandle,
1034    identity: &AgentIdentity,
1035) -> Result<EventStream, meerkat_mob::MobError> {
1036    // Keep the console forwarder on the same authoritative subscription path
1037    // as `/agents/{id}/events`. The observation shortcut can lag the actor's
1038    // runtime-member projection in identity-first/runtime-backed packs, which
1039    // leaves the console with only session-history backfill while direct agent
1040    // SSE streams live deltas correctly.
1041    handle.subscribe_agent_events(identity).await
1042}
1043
1044/// Streaming subscription against the meerkat mob event ledger. Each
1045/// projected envelope's cursor is the upstream `MobEvent.cursor`; after
1046/// projection the cursor is checkpointed via `persistent_metadata` so
1047/// the next runtime instance can resume from where this one left off.
1048///
1049/// Resume semantics on startup:
1050/// - persisted cursor present → `subscribe_after(cursor)`. On
1051///   `MobError::StaleEventCursor` (the ledger has been truncated past
1052///   our checkpoint) the task logs a warning and falls through to a
1053///   fresh `subscribe()` at the current latest.
1054/// - no persisted cursor → `subscribe()` (latest, no replay).
1055///
1056/// Exits when the upstream `event_rx` closes (machine destroyed) or
1057/// when subscription setup fails after a stale-cursor fallback.
1058async fn run_mob_events_subscription(
1059    handle: MobHandle,
1060    store: MobEventsStore,
1061    persistent_metadata: Arc<dyn PersistentMetadataStore>,
1062) {
1063    let mob_id = handle.mob_id().as_str().to_string();
1064    let resume_cursor = match persistent_metadata.get_subscription_cursor(&mob_id).await {
1065        Ok(value) => value,
1066        Err(err) => {
1067            tracing::warn!(
1068                mob_id = %mob_id,
1069                error = %err,
1070                "mob_events subscription: failed to read persisted cursor; resuming from latest"
1071            );
1072            None
1073        }
1074    };
1075
1076    let events = handle.events();
1077    let mut subscription = match resume_cursor {
1078        Some(cursor) => match events.subscribe_after(cursor).await {
1079            Ok(sub) => sub,
1080            Err(MobError::StaleEventCursor {
1081                after_cursor,
1082                latest_cursor,
1083            }) => {
1084                tracing::warn!(
1085                    mob_id = %mob_id,
1086                    after_cursor,
1087                    latest_cursor,
1088                    "mob_events subscription: persisted cursor is past ledger frontier; resuming at latest"
1089                );
1090                match events.subscribe().await {
1091                    Ok(sub) => sub,
1092                    Err(err) => {
1093                        tracing::warn!(
1094                            mob_id = %mob_id,
1095                            error = %err,
1096                            "mob_events subscription: failed to subscribe at latest after stale-cursor recovery"
1097                        );
1098                        return;
1099                    }
1100                }
1101            }
1102            Err(err) => {
1103                tracing::warn!(
1104                    mob_id = %mob_id,
1105                    error = %err,
1106                    "mob_events subscription: failed to resume from persisted cursor"
1107                );
1108                return;
1109            }
1110        },
1111        None => match events.subscribe().await {
1112            Ok(sub) => sub,
1113            Err(err) => {
1114                tracing::warn!(
1115                    mob_id = %mob_id,
1116                    error = %err,
1117                    "mob_events subscription: initial subscribe failed"
1118                );
1119                return;
1120            }
1121        },
1122    };
1123
1124    while let Some(event) = subscription.event_rx.recv().await {
1125        let envelope = store.project_mob_event(&event).await;
1126        if let Err(err) = persistent_metadata
1127            .set_subscription_cursor(&mob_id, envelope.cursor)
1128            .await
1129        {
1130            tracing::warn!(
1131                mob_id = %mob_id,
1132                cursor = envelope.cursor,
1133                error = %err,
1134                "mob_events subscription: failed to persist cursor; continuing"
1135            );
1136        }
1137    }
1138}
1139
1140fn attributed_event_to_unified(attributed: AttributedEvent) -> EventEnvelope<UnifiedEvent> {
1141    EventEnvelope {
1142        event_id: format!("evt-agent-{}", attributed.envelope.event_id),
1143        source: "agent".to_string(),
1144        timestamp_ms: attributed.envelope.timestamp_ms,
1145        event: UnifiedEvent::Agent {
1146            // The runtime id's member component is the comms-safe roster
1147            // encoding (meerkat 0.7 `MemberCommsName`); decode back to the
1148            // public alias space here so console replay resolution, the
1149            // `mobkit/events/subscribe` buffer, and the event log all key
1150            // events by the same ids that spawn/reserve paths register.
1151            agent_id: crate::member_comms_id::runtime_event_alias(&attributed.source),
1152            event_type: agent_event_type(&attributed.envelope.payload).to_string(),
1153            // Project through the console wire shape (not the raw 0.7 event)
1154            // so downstream surfaces — console timeline frames, the
1155            // `mobkit/events/subscribe` replay buffer, and the event-log
1156            // query — keep the `result`/`tool_call_id` keys the SDKs parse.
1157            payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1158                &attributed.envelope.payload,
1159            )),
1160        },
1161    }
1162}
1163
1164/// Projects [`crate::memory::events::MemoryTimelineEvent`]s onto the
1165/// console timeline. Sync fire-and-forget: the async append is spawned on
1166/// the captured runtime handle, so emitters inside mutexes or blocking
1167/// threads never wait on the event surface.
1168struct ConsoleMemoryEventSink {
1169    store: ConsoleEventStore,
1170    handle: tokio::runtime::Handle,
1171}
1172
1173impl crate::memory::events::MemoryEventSink for ConsoleMemoryEventSink {
1174    fn emit(&self, event: crate::memory::events::MemoryTimelineEvent) {
1175        let store = self.store.clone();
1176        let identity = event
1177            .identity()
1178            .map(str::to_string)
1179            .unwrap_or_else(|| crate::console_contracts::SYSTEM_EVENT_IDENTITY.to_string());
1180        let event_type = event.event_type().to_string();
1181        let data = event.data();
1182        self.handle.spawn(async move {
1183            store.append(identity, None, event_type, data).await;
1184        });
1185    }
1186}
1187
1188#[cfg(test)]
1189#[allow(clippy::expect_used, clippy::panic)]
1190mod tests {
1191    use super::*;
1192    use meerkat_mob::ids::Generation;
1193
1194    fn attributed_text_delta(member_id: &str, generation: u64) -> AttributedEvent {
1195        AttributedEvent {
1196            source: AgentRuntimeId::new(
1197                AgentIdentity::from(member_id),
1198                Generation::new(generation),
1199            ),
1200            source_fence_token: FenceToken::new(1),
1201            role: ProfileName::from("worker"),
1202            envelope: meerkat_core::event::EventEnvelope {
1203                event_id: Default::default(),
1204                source: meerkat_core::event::EventSourceIdentity::runtime("test"),
1205                seq: 0,
1206                mob_id: None,
1207                timestamp_ms: 1,
1208                payload: AgentEvent::TextDelta {
1209                    delta: "hello".to_string(),
1210                },
1211            },
1212        }
1213    }
1214
1215    /// Regression: identity-first members spawn under comms-safe encoded
1216    /// roster ids (`mk--…`); the agent-event ingest must decode the member
1217    /// component back to the public alias space before console/SDK
1218    /// projection, or events project under junk identities and reserved
1219    /// interactions never complete.
1220    #[test]
1221    fn attributed_event_ingest_decodes_encoded_roster_member_ids() {
1222        let encoded = crate::member_comms_id::mob_member_id_str("rt:review:singleton:0");
1223        assert!(encoded.starts_with("mk--"), "precondition: alias encodes");
1224        let unified = attributed_event_to_unified(attributed_text_delta(&encoded, 1));
1225        let UnifiedEvent::Agent { agent_id, .. } = unified.event else {
1226            panic!("expected agent event");
1227        };
1228        assert_eq!(agent_id, "rt:review:singleton:0:1");
1229    }
1230
1231    #[test]
1232    fn attributed_event_ingest_passes_plain_member_ids_through() {
1233        let unified = attributed_event_to_unified(attributed_text_delta("worker-one", 0));
1234        let UnifiedEvent::Agent { agent_id, .. } = unified.event else {
1235            panic!("expected agent event");
1236        };
1237        assert_eq!(agent_id, "worker-one:0");
1238    }
1239
1240    /// Regression: the console forwarder must only hold a live subscription
1241    /// for Active members. A Retiring member can keep stale binding atoms
1242    /// while its session injector is gone, so subscribing it fails every
1243    /// 250ms reconcile tick — the 4×/s "failed to subscribe" hot-loop
1244    /// (observed ~49k warnings over 3.4h on one wedged-retiring alias).
1245    #[test]
1246    fn forwarder_only_subscribes_active_members() {
1247        assert!(forwarder_should_subscribe(MobMemberStatus::Active));
1248        assert!(!forwarder_should_subscribe(MobMemberStatus::Retiring));
1249        assert!(!forwarder_should_subscribe(MobMemberStatus::Broken));
1250        assert!(!forwarder_should_subscribe(MobMemberStatus::Completed));
1251        assert!(!forwarder_should_subscribe(MobMemberStatus::Unknown));
1252    }
1253
1254    /// The backoff for a persistently-failing Active subscribe must grow from
1255    /// one reconcile tick and cap, so even a genuinely stuck member retries at
1256    /// most ~once per cap instead of 4×/s.
1257    #[test]
1258    fn subscribe_backoff_grows_and_caps() {
1259        assert_eq!(subscribe_backoff_delay(0), SUBSCRIBE_BACKOFF_BASE);
1260        assert_eq!(subscribe_backoff_delay(1), SUBSCRIBE_BACKOFF_BASE * 2);
1261        assert_eq!(subscribe_backoff_delay(3), SUBSCRIBE_BACKOFF_BASE * 8);
1262        assert_eq!(subscribe_backoff_delay(7), SUBSCRIBE_BACKOFF_MAX);
1263        // Saturates at the cap for arbitrarily many failures (no shift overflow).
1264        assert_eq!(subscribe_backoff_delay(50), SUBSCRIBE_BACKOFF_MAX);
1265        assert!(subscribe_backoff_delay(2) > subscribe_backoff_delay(1));
1266    }
1267}