1use 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
61pub type PostSpawnHook =
63 Arc<dyn Fn(Vec<String>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
64
65pub type PostReconcileHook = Arc<
67 dyn Fn(UnifiedRuntimeReconcileReport) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
68>;
69
70pub 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
82pub 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 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 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 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 contact_directory: Option<crate::contact_directory::ContactDirectory>,
146 peer_mob_handles: tokio::sync::RwLock<BTreeMap<String, MobHandle>>,
147 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
154
155 session_bridge: Option<Arc<dyn crate::identity_first::bridge::SessionBridge>>,
157 identity_first_context: Option<Arc<crate::identity_first::IdentityFirstRuntimeContext>>,
158
159 access_controller: Option<crate::access::AccessController>,
161
162 memory_panel_store:
166 std::sync::RwLock<Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>>,
167
168 metadata_table: Arc<RuntimeMetadataTable>,
170
171 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 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 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 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 pub async fn bootstrap_edges_report(&self) -> Option<UnifiedRuntimeReconcileEdgesReport> {
331 self.bootstrap_edges_report.read().await.clone()
332 }
333
334 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 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 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 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 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 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 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 pub fn metadata_table(&self) -> &Arc<RuntimeMetadataTable> {
555 &self.metadata_table
556 }
557
558 pub fn set_access_controller(&mut self, controller: crate::access::AccessController) {
561 self.access_controller = Some(controller);
562 }
563
564 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 pub fn access_controller(&self) -> Option<&crate::access::AccessController> {
589 self.access_controller.as_ref()
590 }
591
592 pub fn persistent_metadata(&self) -> &Arc<dyn PersistentMetadataStore> {
597 &self.persistent_metadata
598 }
599
600 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 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 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 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 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 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 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 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 pub fn subscribe_mob_events(
694 &self,
695 ) -> tokio::sync::broadcast::Receiver<MobStructuralEventEnvelope> {
696 self.mob_events.subscribe()
697 }
698
699 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 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 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
794struct SubscribeBackoff {
801 next_attempt: tokio::time::Instant,
802 consecutive_failures: u32,
803}
804
805const 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
817fn 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 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 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 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 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 if !forwarder_should_subscribe(entry.status) {
960 subscribe_failures.remove(&tracked_key);
961 continue;
962 }
963
964 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 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 handle.subscribe_agent_events(identity).await
1042}
1043
1044async 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 agent_id: crate::member_comms_id::runtime_event_alias(&attributed.source),
1152 event_type: agent_event_type(&attributed.envelope.payload).to_string(),
1153 payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1158 &attributed.envelope.payload,
1159 )),
1160 },
1161 }
1162}
1163
1164struct 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 #[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 #[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 #[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 assert_eq!(subscribe_backoff_delay(50), SUBSCRIBE_BACKOFF_MAX);
1265 assert!(subscribe_backoff_delay(2) > subscribe_backoff_delay(1));
1266 }
1267}