Skip to main content

monoloop_loop/transaction/
runtime.rs

1//! DefaultTransactionRuntime: start, admit, terminate, shutdown.
2
3use super::active_registry::{ActiveTransactionRegistry, ControlMessage};
4use super::admission::{admit, AdmissionContext};
5use super::bootstrap::RuntimeBootstrap;
6use super::callback_service::CallbackService;
7use super::capacity::CapacityManagers;
8use super::channel_registry::{ChannelBinding, LiveChannel};
9use super::error::StartupError;
10use super::finalization::build_transaction_end;
11use super::host_tools::HostToolRegistry;
12use super::mcp::McpGateway;
13use super::state::RuntimeState;
14use monoloop_contracts::{
15    AdmissionError, AdmissionErrorKind, AdmissionReceipt, ChannelId, ChannelKind,
16    EventDeliveryOutcome, Shutdown, ShutdownDisposition, TerminationDisposition, TerminationMode,
17    TransactionEndKind, TransactionRequest, TransactionRuntime, TransactionSelector,
18};
19use std::collections::HashMap;
20use std::future::Future;
21use std::pin::Pin;
22use std::sync::atomic::{AtomicU8, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::Duration;
25use tokio::sync::mpsc;
26use tokio::sync::Mutex as AsyncMutex;
27
28/// Startup future type.
29pub type Startup = Pin<
30    Box<dyn Future<Output = Result<Arc<DefaultTransactionRuntime>, StartupError>> + Send + 'static>,
31>;
32
33const STATE_ACCEPTING: u8 = 1;
34const STATE_DRAINING: u8 = 2;
35const STATE_STOPPED: u8 = 3;
36
37fn decode_state(v: u8) -> RuntimeState {
38    match v {
39        STATE_ACCEPTING => RuntimeState::Accepting,
40        STATE_DRAINING => RuntimeState::Draining,
41        STATE_STOPPED => RuntimeState::Stopped,
42        _ => RuntimeState::Starting,
43    }
44}
45
46struct RuntimeInner {
47    state: Arc<AtomicU8>,
48    config: super::bootstrap::RuntimeConfig,
49    channels: Arc<HashMap<ChannelId, LiveChannel>>,
50    tools: HostToolRegistry,
51    capacity: Arc<CapacityManagers>,
52    registry: Arc<Mutex<ActiveTransactionRegistry>>,
53    mcp: AsyncMutex<Option<McpGateway>>,
54    /// Cloneable handle for admission/actors (None when MCP listener disabled).
55    mcp_handle: Option<super::mcp::McpGatewayHandle>,
56    /// Runtime-owned completion callbacks (D-021).
57    callbacks: CallbackService,
58    /// Injected Tokio handle for all runtime-owned spawns (D-032).
59    executor: tokio::runtime::Handle,
60    /// Shared shutdown result for concurrent callers (D-029).
61    shutdown_disposition: AsyncMutex<Option<ShutdownDisposition>>,
62}
63
64/// Production transaction runtime.
65pub struct DefaultTransactionRuntime {
66    inner: Arc<RuntimeInner>,
67}
68
69impl DefaultTransactionRuntime {
70    /// Only startup path.
71    pub fn start(bootstrap: RuntimeBootstrap) -> Startup {
72        Box::pin(async move { Self::start_inner(bootstrap).await })
73    }
74
75    async fn start_inner(bootstrap: RuntimeBootstrap) -> Result<Arc<Self>, StartupError> {
76        bootstrap.config.validate()?;
77        let executor = bootstrap.executor.clone();
78        let _ = executor.id();
79
80        let mut realized: Vec<(ChannelId, LiveChannel)> = Vec::new();
81        let mut capacity_pairs: Vec<(ChannelId, usize)> = Vec::new();
82
83        for (id, binding) in bootstrap.channels.iter() {
84            binding.descriptor().validate()?;
85
86            let instance = match binding.connector_factory.create() {
87                Ok(i) => i,
88                Err(e) => {
89                    cleanup_partial(realized, None).await;
90                    return Err(StartupError::from(e));
91                }
92            };
93
94            match binding.kind {
95                ChannelKind::DirectLlm => {
96                    if instance.sessions.is_some() {
97                        cleanup_partial(realized, None).await;
98                        return Err(StartupError::SessionAdapterMismatch(
99                            "DirectLlm must not have SessionAdapter",
100                        ));
101                    }
102                }
103                ChannelKind::ExternalAgent => {
104                    if instance.sessions.is_none() {
105                        cleanup_partial(realized, None).await;
106                        return Err(StartupError::SessionAdapterMismatch(
107                            "ExternalAgent requires SessionAdapter",
108                        ));
109                    }
110                }
111            }
112
113            capacity_pairs.push((
114                id.clone(),
115                binding
116                    .limits
117                    .max_active_transactions
118                    .min(bootstrap.config.transaction_limits.max_active_per_channel),
119            ));
120
121            realized.push((
122                id.clone(),
123                LiveChannel {
124                    binding: clone_binding(binding),
125                    instance,
126                },
127            ));
128        }
129
130        let (mcp, mcp_handle) = if bootstrap.config.enable_mcp_listener {
131            match McpGateway::bind_loopback(256).await {
132                Ok(gw) => {
133                    let handle = gw.handle();
134                    (Some(gw), Some(handle))
135                }
136                Err(_) => {
137                    cleanup_partial(realized, None).await;
138                    return Err(StartupError::McpBindFailed);
139                }
140            }
141        } else {
142            (None, None)
143        };
144
145        let capacity = Arc::new(CapacityManagers::new(
146            bootstrap.config.transaction_limits.max_active_transactions,
147            capacity_pairs,
148        ));
149
150        // One concurrent callback slot per active-transaction budget (D-021).
151        let callbacks = CallbackService::new(
152            bootstrap
153                .config
154                .transaction_limits
155                .max_active_transactions
156                .max(1),
157            bootstrap.config.transaction_limits.callback_deadline,
158            executor.clone(),
159        );
160
161        let mut channels = HashMap::with_capacity(realized.len());
162        for (id, live) in realized {
163            channels.insert(id, live);
164        }
165
166        Ok(Arc::new(Self {
167            inner: Arc::new(RuntimeInner {
168                state: Arc::new(AtomicU8::new(STATE_ACCEPTING)),
169                config: bootstrap.config,
170                channels: Arc::new(channels),
171                tools: bootstrap.tools,
172                capacity,
173                registry: Arc::new(Mutex::new(ActiveTransactionRegistry::new())),
174                mcp: AsyncMutex::new(mcp),
175                mcp_handle,
176                callbacks,
177                executor,
178                shutdown_disposition: AsyncMutex::new(None),
179            }),
180        }))
181    }
182
183    /// Current lifecycle state.
184    pub fn state(&self) -> RuntimeState {
185        decode_state(self.inner.state.load(Ordering::SeqCst))
186    }
187
188    /// Tools shell.
189    pub fn tools(&self) -> &HostToolRegistry {
190        &self.inner.tools
191    }
192
193    /// Capacity managers.
194    pub fn capacity(&self) -> &Arc<CapacityManagers> {
195        &self.inner.capacity
196    }
197
198    /// Active transaction count.
199    pub fn active_count(&self) -> usize {
200        self.inner.registry.lock().map(|r| r.len()).unwrap_or(0)
201    }
202
203    /// Channel count.
204    pub fn channel_count(&self) -> usize {
205        self.inner.channels.len()
206    }
207
208    /// MCP address.
209    pub async fn mcp_local_addr(&self) -> Option<std::net::SocketAddr> {
210        self.inner.mcp.lock().await.as_ref().map(|m| m.local_addr())
211    }
212
213    /// Live channel lookup.
214    pub fn live_channel(&self, id: &ChannelId) -> Option<&LiveChannel> {
215        self.inner.channels.get(id)
216    }
217
218    async fn shutdown_inner(&self, deadline: Duration) -> ShutdownDisposition {
219        // D-020: one absolute global deadline for the whole shutdown.
220        let global = if deadline.is_zero() {
221            self.inner.config.default_shutdown_deadline
222        } else {
223            deadline
224        };
225        let deadline_at = tokio::time::Instant::now() + global;
226
227        let prev = self.inner.state.swap(STATE_DRAINING, Ordering::SeqCst);
228        if prev == STATE_STOPPED || prev == STATE_DRAINING {
229            // D-029: concurrent callers wait for and share the same disposition.
230            while self.inner.state.load(Ordering::SeqCst) != STATE_STOPPED {
231                if tokio::time::Instant::now() >= deadline_at {
232                    break;
233                }
234                if let Some(d) = self.inner.shutdown_disposition.lock().await.clone() {
235                    return d;
236                }
237                tokio::time::sleep(Duration::from_millis(10)).await;
238            }
239            return self
240                .inner
241                .shutdown_disposition
242                .lock()
243                .await
244                .clone()
245                .unwrap_or_default();
246        }
247
248        let active = {
249            let mut reg = self
250                .inner
251                .registry
252                .lock()
253                .unwrap_or_else(|e| e.into_inner());
254            reg.drain_all()
255        };
256
257        // Signal all actors first (group), then join concurrently under remaining time.
258        for entry in &active {
259            let _ = entry.control_tx.try_send(ControlMessage::ForceTerminate);
260        }
261
262        let mut normally_finalized = 0u64;
263        let mut supervisor_finalized = 0u64;
264        let mut callback_failed = 0u64;
265        let mut callback_aborted = 0u64;
266        let mut invariant_failed = 0u64;
267        let cb_cfg = self.inner.config.transaction_limits.callback_deadline;
268
269        let n = active.len().max(1);
270        let mut handles = Vec::with_capacity(active.len());
271        for entry in active {
272            let abort = entry.actor_join.abort_handle();
273            handles.push((entry, abort));
274        }
275
276        for (entry, abort) in handles {
277            let remaining = deadline_at.saturating_duration_since(tokio::time::Instant::now());
278            if remaining.is_zero() {
279                abort.abort();
280                let _ = entry.actor_join.await;
281                if let Some(payload) = entry.guard.try_claim() {
282                    entry.guard.mark_callback_scheduled();
283                    let end = build_transaction_end(
284                        &payload,
285                        TransactionEndKind::RuntimeShutdown,
286                        None,
287                        EventDeliveryOutcome::Failed,
288                        entry.guard.sequencer().last_allocated(),
289                    );
290                    // D-029: no minimum pad after global deadline expiry.
291                    let cb_budget = Duration::ZERO;
292                    match run_callback_isolated(payload.callback, end, cb_budget).await {
293                        CallbackRun::Ok => supervisor_finalized += 1,
294                        CallbackRun::Failed => {
295                            supervisor_finalized += 1;
296                            callback_failed += 1;
297                        }
298                        CallbackRun::Aborted => {
299                            supervisor_finalized += 1;
300                            callback_aborted += 1;
301                        }
302                    }
303                } else {
304                    supervisor_finalized += 1;
305                }
306                (entry.release_capacity)();
307                continue;
308            }
309
310            let per = (remaining / n as u32).max(Duration::from_millis(20));
311            let mut join = entry.actor_join;
312            match tokio::time::timeout(per, &mut join).await {
313                Ok(Ok(())) => {
314                    if entry.guard.callback_was_scheduled() {
315                        normally_finalized += 1;
316                    } else if let Some(payload) = entry.guard.try_claim() {
317                        entry.guard.mark_callback_scheduled();
318                        let end = build_transaction_end(
319                            &payload,
320                            TransactionEndKind::RuntimeShutdown,
321                            None,
322                            EventDeliveryOutcome::Failed,
323                            entry.guard.sequencer().last_allocated(),
324                        );
325                        let cb_budget = cb_cfg.min(
326                            deadline_at.saturating_duration_since(tokio::time::Instant::now()),
327                        );
328                        match run_callback_isolated(payload.callback, end, cb_budget).await {
329                            CallbackRun::Ok => supervisor_finalized += 1,
330                            CallbackRun::Failed => {
331                                supervisor_finalized += 1;
332                                callback_failed += 1;
333                            }
334                            CallbackRun::Aborted => {
335                                supervisor_finalized += 1;
336                                callback_aborted += 1;
337                            }
338                        }
339                    } else {
340                        normally_finalized += 1;
341                    }
342                }
343                Ok(Err(_)) => {
344                    invariant_failed += 1;
345                    if let Some(payload) = entry.guard.try_claim() {
346                        entry.guard.mark_callback_scheduled();
347                        let end = build_transaction_end(
348                            &payload,
349                            TransactionEndKind::RuntimeShutdown,
350                            None,
351                            EventDeliveryOutcome::Failed,
352                            0,
353                        );
354                        let cb_budget = deadline_at
355                            .saturating_duration_since(tokio::time::Instant::now())
356                            .min(cb_cfg);
357                        let _ = run_callback_isolated(payload.callback, end, cb_budget).await;
358                        supervisor_finalized += 1;
359                    }
360                }
361                Err(_) => {
362                    // D-029: abort then join with remaining budget (never detach JoinHandle).
363                    abort.abort();
364                    let join_budget =
365                        deadline_at.saturating_duration_since(tokio::time::Instant::now());
366                    if join_budget.is_zero() {
367                        let _ = join.await;
368                    } else {
369                        let _ = tokio::time::timeout(join_budget, join).await;
370                    }
371                    if let Some(payload) = entry.guard.try_claim() {
372                        entry.guard.mark_callback_scheduled();
373                        let end = build_transaction_end(
374                            &payload,
375                            TransactionEndKind::RuntimeShutdown,
376                            None,
377                            EventDeliveryOutcome::Failed,
378                            entry.guard.sequencer().last_allocated(),
379                        );
380                        let cb_budget = cb_cfg.min(
381                            deadline_at.saturating_duration_since(tokio::time::Instant::now()),
382                        );
383                        match run_callback_isolated(payload.callback, end, cb_budget).await {
384                            CallbackRun::Ok => supervisor_finalized += 1,
385                            CallbackRun::Failed => {
386                                supervisor_finalized += 1;
387                                callback_failed += 1;
388                            }
389                            CallbackRun::Aborted => {
390                                supervisor_finalized += 1;
391                                callback_aborted += 1;
392                            }
393                        }
394                    } else {
395                        supervisor_finalized += 1;
396                    }
397                }
398            }
399            (entry.release_capacity)();
400        }
401
402        // D-029: use only remaining global shutdown time; never pad after expiry.
403        let mcp_budget = deadline_at.saturating_duration_since(tokio::time::Instant::now());
404        if let Some(mcp) = self.inner.mcp.lock().await.take() {
405            if !mcp_budget.is_zero() {
406                let _ = tokio::time::timeout(mcp_budget, mcp.shutdown()).await;
407            }
408        }
409
410        // Drain runtime-owned host callbacks (D-021 / D-029).
411        let cb_drain = deadline_at.saturating_duration_since(tokio::time::Instant::now());
412        if !cb_drain.is_zero() {
413            self.inner.callbacks.drain(cb_drain).await;
414        }
415
416        let disposition = ShutdownDisposition {
417            normally_finalized,
418            supervisor_finalized,
419            callback_failed,
420            callback_aborted,
421            invariant_failed,
422        };
423        *self.inner.shutdown_disposition.lock().await = Some(disposition.clone());
424        self.inner.state.store(STATE_STOPPED, Ordering::SeqCst);
425        disposition
426    }
427}
428
429/// Outcome of a supervisor-invoked completion callback (D-021).
430enum CallbackRun {
431    Ok,
432    Failed,
433    Aborted,
434}
435
436/// Invoke + await host callback with panic isolation on a child task (D-021).
437async fn run_callback_isolated(
438    callback: Box<dyn monoloop_contracts::CompletionCallback>,
439    end: monoloop_contracts::TransactionEnd,
440    deadline: Duration,
441) -> CallbackRun {
442    let call = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback.call(end)));
443    match call {
444        Ok(fut) => {
445            let handle = tokio::spawn(fut);
446            let abort = handle.abort_handle();
447            match tokio::time::timeout(deadline, handle).await {
448                Ok(Ok(Ok(()))) => CallbackRun::Ok,
449                Ok(Ok(Err(_))) => CallbackRun::Failed,
450                Ok(Err(_)) => CallbackRun::Failed, // join error = panic in future
451                Err(_) => {
452                    abort.abort();
453                    CallbackRun::Aborted
454                }
455            }
456        }
457        Err(_) => CallbackRun::Failed, // panic at invoke
458    }
459}
460
461fn clone_binding(binding: &ChannelBinding) -> ChannelBinding {
462    ChannelBinding {
463        id: binding.id.clone(),
464        kind: binding.kind,
465        tool_mode: binding.tool_mode,
466        connector_factory: Arc::clone(&binding.connector_factory),
467        encoder: Arc::clone(&binding.encoder),
468        interpreter: Arc::clone(&binding.interpreter),
469        endpoint_ref: binding.endpoint_ref.clone(),
470        credential_ref: binding.credential_ref.clone(),
471        defaults: binding.defaults.clone(),
472        capabilities: binding.capabilities.clone(),
473        limits: binding.limits.clone(),
474    }
475}
476
477async fn cleanup_partial(realized: Vec<(ChannelId, LiveChannel)>, mcp: Option<McpGateway>) {
478    drop(realized);
479    if let Some(m) = mcp {
480        m.shutdown().await;
481    }
482}
483
484impl TransactionRuntime for DefaultTransactionRuntime {
485    fn submit(&self, request: TransactionRequest) -> Result<AdmissionReceipt, AdmissionError> {
486        match self.state() {
487            RuntimeState::Accepting => {}
488            RuntimeState::Starting | RuntimeState::Draining | RuntimeState::Stopped => {
489                return Err(AdmissionError::new(
490                    AdmissionErrorKind::RuntimeShuttingDown,
491                    "runtime is not accepting submissions",
492                ));
493            }
494        }
495
496        let ctx = AdmissionContext {
497            channels: Arc::clone(&self.inner.channels),
498            tools: self.inner.tools.clone(),
499            capacity: Arc::clone(&self.inner.capacity),
500            registry: Arc::clone(&self.inner.registry),
501            limits: self.inner.config.transaction_limits.clone(),
502            mcp: self.inner.mcp_handle.clone(),
503            runtime_state: Arc::clone(&self.inner.state),
504            callbacks: self.inner.callbacks.clone(),
505            executor: self.inner.executor.clone(),
506        };
507        admit(&ctx, request)
508    }
509
510    fn terminate(
511        &self,
512        selector: TransactionSelector,
513        mode: TerminationMode,
514    ) -> TerminationDisposition {
515        if !matches!(
516            self.state(),
517            RuntimeState::Accepting | RuntimeState::Draining
518        ) {
519            return TerminationDisposition::NotFound;
520        }
521        let reg = self
522            .inner
523            .registry
524            .lock()
525            .unwrap_or_else(|e| e.into_inner());
526        let tx = match selector {
527            TransactionSelector::Transaction(id) => reg.control_tx(&id),
528            TransactionSelector::Session(key) => reg.control_tx_by_session(&key),
529        };
530        drop(reg);
531        let Some(tx) = tx else {
532            return TerminationDisposition::NotFound;
533        };
534        let msg = match mode {
535            TerminationMode::Cancel { .. } => ControlMessage::Cancel,
536            TerminationMode::ForceTerminate { .. } => ControlMessage::ForceTerminate,
537        };
538        match tx.try_send(msg) {
539            Ok(()) => TerminationDisposition::Accepted,
540            Err(mpsc::error::TrySendError::Full(_)) => TerminationDisposition::AlreadyRequested,
541            Err(mpsc::error::TrySendError::Closed(_)) => TerminationDisposition::AlreadyTerminal,
542        }
543    }
544
545    fn shutdown(&self, deadline: Duration) -> Shutdown {
546        let inner = Arc::clone(&self.inner);
547        Box::pin(async move {
548            let view = DefaultTransactionRuntime { inner };
549            view.shutdown_inner(deadline).await
550        })
551    }
552}