Skip to main content

meerkat_mobkit/unified_runtime/
lifecycle.rs

1//! Runtime lifecycle management — startup, shutdown, rediscovery, and periodic maintenance.
2
3use std::future::Future;
4use std::future::IntoFuture;
5use std::sync::atomic::Ordering;
6use std::time::Duration;
7
8use meerkat_mob::SpawnMemberSpec;
9use serde_json::json;
10use tokio::runtime::RuntimeFlavor;
11use tokio::sync::mpsc::error::TryRecvError;
12
13use crate::mob_handle_runtime::{MobRuntimeError, send_message_on_mob};
14use crate::runtime::{
15    MobkitRuntimeHandle, RuntimeDecisionState, ScheduleDefinition, ScheduleDispatchReport,
16    ScheduleValidationError,
17};
18use crate::types::{EventEnvelope, ModuleEvent, UnifiedEvent};
19
20use super::types::{
21    RediscoverReport, ShutdownDrainReport, UnifiedRuntimeError, UnifiedRuntimeRunReport,
22    UnifiedRuntimeShutdownReport,
23};
24use super::{MobEventIngress, UnifiedRuntime, discovery_spec_to_spawn_spec};
25
26impl UnifiedRuntime {
27    /// Reset the mob and re-run discovery + edge reconciliation.
28    ///
29    /// Sequence:
30    /// 1. `MobHandle::reset()` — retires all members, clears projections,
31    ///    restarts MCP servers, returns mob to Running state
32    /// 2. Re-runs the stored `Discovery` (with `Value::Null` context since
33    ///    `PreSpawnHook` is consumed at boot and cannot be replayed)
34    /// 3. Spawns discovered members via `spawn_many`
35    /// 4. Clears managed dynamic edges (stale after reset)
36    /// 5. Runs edge reconciliation if `EdgeDiscovery` is configured
37    ///
38    /// Returns `None` if no `Discovery` is configured (nothing to rediscover).
39    pub async fn rediscover(&self) -> Result<Option<RediscoverReport>, MobRuntimeError> {
40        match self.rediscover_inner().await {
41            Ok(report) => Ok(report),
42            Err(err) => {
43                self.fire_error(super::types::ErrorEvent::RediscoverFailure {
44                    error: format!("{err}"),
45                });
46                Err(err)
47            }
48        }
49    }
50
51    async fn rediscover_inner(&self) -> Result<Option<RediscoverReport>, MobRuntimeError> {
52        let discovery = match &self.discovery {
53            Some(d) => d,
54            None => return Ok(None),
55        };
56
57        // 1. Reset the mob — retires all, clears state, returns to Running
58        self.mob_runtime
59            .handle()
60            .reset()
61            .await
62            .map_err(MobRuntimeError::Mob)?;
63
64        // 2. Re-run discovery (no pre-spawn context — PreSpawnHook is FnOnce)
65        let specs = discovery.discover(serde_json::Value::Null).await;
66        let spawn_specs: Vec<SpawnMemberSpec> =
67            specs.iter().map(discovery_spec_to_spawn_spec).collect();
68        let spawned: Vec<String> = spawn_specs.iter().map(|s| s.identity.to_string()).collect();
69
70        // 3. Spawn discovered members (hook-aware variant fires post_spawn_hook)
71        self.spawn_many(spawn_specs).await?;
72
73        // 4. Clear stale managed edges (old topology is gone after reset)
74        self.managed_dynamic_edges.write().await.clear();
75
76        // 5. Reconcile edges
77        let edges = self.reconcile_edges().await;
78
79        Ok(Some(RediscoverReport { spawned, edges }))
80    }
81
82    pub async fn run<F>(
83        &self,
84        listener: tokio::net::TcpListener,
85        decisions: RuntimeDecisionState,
86        shutdown_signal: F,
87    ) -> UnifiedRuntimeRunReport
88    where
89        F: Future<Output = ()> + Send + 'static,
90    {
91        let app = self.build_reference_app_router(decisions);
92        let serve = axum::serve(listener, app)
93            .with_graceful_shutdown(shutdown_signal)
94            .into_future();
95        tokio::pin!(serve);
96        let serve_result = loop {
97            tokio::select! {
98                result = &mut serve => break result,
99                () = tokio::time::sleep(Duration::from_millis(25)) => {
100                    let _ = self.drain_mob_agent_events().await;
101                }
102            }
103        };
104        let shutdown = self.shutdown().await;
105        UnifiedRuntimeRunReport {
106            serve_result,
107            shutdown,
108        }
109    }
110
111    pub async fn serve(
112        &self,
113        listener: tokio::net::TcpListener,
114        decisions: RuntimeDecisionState,
115    ) -> std::io::Result<()> {
116        let app = self.build_reference_app_router(decisions);
117        let serve = axum::serve(listener, app).into_future();
118        tokio::pin!(serve);
119        loop {
120            tokio::select! {
121                result = &mut serve => break result,
122                () = tokio::time::sleep(Duration::from_millis(25)) => {
123                    let _ = self.drain_mob_agent_events().await;
124                }
125            }
126        }
127    }
128
129    /// Spawn a detached task that periodically drains mob agent events and
130    /// projects them onto the ConsoleEventStore. Returns a [`JoinHandle`] —
131    /// callers that manage graceful shutdown should abort it before stopping
132    /// the runtime.
133    ///
134    /// Use this when embedding [`UnifiedRuntime`] inside a host-owned axum
135    /// server (so [`Self::serve`]'s built-in drain loop isn't running).
136    /// Without this task the mob event router fills up, agent turns never
137    /// reach the console SSE stream, and event-log consumers miss events.
138    pub fn spawn_event_drain_task(self: std::sync::Arc<Self>) -> tokio::task::JoinHandle<()> {
139        tokio::spawn(async move {
140            loop {
141                tokio::time::sleep(Duration::from_millis(25)).await;
142                if self.shutting_down.load(Ordering::SeqCst) {
143                    break;
144                }
145                if let Err(err) = self.drain_mob_agent_events().await {
146                    if matches!(err, UnifiedRuntimeError::RuntimeShuttingDown) {
147                        break;
148                    }
149                    // Transient drain failures are logged but don't stop the
150                    // task — the next tick will try again.
151                    tracing::warn!(error = %err, "mob agent event drain tick failed");
152                }
153            }
154        })
155    }
156
157    pub async fn shutdown(&self) -> UnifiedRuntimeShutdownReport {
158        self.shutting_down.store(true, Ordering::SeqCst);
159        if let Some(task) = self.implicit_delegate_retirement_task.lock().await.take() {
160            task.abort();
161        }
162        if let Some(task) = self.identity_lease_renewal_task.lock().await.take() {
163            task.abort();
164        }
165
166        // Phase 1: Drain in-flight events
167        let drain_start = std::time::Instant::now();
168        let mut drained_count = 0_usize;
169        let drain_result = tokio::time::timeout(self.drain_timeout, async {
170            loop {
171                if self.drain_mob_agent_events().await.is_err() {
172                    break;
173                }
174                let ingress = self.mob_event_ingress.lock().await;
175                if ingress.is_none() {
176                    break;
177                }
178                drop(ingress);
179                drained_count += 1;
180                tokio::time::sleep(Duration::from_millis(50)).await;
181                if drained_count > 1 {
182                    break;
183                }
184            }
185        })
186        .await;
187        let drain = ShutdownDrainReport {
188            drained_count,
189            timed_out: drain_result.is_err(),
190            drain_duration_ms: drain_start.elapsed().as_millis() as u64,
191        };
192
193        // Phase 2: Stop the mob actor while its router/module dependencies
194        // are still alive. Closing them first can race Stop against an
195        // already-dropped actor reply channel under teardown pressure.
196        let mob_stop = self
197            .mob_handle()
198            .stop()
199            .await
200            .map_err(MobRuntimeError::from);
201
202        // Phase 3: Close event router
203        self.close_event_router().await;
204
205        // Phase 4: Shutdown modules
206        let module_shutdown = self.module_runtime.lock().await.shutdown();
207        UnifiedRuntimeShutdownReport {
208            drain,
209            module_shutdown,
210            mob_stop,
211        }
212    }
213
214    /// Drain pending agent/module events from the mob event router and
215    /// project them onto the ConsoleEventStore + event log. Callers that
216    /// embed `UnifiedRuntime` inside their own axum server (rather than
217    /// using `.serve()`) must poll this periodically — typically via
218    /// [`UnifiedRuntime::spawn_event_drain_task`] — or console/event-log
219    /// consumers will never see agent responses.
220    pub async fn drain_mob_agent_events(&self) -> Result<(), UnifiedRuntimeError> {
221        let mut disconnected = false;
222        let mut ingress_guard = match self.mob_event_ingress.try_lock() {
223            Ok(guard) => guard,
224            Err(_) => {
225                // A previous drain tick may still be projecting a burst of
226                // events. Skip this tick instead of killing the host-owned
227                // background drain task.
228                return Ok(());
229            }
230        };
231        let ingress = match ingress_guard.as_mut() {
232            Some(i) => i,
233            None => return Ok(()),
234        };
235
236        loop {
237            match Self::try_recv_ingress_event(ingress) {
238                Some(Ok(unified_event)) => {
239                    // Detect agent run failures and fire HostLoopCrash
240                    if let crate::types::UnifiedEvent::Agent {
241                        ref agent_id,
242                        ref event_type,
243                        ..
244                    } = unified_event.event
245                        && event_type == "run_failed"
246                    {
247                        self.fire_error(super::types::ErrorEvent::HostLoopCrash {
248                            member_id: agent_id.clone(),
249                            error: format!(
250                                "agent run failed (event_id: {})",
251                                unified_event.event_id
252                            ),
253                        });
254                    }
255                    // Ingest into event log (non-blocking, buffered)
256                    self.ingest_event(&unified_event);
257                    self.project_console_event_from_unified(&unified_event)
258                        .await;
259                    self.module_runtime
260                        .lock()
261                        .await
262                        .append_normalized_event(unified_event)?;
263                }
264                Some(Err(TryRecvError::Empty)) => break,
265                Some(Err(TryRecvError::Disconnected)) => {
266                    disconnected = true;
267                    break;
268                }
269                None => break,
270            }
271        }
272
273        if disconnected {
274            *ingress_guard = None;
275        }
276
277        Ok(())
278    }
279
280    pub(super) async fn close_event_router(&self) {
281        let ingress = self.mob_event_ingress.lock().await.take();
282        match ingress {
283            Some(MobEventIngress::Forwarder(forwarder)) => {
284                let task = forwarder.task;
285                task.abort();
286                let _ = task.await;
287            }
288            None => {}
289        }
290
291        // Stop the structural mob-events subscription task as well.
292        if let Some(task) = self.mob_events_subscriber_task.lock().await.take() {
293            task.abort();
294            let _ = task.await;
295        }
296    }
297
298    fn try_recv_ingress_event(
299        ingress: &mut MobEventIngress,
300    ) -> Option<Result<EventEnvelope<UnifiedEvent>, TryRecvError>> {
301        Some(match ingress {
302            MobEventIngress::Forwarder(forwarder) => forwarder.event_rx.try_recv(),
303        })
304    }
305
306    pub async fn dispatch_schedule_tick(
307        &self,
308        schedules: &[ScheduleDefinition],
309        tick_ms: u64,
310    ) -> Result<ScheduleDispatchReport, UnifiedRuntimeError> {
311        if self.shutting_down.load(Ordering::SeqCst) {
312            return Err(UnifiedRuntimeError::RuntimeShuttingDown);
313        }
314        let mut dispatch_report = self
315            .dispatch_schedule_tick_blocking(schedules, tick_ms)
316            .await?;
317
318        for dispatch in &mut dispatch_report.dispatched {
319            let Some(runtime_injection) = dispatch.runtime_injection.clone() else {
320                continue;
321            };
322
323            let injection_result = send_message_on_mob(
324                &self.mob_handle(),
325                &runtime_injection.member_id,
326                runtime_injection.message.clone(),
327            )
328            .await;
329
330            match injection_result {
331                Ok(session_id) => {
332                    self.module_runtime
333                        .lock()
334                        .await
335                        .append_normalized_event(EventEnvelope {
336                            event_id: format!("{}-executed", runtime_injection.injection_event_id),
337                            source: "module".to_string(),
338                            timestamp_ms: dispatch.tick_ms,
339                            event: UnifiedEvent::Module(ModuleEvent {
340                                module: "runtime".to_string(),
341                                event_type: "runtime.injection.executed".to_string(),
342                                payload: json!({
343                                    "schedule_id": dispatch.schedule_id.clone(),
344                                    "claim_key": dispatch.claim_key.clone(),
345                                    "member_id": runtime_injection.member_id,
346                                    "message": runtime_injection.message,
347                                    "session_id": session_id,
348                                }),
349                            }),
350                        })?;
351                }
352                Err(error) => {
353                    dispatch.runtime_injection_error =
354                        Some(format!("mob injection failed: {error}"));
355                    self.module_runtime
356                        .lock()
357                        .await
358                        .append_normalized_event(EventEnvelope {
359                            event_id: format!("{}-failed", runtime_injection.injection_event_id),
360                            source: "module".to_string(),
361                            timestamp_ms: dispatch.tick_ms,
362                            event: UnifiedEvent::Module(ModuleEvent {
363                                module: "runtime".to_string(),
364                                event_type: "runtime.injection.failed".to_string(),
365                                payload: json!({
366                                    "schedule_id": dispatch.schedule_id.clone(),
367                                    "claim_key": dispatch.claim_key.clone(),
368                                    "member_id": runtime_injection.member_id,
369                                    "message": runtime_injection.message,
370                                    "error_kind": "mob_runtime",
371                                    "error": format!("mob injection failed: {error}"),
372                                }),
373                            }),
374                        })?;
375                }
376            }
377        }
378
379        self.drain_mob_agent_events().await?;
380        Ok(dispatch_report)
381    }
382
383    async fn dispatch_schedule_tick_blocking(
384        &self,
385        schedules: &[ScheduleDefinition],
386        tick_ms: u64,
387    ) -> Result<ScheduleDispatchReport, UnifiedRuntimeError> {
388        let mut rt = self.module_runtime.lock().await;
389
390        let dispatch_result = if tokio::runtime::Handle::try_current()
391            .is_ok_and(|handle| handle.runtime_flavor() == RuntimeFlavor::MultiThread)
392        {
393            tokio::task::block_in_place(|| {
394                Self::dispatch_schedule_tick_in_joined_thread(&mut rt, schedules, tick_ms)
395            })
396        } else {
397            Self::dispatch_schedule_tick_in_joined_thread(&mut rt, schedules, tick_ms)
398        };
399
400        dispatch_result
401            .map_err(|_| UnifiedRuntimeError::ScheduleDispatchThreadPanicked)?
402            .map_err(UnifiedRuntimeError::ScheduleValidation)
403    }
404
405    fn dispatch_schedule_tick_in_joined_thread(
406        module_runtime: &mut MobkitRuntimeHandle,
407        schedules: &[ScheduleDefinition],
408        tick_ms: u64,
409    ) -> std::thread::Result<Result<ScheduleDispatchReport, ScheduleValidationError>> {
410        std::thread::scope(|scope| {
411            scope
412                .spawn(move || module_runtime.dispatch_schedule_tick(schedules, tick_ms))
413                .join()
414        })
415    }
416}