use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use futures::stream::{BoxStream, SelectAll, StreamExt};
use meerkat_core::comms::EventStream;
use meerkat_core::event::{AgentEvent, agent_event_type};
use meerkat_mob::{
AgentIdentity, AgentRuntimeId, AttributedEvent, FenceToken, MobError, MobHandle,
MobMemberStatus, ProfileName, SpawnMemberSpec,
};
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::task::JoinHandle;
pub(crate) use self::console_events::ConsoleEventStore;
use self::mob_events::MobEventsStore;
use crate::console_aggregator::{ConsoleLogStore, InMemoryConsoleLogStore};
use crate::mob_handle_runtime::{MobBootstrapSpec, MobRuntime, MobRuntimeError};
use crate::runtime::{
InMemoryMetadataStore, MetadataScope, MobkitRuntimeHandle, PersistentMetadataStore,
RuntimeMetadataTable, RuntimeOptions, start_mobkit_runtime_with_options,
};
use crate::types::{
AgentDiscoverySpec, EventEnvelope, MobKitConfig, MobStructuralEventEnvelope, UnifiedEvent,
};
pub mod builder;
pub(crate) mod console_events;
pub mod cross_mob;
pub mod edge_reconcile;
pub mod edge_types;
pub mod event_log;
pub mod http;
pub(crate) mod implicit_delegate_retirement;
pub mod lifecycle;
pub mod mob_events;
pub mod mob_ops;
pub mod module_ops;
pub mod types;
pub use crate::identity_first::IdentityBootstrapMode;
pub use builder::UnifiedRuntimeBuilder;
pub use edge_types::{
DesiredPeerEdge, DesiredPeerEdgeError, Discovery, EdgeDiscovery, EdgeReconcileFailure,
PreSpawnContext, PreSpawnHook,
};
pub use event_log::{EventLogConfig, EventLogError, EventLogStore, EventQuery, PersistedEvent};
pub use http::DEFAULT_REFERENCE_APP_MAX_CONCURRENT_REQUESTS;
pub use mob_ops::MemberTurnAdmission;
pub use types::{
ErrorEvent, IdentityAuthorityReleaseOutcome, RediscoverReport, ShutdownDrainReport,
UnifiedRuntimeBootstrapError, UnifiedRuntimeBuilderError, UnifiedRuntimeBuilderField,
UnifiedRuntimeError, UnifiedRuntimeReconcileEdgesReport, UnifiedRuntimeReconcileError,
UnifiedRuntimeReconcileReport, UnifiedRuntimeReconcileRoutingReport, UnifiedRuntimeRunReport,
UnifiedRuntimeShutdownReport,
};
pub type PostSpawnHook =
Arc<dyn Fn(Vec<String>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
pub type PostReconcileHook = Arc<
dyn Fn(UnifiedRuntimeReconcileReport) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
>;
pub type ErrorHook =
Arc<dyn Fn(ErrorEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
const ROSTER_ROUTE_PREFIX: &str = "mob.member.";
const ROSTER_ROUTE_CHANNEL: &str = "notification";
const ROSTER_ROUTE_SINK: &str = "mob_member";
const ROSTER_ROUTE_TARGET_MODULE: &str = "delivery";
const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
pub fn discovery_spec_to_spawn_spec(spec: &AgentDiscoverySpec) -> SpawnMemberSpec {
let resume_session_id = spec
.resume_session_id
.as_deref()
.and_then(|s| meerkat_core::types::SessionId::parse(s).ok());
let additional_instructions = if spec.additional_instructions.is_empty() {
None
} else {
Some(spec.additional_instructions.clone())
};
let mut spawn = SpawnMemberSpec::new(
meerkat_mob::ProfileName::from(spec.profile.as_str()),
meerkat_mob::ids::AgentIdentity::from(spec.meerkat_id.as_str()),
);
if let Some(context) = spec.context.clone() {
spawn = spawn.with_context(context);
}
if let Some(labels) = spec.labels.clone() {
spawn = spawn.with_labels(labels);
}
if let Some(sid) = resume_session_id {
spawn = spawn.with_resume_bridge_session_id(sid);
}
if let Some(instructions) = additional_instructions {
spawn = spawn.with_additional_instructions(instructions);
}
spawn
}
pub struct UnifiedRuntime {
mob_runtime: MobRuntime,
post_spawn_hook: Option<PostSpawnHook>,
post_reconcile_hook: Option<PostReconcileHook>,
error_hook: Option<ErrorHook>,
drain_timeout: Duration,
discovery: Option<Box<dyn Discovery>>,
edge_discovery: Option<Arc<dyn EdgeDiscovery>>,
module_runtime: Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>,
managed_dynamic_edges: Arc<tokio::sync::RwLock<BTreeSet<(String, String)>>>,
shutting_down: AtomicBool,
mob_event_ingress: tokio::sync::Mutex<Option<MobEventIngress>>,
bootstrap_edges_report: tokio::sync::RwLock<Option<UnifiedRuntimeReconcileEdgesReport>>,
event_log: Option<event_log::EventLogHandle>,
console_log_store: Arc<dyn ConsoleLogStore>,
console_events: ConsoleEventStore,
mob_events: MobEventsStore,
mob_events_subscriber_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
implicit_delegate_retirement_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
implicit_delegate_identity_runtime:
Arc<std::sync::RwLock<Option<Arc<crate::identity_first::IdentityRuntime>>>>,
identity_lease_renewal_task:
tokio::sync::Mutex<Option<crate::identity_first::runtime::TrackedLeaseRenewalTask>>,
identity_continuity_repair_task:
tokio::sync::Mutex<Option<crate::identity_first::runtime::TrackedContinuityRepairTask>>,
agent_memory_observer_task:
tokio::sync::Mutex<Option<crate::memory::taint::TaintObserverGuard>>,
agent_memory_steward_task: tokio::sync::Mutex<Option<JoinHandle<()>>>,
contact_directory: Option<crate::contact_directory::ContactDirectory>,
peer_mob_handles: tokio::sync::RwLock<BTreeMap<String, cross_mob::PeerMobAuthority>>,
gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
session_bridge: Option<Arc<dyn crate::identity_first::bridge::SessionBridge>>,
identity_first_context: Option<Arc<crate::identity_first::IdentityFirstRuntimeContext>>,
access_controller: Option<crate::access::AccessController>,
topology_controller: crate::topology_control::TopologyController,
memory_panel_store:
std::sync::RwLock<Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>>,
workgraph_service: Option<meerkat::WorkGraphService>,
console_identity_roster:
std::sync::RwLock<Option<Arc<crate::identity_first::MutableRosterProvider>>>,
console_operator_resolver: std::sync::RwLock<
Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>>,
>,
metadata_table: Arc<RuntimeMetadataTable>,
persistent_metadata: Arc<dyn PersistentMetadataStore>,
}
enum MobEventIngress {
Forwarder(MobEventForwarder),
}
struct MobEventForwarder {
event_rx: Receiver<EventEnvelope<UnifiedEvent>>,
task: JoinHandle<()>,
}
impl UnifiedRuntime {
pub fn builder() -> UnifiedRuntimeBuilder {
UnifiedRuntimeBuilder::default()
}
pub(crate) async fn from_parts(
mob_runtime: MobRuntime,
module_runtime: MobkitRuntimeHandle,
persistent_metadata: Arc<dyn PersistentMetadataStore>,
) -> Self {
let metadata_table = Arc::new(RuntimeMetadataTable::new());
let mob_events_store = MobEventsStore::new().with_metadata_table(metadata_table.clone());
let mob_event_ingress = Some(Self::create_event_ingress(
mob_runtime.handle(),
mob_runtime.agent_mob_mcp_state(),
mob_events_store.clone(),
));
let mob_events_task = Self::spawn_mob_events_subscriber(
mob_runtime.handle(),
mob_events_store.clone(),
persistent_metadata.clone(),
);
let console_events = ConsoleEventStore::new();
mob_runtime.install_console_spawn_sink(crate::console_spawn::ConsoleSpawnSink::new(
console_events.clone(),
));
let workgraph_service = mob_runtime.workgraph_service();
let definition_edge_discovery =
edge_reconcile::DefinitionWiringEdgeDiscovery::from_definition(
mob_runtime.handle().definition(),
)
.map(|policy| Arc::new(policy) as Arc<dyn EdgeDiscovery>);
Self {
mob_runtime,
post_spawn_hook: None,
post_reconcile_hook: None,
error_hook: None,
drain_timeout: DEFAULT_DRAIN_TIMEOUT,
discovery: None,
edge_discovery: definition_edge_discovery,
module_runtime: Arc::new(tokio::sync::Mutex::new(module_runtime)),
managed_dynamic_edges: Arc::new(tokio::sync::RwLock::new(BTreeSet::new())),
shutting_down: AtomicBool::new(false),
mob_event_ingress: tokio::sync::Mutex::new(mob_event_ingress),
bootstrap_edges_report: tokio::sync::RwLock::new(None),
event_log: None,
console_log_store: Arc::new(InMemoryConsoleLogStore::new()),
console_events,
mob_events: mob_events_store,
mob_events_subscriber_task: tokio::sync::Mutex::new(mob_events_task),
implicit_delegate_retirement_task: tokio::sync::Mutex::new(None),
implicit_delegate_identity_runtime: Arc::new(std::sync::RwLock::new(None)),
identity_lease_renewal_task: tokio::sync::Mutex::new(None),
identity_continuity_repair_task: tokio::sync::Mutex::new(None),
agent_memory_observer_task: tokio::sync::Mutex::new(None),
agent_memory_steward_task: tokio::sync::Mutex::new(None),
contact_directory: None,
peer_mob_handles: tokio::sync::RwLock::new(BTreeMap::new()),
gateway_peer_keys: None,
session_bridge: None,
identity_first_context: None,
access_controller: None,
topology_controller: crate::topology_control::TopologyController::default(),
memory_panel_store: std::sync::RwLock::new(None),
workgraph_service,
console_identity_roster: std::sync::RwLock::new(None),
console_operator_resolver: std::sync::RwLock::new(None),
metadata_table,
persistent_metadata,
}
}
fn spawn_mob_events_subscriber(
handle: MobHandle,
store: MobEventsStore,
persistent_metadata: Arc<dyn PersistentMetadataStore>,
) -> Option<JoinHandle<()>> {
let runtime_handle = tokio::runtime::Handle::try_current().ok()?;
Some(runtime_handle.spawn(run_mob_events_subscription(
handle,
store,
persistent_metadata,
)))
}
pub async fn bootstrap(
mob_spec: MobBootstrapSpec,
module_config: MobKitConfig,
timeout: Duration,
) -> Result<Self, UnifiedRuntimeBootstrapError> {
Box::pin(Self::bootstrap_with_options(
mob_spec,
module_config,
Vec::new(),
timeout,
RuntimeOptions::default(),
Arc::new(InMemoryMetadataStore::new()),
))
.await
}
pub async fn bootstrap_with_options(
mob_spec: MobBootstrapSpec,
module_config: MobKitConfig,
module_agent_events: Vec<EventEnvelope<UnifiedEvent>>,
timeout: Duration,
options: RuntimeOptions,
persistent_metadata: Arc<dyn PersistentMetadataStore>,
) -> Result<Self, UnifiedRuntimeBootstrapError> {
Self::bootstrap_with_options_and_topology(
mob_spec,
module_config,
module_agent_events,
timeout,
options,
persistent_metadata,
crate::topology_control::TopologyBootstrapConfig::default(),
)
.await
}
pub async fn bootstrap_with_topology(
mob_spec: MobBootstrapSpec,
module_config: MobKitConfig,
timeout: Duration,
topology: crate::topology_control::TopologyBootstrapConfig,
) -> Result<Self, UnifiedRuntimeBootstrapError> {
Self::bootstrap_with_options_and_topology(
mob_spec,
module_config,
Vec::new(),
timeout,
RuntimeOptions::default(),
Arc::new(InMemoryMetadataStore::new()),
topology,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn bootstrap_with_options_and_topology(
mob_spec: MobBootstrapSpec,
module_config: MobKitConfig,
module_agent_events: Vec<EventEnvelope<UnifiedEvent>>,
timeout: Duration,
options: RuntimeOptions,
persistent_metadata: Arc<dyn PersistentMetadataStore>,
topology: crate::topology_control::TopologyBootstrapConfig,
) -> Result<Self, UnifiedRuntimeBootstrapError> {
let topology_authority = mob_spec.definition.id.to_string();
let topology_controller = match topology.state_path {
Some(path) => {
crate::topology_control::TopologyController::load_or_default(topology.policy, path)
}
None => crate::topology_control::TopologyController::new(topology.policy),
}
.map_err(|error| UnifiedRuntimeBootstrapError::Topology(error.to_string()))?;
topology_controller
.bind_authority(topology_authority)
.await
.map_err(|error| UnifiedRuntimeBootstrapError::Topology(error.to_string()))?;
let mob_runtime = MobRuntime::bootstrap(mob_spec)
.await
.map_err(UnifiedRuntimeBootstrapError::Mob)?;
let runtime_options = options.clone();
let module_start_result = std::thread::spawn(move || {
start_mobkit_runtime_with_options(module_config, module_agent_events, timeout, options)
})
.join();
match module_start_result {
Ok(Ok(module_runtime)) => {
let mut runtime =
Self::from_parts(mob_runtime, module_runtime, persistent_metadata).await;
runtime.topology_controller = topology_controller;
runtime
.configure_implicit_delegate_retirement(&runtime_options)
.await;
if runtime.edge_discovery.is_some()
|| runtime.topology_controller.revision().await > 0
|| runtime.topology_controller.has_pending().await
{
let report = runtime.reconcile_edges().await;
*runtime.bootstrap_edges_report.write().await = Some(report);
}
Ok(runtime)
}
Ok(Err(error)) => {
let startup_error = UnifiedRuntimeBootstrapError::Module(error);
Self::rollback_mob_runtime(mob_runtime, startup_error).await
}
Err(_) => {
let startup_error = UnifiedRuntimeBootstrapError::ModuleStartupThreadPanicked;
Self::rollback_mob_runtime(mob_runtime, startup_error).await
}
}
}
pub async fn bootstrap_edges_report(&self) -> Option<UnifiedRuntimeReconcileEdgesReport> {
self.bootstrap_edges_report.read().await.clone()
}
pub fn set_error_hook(&mut self, hook: ErrorHook) {
self.error_hook = Some(hook.clone());
if let Some(identity_runtime) = self.identity_runtime() {
identity_runtime.set_error_hook(Some(hook));
}
}
pub fn start_event_log(&mut self, config: EventLogConfig) {
let handle = event_log::start_event_log(config, self.error_hook.clone());
self.event_log = Some(handle);
}
pub(crate) fn console_events(&self) -> ConsoleEventStore {
self.console_events.clone()
}
pub fn memory_event_sink(&self) -> Arc<dyn crate::memory::events::MemoryEventSink> {
Arc::new(ConsoleMemoryEventSink {
store: self.console_events(),
handle: tokio::runtime::Handle::current(),
})
}
pub async fn register_gating_resolution_observer(
&self,
observer: Arc<dyn crate::runtime::GatingResolutionObserver>,
) {
self.module_runtime
.lock()
.await
.register_gating_resolution_observer(observer);
}
pub(crate) fn mob_events_store(&self) -> MobEventsStore {
self.mob_events.clone()
}
pub fn binary_blob_store(&self) -> Option<Arc<dyn crate::blob_store::BinaryBlobStore>> {
self.mob_runtime.binary_blob_store()
}
pub(crate) fn module_runtime_handle(&self) -> Arc<tokio::sync::Mutex<MobkitRuntimeHandle>> {
Arc::clone(&self.module_runtime)
}
pub(crate) fn mobpack_runtime_catalog_state_snapshot(
&self,
) -> crate::mobpack::MobpackRuntimeCatalogState {
let loaded_modules = self
.module_runtime
.try_lock()
.map(|runtime| runtime.loaded_modules())
.unwrap_or_default();
let has_peer_mob_handles = self
.peer_mob_handles
.try_read()
.map(|handles| !handles.is_empty())
.unwrap_or(false);
let mut runtime_methods = vec![
"mobkit/capabilities".to_string(),
"mobkit/models/catalog".to_string(),
"mobkit/spawn_member".to_string(),
"mobkit/list_members".to_string(),
"mobkit/get_member".to_string(),
"mobkit/run_flow".to_string(),
"mobkit/list_flows".to_string(),
"mobkit/list_runs".to_string(),
];
runtime_methods.extend(
crate::rpc::MOBPACK_AUTHORING_METHODS
.iter()
.map(std::string::ToString::to_string),
);
if self.has_contact_directory() {
runtime_methods.push("mobkit/cross_mob/directory".to_string());
}
if has_peer_mob_handles && self.has_inproc_contacts() {
runtime_methods.extend([
"mobkit/cross_mob/wire".to_string(),
"mobkit/cross_mob/unwire".to_string(),
"mobkit/cross_mob/send".to_string(),
]);
}
crate::mobpack::MobpackRuntimeCatalogState {
loaded_modules,
runtime_methods,
has_contact_directory: self.has_contact_directory(),
has_peer_mob_handles,
has_inproc_contacts: self.has_inproc_contacts(),
runtime_flow_rows: crate::mobpack::runtime_flow_registry_rows_from_definition(
self.mob_handle().definition(),
),
runtime_agent_definition_sources:
crate::mobpack::runtime_agent_definition_sources_from_definition(
self.mob_handle().definition(),
),
runtime_skill_realms: crate::mobpack::runtime_skill_realms_from_definition(
self.mob_handle().definition(),
),
}
}
pub fn session_bridge(&self) -> Option<&Arc<dyn crate::identity_first::bridge::SessionBridge>> {
self.session_bridge.as_ref()
}
pub fn identity_first_context(
&self,
) -> Option<&Arc<crate::identity_first::IdentityFirstRuntimeContext>> {
self.identity_first_context.as_ref()
}
pub fn identity_runtime(&self) -> Option<&Arc<crate::identity_first::IdentityRuntime>> {
self.identity_first_context.as_ref().map(|ctx| &ctx.runtime)
}
pub async fn remember_agent_memory(
&self,
realm: &str,
identity: &crate::identity_first::AgentIdentity,
memory: crate::identity_first::NewAgentMemory,
) -> Result<crate::identity_first::AgentMemoryRecord, crate::identity_first::AgentMemoryError>
{
let runtime = self.identity_runtime().ok_or_else(|| {
crate::identity_first::AgentMemoryError::InvalidConfig(
"identity-first runtime is not configured".to_string(),
)
})?;
runtime.remember_agent_memory(realm, identity, memory).await
}
pub async fn recall_agent_memory(
&self,
request: crate::identity_first::AgentMemoryRecallRequest,
) -> Result<
Vec<crate::identity_first::AgentMemoryRecord>,
crate::identity_first::AgentMemoryError,
> {
let runtime = self.identity_runtime().ok_or_else(|| {
crate::identity_first::AgentMemoryError::InvalidConfig(
"identity-first runtime is not configured".to_string(),
)
})?;
runtime.recall_agent_memory(request).await
}
pub async fn forget_agent_memory(
&self,
realm: &str,
identity: &crate::identity_first::AgentIdentity,
memory_id: &str,
) -> Result<
crate::identity_first::AgentMemoryForgetResult,
crate::identity_first::AgentMemoryError,
> {
let runtime = self.identity_runtime().ok_or_else(|| {
crate::identity_first::AgentMemoryError::InvalidConfig(
"identity-first runtime is not configured".to_string(),
)
})?;
runtime
.forget_agent_memory(realm, identity, memory_id)
.await
}
pub fn attach_identity_first_context(
&mut self,
context: Arc<crate::identity_first::IdentityFirstRuntimeContext>,
) {
self.install_identity_first_context_authority(context);
self.start_identity_first_supervisors();
}
pub async fn install_and_bootstrap_identity_first_context(
&mut self,
context: Arc<crate::identity_first::IdentityFirstRuntimeContext>,
roster: &[crate::identity_first::DurableAgentSpec],
) -> Result<crate::identity_first::RestoreFlowResult, crate::identity_first::IdentityRuntimeError>
{
self.install_identity_first_context_authority(Arc::clone(&context));
match context.bootstrap_roster(roster).await {
Ok(result) => {
self.start_identity_first_supervisors();
Ok(result)
}
Err(error) => {
self.shutdown().await;
Err(error)
}
}
}
fn install_identity_first_context_authority(
&mut self,
context: Arc<crate::identity_first::IdentityFirstRuntimeContext>,
) {
self.mob_runtime
.install_identity_runtime_authority(Arc::clone(&context.runtime));
*self
.implicit_delegate_identity_runtime
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(Arc::clone(&context.runtime));
self.identity_first_context = Some(context);
}
fn start_identity_first_supervisors(&mut self) {
let Some(context) = self.identity_first_context.clone() else {
return;
};
let lease_task = context.runtime.clone().spawn_tracked_lease_renewal_task();
if let Some(previous) = self
.identity_lease_renewal_task
.get_mut()
.replace(lease_task)
{
previous.cancel();
tokio::spawn(previous.cancel_and_join());
}
let repair_task = context.spawn_tracked_broken_identity_repair_task(Default::default());
if let Some(previous) = self
.identity_continuity_repair_task
.get_mut()
.replace(repair_task)
{
previous.cancel();
tokio::spawn(previous.cancel_and_join());
}
}
pub async fn refresh_desired_topology(
&self,
) -> Result<
Option<crate::identity_first::RestoreFlowResult>,
crate::identity_first::IdentityRuntimeError,
> {
match self.identity_first_context.as_ref() {
Some(ctx) => ctx.refresh_desired_topology_tracked().await.map(Some),
None => Ok(None),
}
}
pub async fn materialize_identity_first_for_flow(
&self,
) -> Result<
Vec<crate::identity_first::ContinuityRecord>,
crate::identity_first::IdentityRuntimeError,
> {
match self.identity_runtime() {
Some(runtime) => runtime.materialize_all_required_tracked().await,
None => Ok(Vec::new()),
}
}
pub fn metadata_table(&self) -> &Arc<RuntimeMetadataTable> {
&self.metadata_table
}
pub fn set_access_controller(&mut self, controller: crate::access::AccessController) {
self.access_controller = Some(controller);
}
pub fn set_console_identity_roster(
&self,
roster: Arc<crate::identity_first::MutableRosterProvider>,
) {
*self
.console_identity_roster
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(roster);
}
pub fn console_identity_roster(
&self,
) -> Option<Arc<crate::identity_first::MutableRosterProvider>> {
self.console_identity_roster
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn set_memory_panel_store(
&self,
store: crate::memory::sqlite_store::SqliteAgentMemoryStore,
) {
*self
.memory_panel_store
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(store);
}
pub fn memory_panel_store(
&self,
) -> Option<crate::memory::sqlite_store::SqliteAgentMemoryStore> {
self.memory_panel_store
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn workgraph_service(&self) -> Option<meerkat::WorkGraphService> {
self.workgraph_service.clone()
}
pub(crate) fn workgraph_admission(
&self,
) -> std::sync::Arc<crate::workgraph_admission::WorkGraphAdmission> {
self.mob_runtime.workgraph_admission()
}
pub fn set_console_operator_resolver(
&self,
resolver: Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>,
) {
*self
.console_operator_resolver
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(resolver);
}
pub fn console_operator_resolver(
&self,
) -> Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>> {
self.console_operator_resolver
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn access_controller(&self) -> Option<&crate::access::AccessController> {
self.access_controller.as_ref()
}
pub fn topology_controller(&self) -> &crate::topology_control::TopologyController {
&self.topology_controller
}
pub fn topology_runtime_handle(&self) -> crate::topology_control::TopologyRuntimeHandle {
crate::topology_control::TopologyRuntimeHandle::new(
self.mob_handle(),
self.edge_discovery.clone(),
Arc::clone(&self.managed_dynamic_edges),
self.topology_controller.clone(),
self.identity_first_context.clone(),
)
}
pub fn set_topology_control_policy(
&self,
policy: crate::topology_control::TopologyControlPolicy,
) -> Result<(), crate::topology_control::TopologyControlError> {
self.topology_controller.set_policy(policy)
}
pub fn persistent_metadata(&self) -> &Arc<dyn PersistentMetadataStore> {
&self.persistent_metadata
}
pub async fn set_mob_labels(&self, labels: BTreeMap<String, String>) {
self.metadata_table
.set_labels(MetadataScope::Mob(self.mob_id()), labels)
.await;
}
pub async fn get_mob_labels(&self) -> BTreeMap<String, String> {
self.metadata_table
.get_labels(&MetadataScope::Mob(self.mob_id()))
.await
}
pub async fn delete_mob_labels(&self) {
let _ = self
.metadata_table
.delete_labels(&MetadataScope::Mob(self.mob_id()))
.await;
}
pub async fn set_run_labels(&self, run_id: &str, labels: BTreeMap<String, String>) {
self.metadata_table
.set_labels(
MetadataScope::Run(self.mob_id(), run_id.to_string()),
labels,
)
.await;
}
pub async fn get_run_labels(&self, run_id: &str) -> BTreeMap<String, String> {
self.metadata_table
.get_labels(&MetadataScope::Run(self.mob_id(), run_id.to_string()))
.await
}
pub async fn delete_run_labels(&self, run_id: &str) {
let _ = self
.metadata_table
.delete_labels(&MetadataScope::Run(self.mob_id(), run_id.to_string()))
.await;
}
pub fn event_log_store(&self) -> Option<std::sync::Arc<dyn event_log::EventLogStore>> {
self.event_log
.as_ref()
.map(event_log::EventLogHandle::store)
}
pub fn console_log_store(&self) -> Arc<dyn ConsoleLogStore> {
self.console_log_store.clone()
}
pub fn set_console_log_store(&mut self, store: Arc<dyn ConsoleLogStore>) {
self.console_log_store = store;
}
pub async fn query_mob_events(
&self,
query: &EventQuery,
) -> Result<Vec<MobStructuralEventEnvelope>, mob_events::MobEventsQueryError> {
let events = self.mob_runtime.handle().events();
mob_events::query_ledger_with_filter(&events, &self.mob_events, query).await
}
pub fn subscribe_mob_events(
&self,
) -> tokio::sync::broadcast::Receiver<MobStructuralEventEnvelope> {
self.mob_events.subscribe()
}
pub(crate) fn ingest_event(&self, event: &EventEnvelope<UnifiedEvent>) {
if let Some(ref log) = self.event_log {
log.ingest(event.clone());
}
}
pub(crate) async fn record_console_lifecycle(
&self,
identity: &str,
event_type: &str,
data: serde_json::Value,
) {
self.console_events
.record_lifecycle(identity, event_type, data)
.await;
}
pub async fn reserve_identity_interaction(
&self,
identity: &str,
runtime_member_id: Option<&str>,
interaction_id: &str,
origin: &str,
content: serde_json::Value,
) -> Result<(), &'static str> {
self.console_events
.reserve_interaction_value(identity, runtime_member_id, interaction_id, origin, content)
.await
}
pub(crate) async fn project_console_event_from_unified(
&self,
event: &EventEnvelope<UnifiedEvent>,
) {
self.console_events.project_unified_event(event).await;
}
pub(crate) fn fire_error(&self, event: ErrorEvent) {
if let Some(ref hook) = self.error_hook {
let hook = hook.clone();
tokio::spawn(async move {
let () = hook(event).await;
});
}
}
fn create_event_ingress(
mob_handle: MobHandle,
agent_mob_mcp_state: Option<Arc<meerkat_mob_mcp::MobMcpState>>,
mob_events: MobEventsStore,
) -> MobEventIngress {
let (event_tx, event_rx) = tokio::sync::mpsc::channel(256);
let task = tokio::spawn(run_resilient_mob_agent_event_forwarder(
mob_handle,
agent_mob_mcp_state,
event_tx,
mob_events,
));
MobEventIngress::Forwarder(MobEventForwarder { event_rx, task })
}
async fn rollback_mob_runtime(
mob_runtime: MobRuntime,
startup_error: UnifiedRuntimeBootstrapError,
) -> Result<Self, UnifiedRuntimeBootstrapError> {
match mob_runtime.handle().stop().await {
Ok(()) => Err(startup_error),
Err(err) => Err(UnifiedRuntimeBootstrapError::ModuleStartupRollbackFailed {
startup_error: Box::new(startup_error),
rollback_error: MobRuntimeError::from(err),
}),
}
}
}
type TaggedAgentEvent = (
AgentRuntimeId,
FenceToken,
ProfileName,
meerkat_core::event::EventEnvelope<AgentEvent>,
);
enum ForwardedAgentEvent {
Event(Box<TaggedAgentEvent>),
Closed(TrackedAgentEventStream),
}
type TrackedAgentEventStream = (String, AgentIdentity, AgentRuntimeId, FenceToken);
type TaggedAgentEventStream = BoxStream<'static, ForwardedAgentEvent>;
struct SubscribeBackoff {
next_attempt: tokio::time::Instant,
consecutive_failures: u32,
}
const SUBSCRIBE_BACKOFF_BASE: Duration = Duration::from_millis(250);
const SUBSCRIBE_BACKOFF_MAX: Duration = Duration::from_secs(30);
fn subscribe_backoff_delay(consecutive_failures: u32) -> Duration {
SUBSCRIBE_BACKOFF_BASE
.saturating_mul(1u32 << consecutive_failures.min(7))
.min(SUBSCRIBE_BACKOFF_MAX)
}
fn forwarder_should_subscribe(status: MobMemberStatus) -> bool {
matches!(status, MobMemberStatus::Active)
}
async fn run_resilient_mob_agent_event_forwarder(
handle: MobHandle,
agent_mob_mcp_state: Option<Arc<meerkat_mob_mcp::MobMcpState>>,
event_tx: Sender<EventEnvelope<UnifiedEvent>>,
mob_events: MobEventsStore,
) {
let mut streams: SelectAll<TaggedAgentEventStream> = SelectAll::new();
let mut tracked = HashSet::new();
let mut subscribe_failures: HashMap<TrackedAgentEventStream, SubscribeBackoff> = HashMap::new();
let mut reconcile_interval = tokio::time::interval(Duration::from_millis(250));
#[cfg(not(target_arch = "wasm32"))]
reconcile_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
Box::pin(reconcile_agent_event_streams(
&handle,
&agent_mob_mcp_state,
&mut tracked,
&mut subscribe_failures,
&mut streams,
))
.await;
loop {
tokio::select! {
Some(forwarded) = streams.next() => {
match forwarded {
ForwardedAgentEvent::Event(event) => {
let (source, source_fence_token, role, envelope) = *event;
let attributed_event = AttributedEvent {
source,
source_fence_token,
role,
envelope,
};
let _ = mob_events.project_attributed_event(&attributed_event).await;
if event_tx
.send(attributed_event_to_unified(attributed_event))
.await
.is_err()
{
break;
}
}
ForwardedAgentEvent::Closed(tracked_key) => {
tracked.remove(&tracked_key);
}
}
}
_ = reconcile_interval.tick() => {
Box::pin(reconcile_agent_event_streams(&handle, &agent_mob_mcp_state, &mut tracked, &mut subscribe_failures, &mut streams)).await;
}
}
}
}
async fn reconcile_agent_event_streams(
handle: &MobHandle,
agent_mob_mcp_state: &Option<Arc<meerkat_mob_mcp::MobMcpState>>,
tracked: &mut HashSet<TrackedAgentEventStream>,
subscribe_failures: &mut HashMap<TrackedAgentEventStream, SubscribeBackoff>,
streams: &mut SelectAll<TaggedAgentEventStream>,
) {
let mut handles = vec![handle.clone()];
if let Some(state) = agent_mob_mcp_state {
let primary_mob_id = handle.mob_id().to_string();
handles.extend(
Box::pin(state.mob_handles_snapshot())
.await
.unwrap_or_default()
.into_iter()
.filter_map(|(mob_id, child_handle)| {
if mob_id.as_str() == primary_mob_id {
None
} else {
Some(child_handle)
}
}),
);
}
let mut current: HashSet<TrackedAgentEventStream> = HashSet::new();
for handle in &handles {
let mob_id = handle.mob_id().to_string();
for entry in handle.list_members_including_retiring().await {
let Some((runtime_id, fence_token)) = entry.binding_atoms() else {
continue;
};
current.insert((
mob_id.clone(),
entry.agent_identity.clone(),
runtime_id,
fence_token,
));
}
}
tracked.retain(|tracked_key| current.contains(tracked_key));
subscribe_failures.retain(|key, _| current.contains(key));
for handle in handles {
let mob_id = handle.mob_id().to_string();
for entry in handle.list_members_including_retiring().await {
let identity = entry.agent_identity.clone();
let Some((runtime_id, fence_token)) = entry.binding_atoms() else {
continue;
};
let tracked_key = (
mob_id.clone(),
identity.clone(),
runtime_id.clone(),
fence_token,
);
if tracked.contains(&tracked_key) {
continue;
}
if !forwarder_should_subscribe(entry.status) {
subscribe_failures.remove(&tracked_key);
continue;
}
let now = tokio::time::Instant::now();
if let Some(backoff) = subscribe_failures.get(&tracked_key)
&& now < backoff.next_attempt
{
continue;
}
let role = entry.role.clone();
match subscribe_agent_events_for_console_forwarder(&handle, &identity).await {
Ok(stream) => {
let close_key = tracked_key.clone();
subscribe_failures.remove(&tracked_key);
tracked.insert(tracked_key);
let mapped = stream
.map(move |envelope| {
ForwardedAgentEvent::Event(Box::new((
runtime_id.clone(),
fence_token,
role.clone(),
envelope,
)))
})
.chain(futures::stream::once(async move {
ForwardedAgentEvent::Closed(close_key)
}))
.boxed();
streams.push(mapped);
}
Err(error) => {
let backoff =
subscribe_failures
.entry(tracked_key)
.or_insert(SubscribeBackoff {
next_attempt: now,
consecutive_failures: 0,
});
if backoff.consecutive_failures == 0 {
tracing::warn!(
mob_id = %mob_id,
identity = %identity,
error = %error,
"mobkit agent event forwarder: failed to subscribe; will retry with backoff"
);
} else {
tracing::debug!(
mob_id = %mob_id,
identity = %identity,
error = %error,
consecutive_failures = backoff.consecutive_failures,
"mobkit agent event forwarder: subscribe still failing; backing off"
);
}
backoff.next_attempt =
now + subscribe_backoff_delay(backoff.consecutive_failures);
backoff.consecutive_failures = backoff.consecutive_failures.saturating_add(1);
}
}
}
}
}
async fn subscribe_agent_events_for_console_forwarder(
handle: &MobHandle,
identity: &AgentIdentity,
) -> Result<EventStream, meerkat_mob::MobError> {
handle.subscribe_agent_events(identity).await
}
async fn run_mob_events_subscription(
handle: MobHandle,
store: MobEventsStore,
persistent_metadata: Arc<dyn PersistentMetadataStore>,
) {
let mob_id = handle.mob_id().as_str().to_string();
let resume_cursor = match persistent_metadata.get_subscription_cursor(&mob_id).await {
Ok(value) => value,
Err(err) => {
tracing::warn!(
mob_id = %mob_id,
error = %err,
"mob_events subscription: failed to read persisted cursor; resuming from latest"
);
None
}
};
let events = handle.events();
let mut subscription = match resume_cursor {
Some(cursor) => match events.subscribe_after(cursor).await {
Ok(sub) => sub,
Err(MobError::StaleEventCursor {
after_cursor,
latest_cursor,
}) => {
tracing::warn!(
mob_id = %mob_id,
after_cursor,
latest_cursor,
"mob_events subscription: persisted cursor is past ledger frontier; resuming at latest"
);
match events.subscribe().await {
Ok(sub) => sub,
Err(err) => {
tracing::warn!(
mob_id = %mob_id,
error = %err,
"mob_events subscription: failed to subscribe at latest after stale-cursor recovery"
);
return;
}
}
}
Err(err) => {
tracing::warn!(
mob_id = %mob_id,
error = %err,
"mob_events subscription: failed to resume from persisted cursor"
);
return;
}
},
None => match events.subscribe().await {
Ok(sub) => sub,
Err(err) => {
tracing::warn!(
mob_id = %mob_id,
error = %err,
"mob_events subscription: initial subscribe failed"
);
return;
}
},
};
while let Some(event) = subscription.event_rx.recv().await {
let envelope = store.project_mob_event(&event).await;
if let Err(err) = persistent_metadata
.set_subscription_cursor(&mob_id, envelope.cursor)
.await
{
tracing::warn!(
mob_id = %mob_id,
cursor = envelope.cursor,
error = %err,
"mob_events subscription: failed to persist cursor; continuing"
);
}
}
}
fn attributed_event_to_unified(attributed: AttributedEvent) -> EventEnvelope<UnifiedEvent> {
EventEnvelope {
event_id: format!("evt-agent-{}", attributed.envelope.event_id),
source: "agent".to_string(),
timestamp_ms: attributed.envelope.timestamp_ms,
event: UnifiedEvent::Agent {
agent_id: crate::member_comms_id::runtime_event_alias(&attributed.source),
event_type: agent_event_type(&attributed.envelope.payload).to_string(),
payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
&attributed.envelope.payload,
)),
},
}
}
struct ConsoleMemoryEventSink {
store: ConsoleEventStore,
handle: tokio::runtime::Handle,
}
impl crate::memory::events::MemoryEventSink for ConsoleMemoryEventSink {
fn emit(&self, event: crate::memory::events::MemoryTimelineEvent) {
let store = self.store.clone();
let identity = event
.identity()
.map(str::to_string)
.unwrap_or_else(|| crate::console_contracts::SYSTEM_EVENT_IDENTITY.to_string());
let event_type = event.event_type().to_string();
let data = event.data();
self.handle.spawn(async move {
store.append(identity, None, event_type, data).await;
});
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use meerkat_mob::ids::Generation;
fn attributed_text_delta(member_id: &str, generation: u64) -> AttributedEvent {
AttributedEvent {
source: AgentRuntimeId::new(
AgentIdentity::from(member_id),
Generation::new(generation),
),
source_fence_token: FenceToken::new(1),
role: ProfileName::from("worker"),
envelope: meerkat_core::event::EventEnvelope {
event_id: Default::default(),
source: meerkat_core::event::EventSourceIdentity::runtime("test"),
seq: 0,
mob_id: None,
timestamp_ms: 1,
payload: AgentEvent::TextDelta {
delta: "hello".to_string(),
},
},
}
}
#[test]
fn attributed_event_ingest_decodes_encoded_roster_member_ids() {
let encoded = crate::member_comms_id::mob_member_id_str("rt:review:singleton:0");
assert!(encoded.starts_with("mk--"), "precondition: alias encodes");
let unified = attributed_event_to_unified(attributed_text_delta(&encoded, 1));
let UnifiedEvent::Agent { agent_id, .. } = unified.event else {
panic!("expected agent event");
};
assert_eq!(agent_id, "rt:review:singleton:0:1");
}
#[test]
fn attributed_event_ingest_passes_plain_member_ids_through() {
let unified = attributed_event_to_unified(attributed_text_delta("worker-one", 0));
let UnifiedEvent::Agent { agent_id, .. } = unified.event else {
panic!("expected agent event");
};
assert_eq!(agent_id, "worker-one:0");
}
#[test]
fn forwarder_only_subscribes_active_members() {
assert!(forwarder_should_subscribe(MobMemberStatus::Active));
assert!(!forwarder_should_subscribe(MobMemberStatus::Retiring));
assert!(!forwarder_should_subscribe(MobMemberStatus::Broken));
assert!(!forwarder_should_subscribe(MobMemberStatus::Completed));
assert!(!forwarder_should_subscribe(MobMemberStatus::Unknown));
}
#[test]
fn subscribe_backoff_grows_and_caps() {
assert_eq!(subscribe_backoff_delay(0), SUBSCRIBE_BACKOFF_BASE);
assert_eq!(subscribe_backoff_delay(1), SUBSCRIBE_BACKOFF_BASE * 2);
assert_eq!(subscribe_backoff_delay(3), SUBSCRIBE_BACKOFF_BASE * 8);
assert_eq!(subscribe_backoff_delay(7), SUBSCRIBE_BACKOFF_MAX);
assert_eq!(subscribe_backoff_delay(50), SUBSCRIBE_BACKOFF_MAX);
assert!(subscribe_backoff_delay(2) > subscribe_backoff_delay(1));
}
}