Skip to main content

monoloop_loop/
runtime.rs

1//! Loop runtime: state machine, empty-registry dispatch, terminal accounting.
2
3use crate::registry::{EmptyToolRegistry, ResolveToolRequest, ToolRegistry, ToolResolution};
4use crate::subscription::{CanonicalEventSubscription, SubscriptionStatus};
5use crate::tools::{NoToolRuntime, ToolRuntime};
6use monoloop_contracts::{
7    CanonicalUnit, CanonicalUnitEvent, InterpretationEnd, InterpreterOutputEvent, LoopEnd,
8    LoopEndKind, LoopError, LoopId, LoopLimits, LoopOutputEvent, LoopScope, MonoloopRunId,
9    OutboundToolOutcome, OutboundToolResult, ToolActionId, ToolExecutionId, ToolRequestState,
10    UnitId,
11};
12use std::collections::HashMap;
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::Arc;
15use tokio::sync::{mpsc, oneshot, Mutex};
16
17/// Start request for one Loop instance.
18pub struct StartLoop {
19    /// Owning run.
20    pub monoloop_run_id: MonoloopRunId,
21    /// Loop id.
22    pub loop_id: LoopId,
23    /// Admission scope.
24    pub scope: LoopScope,
25    /// Lossless subscription (exclusive to this Loop).
26    pub subscription: CanonicalEventSubscription,
27    /// Tool registry.
28    pub tool_registry: Arc<dyn ToolRegistry>,
29    /// Tool runtime.
30    pub tool_runtime: Arc<dyn ToolRuntime>,
31    /// Output sink capacity (loop publishes here).
32    pub output_capacity: usize,
33    /// Limits.
34    pub limits: LoopLimits,
35}
36
37/// Live loop handle.
38pub struct LoopHandle {
39    /// Loop identity.
40    pub loop_id: LoopId,
41    /// Control.
42    pub control: LoopControl,
43    /// Health counters.
44    pub health: LoopHealth,
45    /// Completion.
46    pub completion: LoopCompletion,
47    /// Output events (independent of Interpreter stream).
48    pub output: Mutex<mpsc::Receiver<LoopOutputEvent>>,
49}
50
51/// Cancellation control.
52#[derive(Clone)]
53pub struct LoopControl {
54    cancelled: Arc<AtomicBool>,
55    notify: Arc<tokio::sync::Notify>,
56}
57
58impl LoopControl {
59    fn new() -> Self {
60        Self {
61            cancelled: Arc::new(AtomicBool::new(false)),
62            notify: Arc::new(tokio::sync::Notify::new()),
63        }
64    }
65
66    /// Request cancel (idempotent).
67    pub fn cancel(&self) {
68        self.cancelled.store(true, Ordering::SeqCst);
69        self.notify.notify_waiters();
70    }
71
72    fn is_cancelled(&self) -> bool {
73        self.cancelled.load(Ordering::SeqCst)
74    }
75}
76
77/// Content-free health.
78#[derive(Clone, Debug, Default)]
79pub struct LoopHealth {
80    /// Events received.
81    pub events_received: Arc<AtomicU64>,
82    /// Tools resolved unavailable.
83    pub tools_unavailable: Arc<AtomicU64>,
84}
85
86/// Completion handle.
87pub struct LoopCompletion {
88    rx: Mutex<Option<oneshot::Receiver<LoopEnd>>>,
89}
90
91impl LoopCompletion {
92    /// Wait for exactly one LoopEnd.
93    pub async fn wait(self) -> LoopEnd {
94        let mut guard = self.rx.lock().await;
95        let rx = guard.take().expect("LoopCompletion polled twice");
96        rx.await.unwrap_or(LoopEnd {
97            monoloop_run_id: MonoloopRunId::new("unknown"),
98            loop_id: LoopId::new("unknown"),
99            kind: LoopEndKind::InvariantFailed,
100            delivery_events_received: 0,
101            duplicate_events: 0,
102            tools_unavailable: 0,
103            outbound_results_emitted: 0,
104            safe_diagnostics: vec!["completion dropped".into()],
105        })
106    }
107}
108
109/// Default runtime factory.
110#[derive(Clone, Debug, Default)]
111pub struct DefaultLoopRuntime;
112
113impl DefaultLoopRuntime {
114    /// Create.
115    pub fn new() -> Self {
116        Self
117    }
118
119    /// Start a loop with EmptyToolRegistry + NoToolRuntime defaults available via helpers.
120    pub fn start(&self, request: StartLoop) -> Result<LoopHandle, LoopError> {
121        spawn_loop(request)
122    }
123
124    /// Convenience: empty-tool composition.
125    pub fn start_empty(
126        &self,
127        monoloop_run_id: MonoloopRunId,
128        loop_id: LoopId,
129        scope: LoopScope,
130        subscription: CanonicalEventSubscription,
131        limits: LoopLimits,
132    ) -> Result<LoopHandle, LoopError> {
133        self.start(StartLoop {
134            monoloop_run_id,
135            loop_id,
136            scope,
137            subscription,
138            tool_registry: Arc::new(EmptyToolRegistry::new()),
139            tool_runtime: Arc::new(NoToolRuntime::new()),
140            output_capacity: limits.max_output_queue,
141            limits,
142        })
143    }
144}
145
146fn spawn_loop(request: StartLoop) -> Result<LoopHandle, LoopError> {
147    let control = LoopControl::new();
148    let health = LoopHealth::default();
149    let (out_tx, out_rx) = mpsc::channel(request.output_capacity.max(1));
150    let (end_tx, end_rx) = oneshot::channel();
151
152    let loop_id = request.loop_id.clone();
153    let control_task = control.clone();
154    let health_task = health.clone();
155
156    tokio::spawn(async move {
157        let mut owner = LoopOwner {
158            monoloop_run_id: request.monoloop_run_id,
159            loop_id: request.loop_id,
160            scope: request.scope,
161            registry: request.tool_registry,
162            runtime: request.tool_runtime,
163            limits: request.limits,
164            out_tx,
165            control: control_task,
166            health: health_task,
167            last_seq: 0,
168            actions: HashMap::new(),
169            dedup: HashMap::new(),
170            delivery_events: 0,
171            duplicates: 0,
172            tools_unavailable: 0,
173            outbound_results: 0,
174            diagnostics: Vec::new(),
175            ended: false,
176        };
177        owner.run(request.subscription, end_tx).await;
178    });
179
180    Ok(LoopHandle {
181        loop_id,
182        control,
183        health,
184        completion: LoopCompletion {
185            rx: Mutex::new(Some(end_rx)),
186        },
187        output: Mutex::new(out_rx),
188    })
189}
190
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192enum ActionState {
193    ObservedWaiting,
194    RequestReady,
195    Unavailable,
196    Incomplete,
197}
198
199struct ActionRecord {
200    #[allow(dead_code)]
201    tool_action_id: ToolActionId,
202    #[allow(dead_code)]
203    unit_id: UnitId,
204    last_generation: u64,
205    state: ActionState,
206    dispatched: bool,
207}
208
209struct LoopOwner {
210    monoloop_run_id: MonoloopRunId,
211    loop_id: LoopId,
212    scope: LoopScope,
213    registry: Arc<dyn ToolRegistry>,
214    runtime: Arc<dyn ToolRuntime>,
215    limits: LoopLimits,
216    out_tx: mpsc::Sender<LoopOutputEvent>,
217    control: LoopControl,
218    health: LoopHealth,
219    last_seq: u64,
220    actions: HashMap<String, ActionRecord>,
221    dedup: HashMap<String, u64>,
222    delivery_events: u64,
223    duplicates: u64,
224    tools_unavailable: u64,
225    outbound_results: u64,
226    diagnostics: Vec<String>,
227    ended: bool,
228}
229
230impl LoopOwner {
231    async fn run(
232        &mut self,
233        mut subscription: CanonicalEventSubscription,
234        end_tx: oneshot::Sender<LoopEnd>,
235    ) {
236        loop {
237            if self.control.is_cancelled() {
238                self.finish(LoopEndKind::Cancelled, end_tx).await;
239                return;
240            }
241
242            tokio::select! {
243                biased;
244                _ = self.control.notify.notified() => {
245                    if self.control.is_cancelled() {
246                        self.finish(LoopEndKind::Cancelled, end_tx).await;
247                        return;
248                    }
249                }
250                msg = subscription.recv() => {
251                    match msg {
252                        None => {
253                            self.finish(LoopEndKind::Drained, end_tx).await;
254                            return;
255                        }
256                        Some(Err(SubscriptionStatus::Gap(_)))
257                        | Some(Err(SubscriptionStatus::Lost)) => {
258                            self.finish(LoopEndKind::SubscriptionLost, end_tx).await;
259                            return;
260                        }
261                        Some(Err(SubscriptionStatus::Opened | SubscriptionStatus::Closing)) => {}
262                        Some(Ok(delivered)) => {
263                            if let Err(kind) = self.on_delivered(delivered).await {
264                                self.finish(kind, end_tx).await;
265                                return;
266                            }
267                        }
268                    }
269                }
270            }
271        }
272    }
273
274    async fn on_delivered(
275        &mut self,
276        delivered: crate::subscription::DeliveredEvent,
277    ) -> Result<(), LoopEndKind> {
278        // Gap detection: sequences must be contiguous.
279        if self.last_seq > 0 && delivered.delivery_sequence != self.last_seq + 1 {
280            self.diag(format!(
281                "delivery gap: expected {}, got {}",
282                self.last_seq + 1,
283                delivered.delivery_sequence
284            ));
285            return Err(LoopEndKind::SubscriptionLost);
286        }
287        self.last_seq = delivered.delivery_sequence;
288        self.delivery_events += 1;
289        self.health.events_received.fetch_add(1, Ordering::Relaxed);
290
291        match delivered.event {
292            InterpreterOutputEvent::Unit(ev) => self.on_unit(*ev).await,
293            InterpreterOutputEvent::Ended(end) => {
294                self.on_interpretation_end(end).await?;
295                // Source drained for this interpretation — loop may still end when subscription closes.
296                Ok(())
297            }
298        }
299    }
300
301    async fn on_unit(&mut self, event: CanonicalUnitEvent) -> Result<(), LoopEndKind> {
302        let snap = event.snapshot();
303        if !self.in_scope(snap) {
304            // Observe sequence only; do not mutate tool state.
305            return Ok(());
306        }
307
308        // Text, structure, etc. — observe only. Tools drive dispatch.
309        let CanonicalUnit::Tool(tool) = &snap.unit else {
310            return Ok(());
311        };
312        let key = format!(
313            "{}:{}",
314            snap.interpretation_id.as_str(),
315            tool.tool_action_id.as_str()
316        );
317        let dig_key = format!("{}:{}", key, snap.unit_generation);
318        if let Some(prev) = self.dedup.get(&dig_key) {
319            if *prev == snap.unit_generation {
320                self.duplicates += 1;
321                return Ok(());
322            }
323        }
324        if self.dedup.len() >= self.limits.max_dedup_entries {
325            return Err(LoopEndKind::InvariantFailed);
326        }
327        self.dedup.insert(dig_key, snap.unit_generation);
328
329        match tool.request_state {
330            ToolRequestState::Assembling => {
331                self.track_waiting(
332                    &key,
333                    tool.tool_action_id.clone(),
334                    snap.unit_id.clone(),
335                    snap.unit_generation,
336                );
337            }
338            ToolRequestState::Ready => {
339                self.on_request_ready(
340                    &key,
341                    tool.tool_action_id.clone(),
342                    snap.unit_id.clone(),
343                    snap.unit_generation,
344                    tool.tool_name.clone(),
345                    tool.request_payload.clone(),
346                    snap,
347                )
348                .await?;
349            }
350            ToolRequestState::Incomplete | ToolRequestState::Malformed => {
351                let rec = self.actions.entry(key).or_insert_with(|| ActionRecord {
352                    tool_action_id: tool.tool_action_id.clone(),
353                    unit_id: snap.unit_id.clone(),
354                    last_generation: 0,
355                    state: ActionState::Incomplete,
356                    dispatched: false,
357                });
358                if snap.unit_generation >= rec.last_generation {
359                    rec.last_generation = snap.unit_generation;
360                    rec.state = ActionState::Incomplete;
361                }
362            }
363        }
364        Ok(())
365    }
366
367    fn track_waiting(
368        &mut self,
369        key: &str,
370        tool_action_id: ToolActionId,
371        unit_id: UnitId,
372        generation: u64,
373    ) {
374        let rec = self
375            .actions
376            .entry(key.to_string())
377            .or_insert_with(|| ActionRecord {
378                tool_action_id,
379                unit_id,
380                last_generation: 0,
381                state: ActionState::ObservedWaiting,
382                dispatched: false,
383            });
384        if generation < rec.last_generation {
385            return; // stale
386        }
387        rec.last_generation = generation;
388        if rec.state != ActionState::Unavailable && !rec.dispatched {
389            rec.state = ActionState::ObservedWaiting;
390        }
391    }
392
393    #[allow(clippy::too_many_arguments)]
394    async fn on_request_ready(
395        &mut self,
396        key: &str,
397        tool_action_id: ToolActionId,
398        unit_id: UnitId,
399        generation: u64,
400        tool_name: Option<String>,
401        request_payload: Option<String>,
402        snap: &monoloop_contracts::CanonicalUnitSnapshot,
403    ) -> Result<(), LoopEndKind> {
404        if self.actions.len() >= self.limits.max_tool_actions && !self.actions.contains_key(key) {
405            return Err(LoopEndKind::InvariantFailed);
406        }
407
408        let rec = self
409            .actions
410            .entry(key.to_string())
411            .or_insert_with(|| ActionRecord {
412                tool_action_id: tool_action_id.clone(),
413                unit_id: unit_id.clone(),
414                last_generation: 0,
415                state: ActionState::RequestReady,
416                dispatched: false,
417            });
418
419        if generation < rec.last_generation {
420            return Ok(()); // stale
421        }
422        rec.last_generation = generation;
423
424        // At-most-once dispatch per action in this Loop incarnation.
425        if rec.dispatched {
426            self.duplicates += 1;
427            return Ok(());
428        }
429
430        let Some(name) = tool_name else {
431            self.diag("ToolRequestReady missing tool name".into());
432            return Ok(());
433        };
434        let Some(payload) = request_payload else {
435            self.diag("ToolRequestReady missing payload".into());
436            return Ok(());
437        };
438
439        rec.state = ActionState::RequestReady;
440        rec.dispatched = true;
441
442        if self
443            .out_tx
444            .send(LoopOutputEvent::ToolDispatchRequested {
445                tool_action_id: tool_action_id.clone(),
446                request_generation: generation,
447            })
448            .await
449            .is_err()
450        {
451            return Err(LoopEndKind::OutputFailed);
452        }
453
454        let resolution = self
455            .registry
456            .resolve(ResolveToolRequest {
457                tool_action_id: tool_action_id.clone(),
458                tool_name: name.clone(),
459                request_payload: payload.clone(),
460            })
461            .await
462            .map_err(|_| LoopEndKind::InvariantFailed)?;
463
464        match resolution {
465            ToolResolution::Unavailable(reason) => {
466                rec.state = ActionState::Unavailable;
467                self.tools_unavailable += 1;
468                self.health
469                    .tools_unavailable
470                    .fetch_add(1, Ordering::Relaxed);
471
472                if self
473                    .out_tx
474                    .send(LoopOutputEvent::ToolUnavailable {
475                        tool_action_id: tool_action_id.clone(),
476                        reason,
477                    })
478                    .await
479                    .is_err()
480                {
481                    return Err(LoopEndKind::OutputFailed);
482                }
483
484                let result = OutboundToolResult {
485                    outbound_result_id: uuid::Uuid::new_v4().to_string(),
486                    monoloop_run_id: self.monoloop_run_id.clone(),
487                    loop_id: self.loop_id.clone(),
488                    source_interpretation_id: snap.interpretation_id.clone(),
489                    source_connection_id: snap.connection_id.clone(),
490                    external_session_id: snap.external_session_id.clone(),
491                    tool_action_id,
492                    request_generation: generation,
493                    tool_execution_id: None,
494                    outcome: OutboundToolOutcome::ToolUnavailable,
495                    payload: format!("{reason:?}"),
496                    source_unit_id: unit_id,
497                };
498                self.outbound_results += 1;
499                if self
500                    .out_tx
501                    .send(LoopOutputEvent::OutboundToolResult(result))
502                    .await
503                    .is_err()
504                {
505                    return Err(LoopEndKind::OutputFailed);
506                }
507                // Empty registry: never call ToolRuntime.start.
508            }
509            ToolResolution::Available(_) => {
510                let execution_id = ToolExecutionId::generate();
511                let handle = match self.runtime.start(crate::tools::StartToolExecution {
512                    execution_id: execution_id.clone(),
513                    tool_action_id: tool_action_id.as_str().to_string(),
514                    tool_name: name,
515                    request_payload: payload,
516                    request_generation: generation,
517                }) {
518                    Ok(h) => h,
519                    Err(e) => {
520                        self.diag(format!("tool runtime start failed: {e}"));
521                        if self
522                            .out_tx
523                            .send(LoopOutputEvent::OutboundToolResult(OutboundToolResult {
524                                outbound_result_id: uuid::Uuid::new_v4().to_string(),
525                                monoloop_run_id: self.monoloop_run_id.clone(),
526                                loop_id: self.loop_id.clone(),
527                                source_interpretation_id: snap.interpretation_id.clone(),
528                                source_connection_id: snap.connection_id.clone(),
529                                external_session_id: snap.external_session_id.clone(),
530                                tool_action_id,
531                                request_generation: generation,
532                                tool_execution_id: None,
533                                outcome: OutboundToolOutcome::ExecutionFailed,
534                                payload: e.0,
535                                source_unit_id: unit_id,
536                            }))
537                            .await
538                            .is_err()
539                        {
540                            return Err(LoopEndKind::OutputFailed);
541                        }
542                        self.outbound_results += 1;
543                        return Ok(());
544                    }
545                };
546
547                let terminal = if let Some(rx) = handle.completion {
548                    rx.await.unwrap_or(crate::tools::ToolRuntimeTerminal {
549                        outcome: OutboundToolOutcome::ExecutionLost,
550                        payload: "completion lost".into(),
551                    })
552                } else {
553                    crate::tools::ToolRuntimeTerminal {
554                        outcome: OutboundToolOutcome::ExecutionFailed,
555                        payload: "runtime returned no completion".into(),
556                    }
557                };
558
559                self.outbound_results += 1;
560                if self
561                    .out_tx
562                    .send(LoopOutputEvent::OutboundToolResult(OutboundToolResult {
563                        outbound_result_id: uuid::Uuid::new_v4().to_string(),
564                        monoloop_run_id: self.monoloop_run_id.clone(),
565                        loop_id: self.loop_id.clone(),
566                        source_interpretation_id: snap.interpretation_id.clone(),
567                        source_connection_id: snap.connection_id.clone(),
568                        external_session_id: snap.external_session_id.clone(),
569                        tool_action_id,
570                        request_generation: generation,
571                        tool_execution_id: Some(execution_id),
572                        outcome: terminal.outcome,
573                        payload: terminal.payload,
574                        source_unit_id: unit_id,
575                    }))
576                    .await
577                    .is_err()
578                {
579                    return Err(LoopEndKind::OutputFailed);
580                }
581            }
582        }
583        Ok(())
584    }
585
586    async fn on_interpretation_end(&mut self, _end: InterpretationEnd) -> Result<(), LoopEndKind> {
587        // Not turn completion. Do not expand scope.
588        Ok(())
589    }
590
591    fn in_scope(&self, snap: &monoloop_contracts::CanonicalUnitSnapshot) -> bool {
592        if self.scope.accept_all_in_run {
593            return true;
594        }
595        if !self.scope.accepted_interpretation_ids.is_empty()
596            && !self
597                .scope
598                .accepted_interpretation_ids
599                .iter()
600                .any(|id| id.as_str() == snap.interpretation_id.as_str())
601        {
602            return false;
603        }
604        if !self.scope.accepted_connection_ids.is_empty()
605            && !self
606                .scope
607                .accepted_connection_ids
608                .iter()
609                .any(|id| id.as_str() == snap.connection_id.as_str())
610        {
611            return false;
612        }
613        if !self.scope.accepted_external_session_ids.is_empty() {
614            match &snap.external_session_id {
615                Some(ext) => {
616                    if !self
617                        .scope
618                        .accepted_external_session_ids
619                        .iter()
620                        .any(|id| id.as_str() == ext.as_str())
621                    {
622                        return false;
623                    }
624                }
625                None => return false,
626            }
627        }
628        true
629    }
630
631    fn diag(&mut self, msg: String) {
632        if self.diagnostics.len() < 32 {
633            self.diagnostics.push(msg);
634        }
635    }
636
637    async fn finish(&mut self, kind: LoopEndKind, end_tx: oneshot::Sender<LoopEnd>) {
638        if self.ended {
639            return;
640        }
641        self.ended = true;
642        let end = LoopEnd {
643            monoloop_run_id: self.monoloop_run_id.clone(),
644            loop_id: self.loop_id.clone(),
645            kind,
646            delivery_events_received: self.delivery_events,
647            duplicate_events: self.duplicates,
648            tools_unavailable: self.tools_unavailable,
649            outbound_results_emitted: self.outbound_results,
650            safe_diagnostics: self.diagnostics.clone(),
651        };
652        let _ = self
653            .out_tx
654            .send(LoopOutputEvent::LoopEnded(end.clone()))
655            .await;
656        let _ = end_tx.send(end);
657    }
658}