Skip to main content

mermaid_cli/engine/
mod.rs

1//! The driving loop, as a value.
2//!
3//! `update(State, Msg) -> (State, Vec<Cmd>)` is the product; driving it is five
4//! lines that were written out longhand in six places, each with its own
5//! spelling of the surrounding loop. [`Engine`] owns the reducer state and the
6//! effect sink and exposes those five lines once:
7//!
8//! ```text
9//!   inbox ── Msg ──► observer ──► update(State, Msg) ──► (State, Vec<Cmd>) ──► sink
10//! ```
11//!
12//! Three seams, one per axis the callers actually differ on: [`EffectSink`]
13//! (where a `Cmd` goes), [`StepObserver`] (what watches each message before the
14//! reducer consumes it), and [`DrivePolicy`] (when the loop stops).
15//!
16//! [`Engine::drive`] is an actor loop, and [`EngineHandle`] names its two ends
17//! so something outside it — a daemon socket, a second view of one session, an
18//! SDK client — can send messages in and watch what comes out.
19//!
20//! [`Engine::reduce`] — the kernel — is synchronous and observer-free on
21//! purpose: `--replay` folds a recorded log with no tokio runtime in sight, and
22//! keeping the kernel callable from a plain `for` loop is what proves this
23//! abstraction did not smuggle a runtime into the fold.
24//!
25//! See `docs/design/engine-extraction.md`.
26
27mod handle;
28
29pub use handle::{EngineGone, EngineHandle};
30
31use std::future::Future;
32use std::time::Duration;
33
34use chrono::{DateTime, Local};
35use tokio::sync::mpsc;
36use tokio::time::Instant;
37use tokio_util::sync::CancellationToken;
38
39use crate::app::lifecycle::RuntimeLifecycle;
40use crate::effect::EffectRunner;
41use mermaid_domain::{Cmd, Msg, State, TurnState, update};
42
43/// Where a reducer-emitted `Cmd` goes.
44///
45/// The live sink is [`EffectRunner`]; `--replay` uses [`DropEffects`]. It is
46/// also the interception point for commands a specific driver owns rather than
47/// the effect layer — the interactive loop's `Cmd::ComposeInEditor` suspends
48/// the terminal and the crossterm event stream, which only that loop holds.
49pub trait EffectSink {
50    fn dispatch(&mut self, cmd: Cmd);
51}
52
53impl EffectSink for EffectRunner {
54    fn dispatch(&mut self, cmd: Cmd) {
55        // The inherent method; this impl only exposes it through the seam.
56        Self::dispatch(self, cmd);
57    }
58}
59
60/// Discards every command. `--replay`'s sink: the recorded log already holds
61/// each effect's real-world result as a later `Msg`, so re-running the effect
62/// would both duplicate it and make the fold impure.
63#[derive(Debug, Default, Clone, Copy)]
64pub struct DropEffects;
65
66impl EffectSink for DropEffects {
67    fn dispatch(&mut self, _cmd: Cmd) {}
68}
69
70/// One message, as seen *before* `update` consumes it.
71///
72/// Pre-update is the whole point: the recorder logs the input that produced a
73/// state, the `RunEvent` projection reads fields the reducer strips, and the
74/// subagent's progress relay needs the pending tool call that the reducer is
75/// about to complete.
76pub struct Observation<'a> {
77    /// The clock this message will be reduced under — the same value the
78    /// recorder writes, so a replay of the log reproduces this step exactly.
79    pub now: DateTime<Local>,
80    pub msg: &'a Msg,
81    pub state: &'a State,
82}
83
84/// A hook on every message an [`Engine`] pumps.
85///
86/// `async` because one implementor (the subagent's progress relay) sends on a
87/// bounded channel and must not drop events under backpressure. Implementors
88/// that do no I/O just write a body that never awaits.
89pub trait StepObserver {
90    fn observe(&mut self, obs: Observation<'_>) -> impl Future<Output = ()> + Send;
91}
92
93/// The no-op observer, for drivers that only want the reducer pumped.
94impl StepObserver for () {
95    async fn observe(&mut self, _obs: Observation<'_>) {}
96}
97
98/// What one reduction did that a driver has to act on.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct StepOutcome {
101    /// `state.should_exit` after the step. Every driver stops on it.
102    pub should_exit: bool,
103}
104
105/// When [`Engine::drive`] returns.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum DriveExit {
108    /// The turn went idle with nothing queued ([`StopWhen::Settled`]).
109    Settled,
110    /// The reducer asked to quit (`state.should_exit`).
111    Exited,
112    /// The cancel token fired. Under [`OnCancel::Unwind`] this also covers the
113    /// grace window expiring before the turn finished unwinding.
114    Cancelled,
115    /// The wall-clock deadline elapsed.
116    TimedOut,
117    /// The message channel closed — the effect runner is gone.
118    Closed,
119}
120
121/// How far [`Engine::drive`] runs.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum StopWhen {
124    /// Run until the reducer says quit. The interactive session.
125    Exit,
126    /// Also stop once the turn is idle and no prompts are queued. Every
127    /// headless driver: one prompt in, one answer out.
128    Settled,
129}
130
131/// What a fired cancel token does to the drive.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum OnCancel {
134    /// Stop the drive immediately. The caller still owns the state and is
135    /// expected to shut its sink down. The subagent's child token.
136    Abort,
137    /// Inject `Msg::CancelTurn` — the same message the TUI's Esc sends — and
138    /// keep pumping so the turn unwinds gracefully (tool process trees killed,
139    /// the turn's `JoinSet` drained). Queued prompts must not seed another
140    /// turn, so from then on the drive stops as soon as the turn is idle,
141    /// drained or not. Hard-stops if `grace` elapses first.
142    Unwind { grace: Duration },
143}
144
145/// Everything that ends a drive, in one value.
146#[derive(Debug, Clone)]
147pub struct DrivePolicy {
148    pub stop: StopWhen,
149    pub cancel: Option<CancellationToken>,
150    pub on_cancel: OnCancel,
151    /// Wall-clock budget for this drive. A `select!` arm rather than a
152    /// `timeout()` wrapper, so a timed-out caller keeps its state and still
153    /// reaches its own shutdown path instead of dropping the sink mid-flight
154    /// (#76).
155    pub deadline: Option<Duration>,
156}
157
158impl DrivePolicy {
159    /// Run until the reducer quits. No cancel token, no deadline.
160    #[must_use]
161    pub const fn until_exit() -> Self {
162        Self {
163            stop: StopWhen::Exit,
164            cancel: None,
165            on_cancel: OnCancel::Abort,
166            deadline: None,
167        }
168    }
169
170    /// Run one turn to completion: stop when idle with nothing queued.
171    #[must_use]
172    pub const fn until_settled() -> Self {
173        Self {
174            stop: StopWhen::Settled,
175            cancel: None,
176            on_cancel: OnCancel::Abort,
177            deadline: None,
178        }
179    }
180
181    #[must_use]
182    pub fn cancel_with(mut self, token: Option<CancellationToken>, on_cancel: OnCancel) -> Self {
183        self.cancel = token;
184        self.on_cancel = on_cancel;
185        self
186    }
187
188    #[must_use]
189    pub const fn deadline(mut self, deadline: Option<Duration>) -> Self {
190        self.deadline = deadline;
191        self
192    }
193}
194
195/// The messages a drive pumps.
196///
197/// Two sources because that is how many exist: the effect runner's channel, and
198/// (for the drivers that own the process) OS lifecycle signals. Terminal events
199/// are deliberately absent — the interactive loop keeps its own `select!` for
200/// those, because the `$EDITOR` round-trip has to drop and rebuild the event
201/// stream around a suspend.
202pub struct Inbox<'a> {
203    msgs: &'a mut mpsc::Receiver<Msg>,
204    lifecycle: Option<&'a mut RuntimeLifecycle>,
205}
206
207impl<'a> Inbox<'a> {
208    #[must_use]
209    pub const fn new(msgs: &'a mut mpsc::Receiver<Msg>) -> Self {
210        Self {
211            msgs,
212            lifecycle: None,
213        }
214    }
215
216    /// Merge OS lifecycle signals (SIGINT/SIGTERM/SIGHUP) into the stream, so
217    /// an externally delivered signal unwinds through the reducer like `/quit`.
218    #[must_use]
219    pub const fn with_lifecycle(mut self, lifecycle: &'a mut RuntimeLifecycle) -> Self {
220        self.lifecycle = Some(lifecycle);
221        self
222    }
223
224    /// Next message, or `None` once the effect channel closes.
225    ///
226    /// A closed *lifecycle* channel is not the end of the inbox: it is dropped
227    /// from the select and the effect channel carries on. (The predecessor
228    /// `continue`d instead, which would have spun hot on a closed signal
229    /// channel — unreachable in practice only because the signal tasks hold
230    /// their sender for the life of the process.)
231    async fn next(&mut self) -> Option<Msg> {
232        loop {
233            let Some(lifecycle) = self.lifecycle.as_mut() else {
234                return self.msgs.recv().await;
235            };
236            tokio::select! {
237                m = self.msgs.recv() => return m,
238                s = lifecycle.next_msg() => match s {
239                    Some(s) => return Some(s),
240                    None => self.lifecycle = None,
241                },
242            }
243        }
244    }
245}
246
247/// The reducer, its state, and where its commands go.
248pub struct Engine<S: EffectSink, O: StepObserver = ()> {
249    /// `Option` only because `update` consumes `State` by value — the pure
250    /// reducer's signature, and not up for negotiation. [`Engine::reduce`]
251    /// takes it out and puts the new one back; it is `Some` at every
252    /// observable point.
253    state: Option<State>,
254    sink: S,
255    observer: O,
256}
257
258impl<S: EffectSink> Engine<S, ()> {
259    /// An engine that pumps the reducer and nothing else.
260    pub const fn new(state: State, sink: S) -> Self {
261        Self {
262            state: Some(state),
263            sink,
264            observer: (),
265        }
266    }
267}
268
269impl<S: EffectSink, O: StepObserver> Engine<S, O> {
270    /// Attach something that watches every message before the reducer sees it.
271    pub fn with_observer<O2: StepObserver>(self, observer: O2) -> Engine<S, O2> {
272        Engine {
273            state: self.state,
274            sink: self.sink,
275            observer,
276        }
277    }
278
279    /// The current reducer state.
280    ///
281    /// # Panics
282    ///
283    /// Only if an earlier reduction panicked inside the reducer, which is what
284    /// would leave the engine without a state. Every use of the engine after
285    /// that is already a bug.
286    #[must_use]
287    pub const fn state(&self) -> &State {
288        Self::present(self.state.as_ref())
289    }
290
291    /// Direct state access for the bootstrap window — seeding a conversation,
292    /// stamping a scratchpad path — before the first message is pumped.
293    ///
294    /// # Panics
295    ///
296    /// As [`Engine::state`].
297    pub const fn state_mut(&mut self) -> &mut State {
298        self.state
299            .as_mut()
300            .expect("engine state is present between reductions")
301    }
302
303    pub const fn sink_mut(&mut self) -> &mut S {
304        &mut self.sink
305    }
306
307    /// Give everything back, so the caller can build its result, seal whatever
308    /// its observer was writing, and shut the runner down.
309    ///
310    /// # Panics
311    ///
312    /// As [`Engine::state`].
313    pub fn into_parts(self) -> (State, S, O) {
314        (
315            self.state
316                .expect("engine state is present between reductions"),
317            self.sink,
318            self.observer,
319        )
320    }
321
322    /// No turn in flight.
323    #[must_use]
324    pub const fn is_idle(&self) -> bool {
325        matches!(self.state().turn, TurnState::Idle)
326    }
327
328    /// Idle, with no user prompt waiting to seed the next turn. What every
329    /// headless driver means by "done".
330    #[must_use]
331    pub fn is_settled(&self) -> bool {
332        self.is_idle() && self.state().ui.queued_messages.is_empty()
333    }
334
335    /// The `Option`'s contract, spelled once: it is `Some` at every observable
336    /// point. Takes the borrowed field rather than `&self` so callers that
337    /// also need `&mut self.observer` can split the borrow.
338    const fn present(state: Option<&State>) -> &State {
339        state.expect("engine state is present between reductions")
340    }
341
342    /// Take the state out for the instant `update` owns it. Private, because
343    /// the hole it leaves is never observable: `reduce` puts the new state back
344    /// before it returns, and there is no `.await` in between.
345    const fn take_state(&mut self) -> State {
346        self.state
347            .take()
348            .expect("engine state is present between reductions")
349    }
350
351    /// The kernel: stamp the clock, reduce, route the commands.
352    ///
353    /// Synchronous and observer-free — `--replay` folds an entire recorded log
354    /// through this with no runtime, stamping each entry's recorded timestamp
355    /// instead of reading a clock.
356    pub fn reduce(&mut self, now: DateTime<Local>, msg: Msg) -> StepOutcome {
357        let mut state = self.take_state();
358        // Inject the wall clock as data: the reducer never reads one, which is
359        // what makes the same log fold to the same state tomorrow.
360        state.now = now;
361        let (state, cmds) = update(state, msg);
362        let should_exit = state.should_exit;
363        self.state = Some(state);
364        for cmd in cmds {
365            self.sink.dispatch(cmd);
366        }
367        StepOutcome { should_exit }
368    }
369
370    /// One message under the current wall clock, shown to the observer first.
371    pub async fn step(&mut self, msg: Msg) -> StepOutcome {
372        self.step_at(Local::now(), msg).await
373    }
374
375    /// [`Engine::step`] with the clock supplied by the caller — used where a
376    /// single timestamp is shared with something else, such as the recorder
377    /// line that must carry the exact `now` its message was reduced under.
378    pub async fn step_at(&mut self, now: DateTime<Local>, msg: Msg) -> StepOutcome {
379        self.notify(now, &msg).await;
380        self.reduce(now, msg)
381    }
382
383    /// Show one message to the observer, borrowing rather than owning it.
384    ///
385    /// Split out from [`Engine::step_at`] so `drive` can await it directly:
386    /// nesting `step_at`'s whole state machine inside the drive loop's would
387    /// give the combined future a second `Msg`-sized slot, and `Msg` is the
388    /// large enum this codebase already carries an expect for. The drive loop
389    /// sits inside every caller's future in turn, so that slot is paid for all
390    /// the way up to `main`.
391    async fn notify(&mut self, now: DateTime<Local>, msg: &Msg) {
392        // Split borrow: the observation reads `self.state`, the observe call
393        // takes `self.observer` mutably. Disjoint fields, so both are fine.
394        let obs = Observation {
395            now,
396            msg,
397            state: Self::present(self.state.as_ref()),
398        };
399        self.observer.observe(obs).await;
400    }
401
402    /// Pump `inbox` until `policy` says stop.
403    ///
404    /// The `select!` is `biased`: cancellation and the deadline are one-shot
405    /// arms that must win against a saturated message channel. (The interactive
406    /// loop's fairness requirement — #112, where a hot channel starved terminal
407    /// input — does not apply here, because there is no input arm to starve.)
408    pub async fn drive(&mut self, inbox: &mut Inbox<'_>, policy: &DrivePolicy) -> DriveExit {
409        let deadline = policy.deadline.map(|d| Instant::now() + d);
410        // Set when a cancel token fires under `OnCancel::Unwind`: from then on
411        // the drive stops as soon as the turn is idle (a queued prompt must not
412        // seed another turn), or when this grace deadline passes.
413        let mut unwind_by: Option<Instant> = None;
414
415        loop {
416            if matches!(policy.on_cancel, OnCancel::Abort)
417                && policy
418                    .cancel
419                    .as_ref()
420                    .is_some_and(CancellationToken::is_cancelled)
421            {
422                return DriveExit::Cancelled;
423            }
424            if matches!(policy.stop, StopWhen::Settled)
425                && self.is_idle()
426                && (self.state().ui.queued_messages.is_empty() || unwind_by.is_some())
427            {
428                return if unwind_by.is_some() {
429                    DriveExit::Cancelled
430                } else {
431                    DriveExit::Settled
432                };
433            }
434
435            // `select!` evaluates every branch expression even when its `if`
436            // guard is false, so both sleep targets must be total.
437            let far_future = || Instant::now() + Duration::from_secs(86_400);
438            let msg = tokio::select! {
439                biased;
440                () = async {
441                    match &policy.cancel {
442                        Some(token) => token.cancelled().await,
443                        None => std::future::pending().await,
444                    }
445                }, if policy.cancel.is_some() && unwind_by.is_none() => {
446                    match policy.on_cancel {
447                        OnCancel::Abort => return DriveExit::Cancelled,
448                        OnCancel::Unwind { grace } => {
449                            unwind_by = Some(Instant::now() + grace);
450                            Msg::CancelTurn
451                        },
452                    }
453                },
454                () = tokio::time::sleep_until(unwind_by.unwrap_or_else(far_future)),
455                    if unwind_by.is_some() => {
456                    tracing::warn!("cancelled run did not unwind within grace; hard-stopping");
457                    return DriveExit::Cancelled;
458                },
459                () = tokio::time::sleep_until(deadline.unwrap_or_else(far_future)),
460                    if deadline.is_some() => return DriveExit::TimedOut,
461                m = inbox.next() => match m {
462                    Some(m) => m,
463                    None => return DriveExit::Closed,
464                },
465            };
466
467            // `notify` + `reduce` rather than `step`, to keep one `Msg`-sized
468            // slot out of this loop's future — see `notify`.
469            let now = Local::now();
470            self.notify(now, &msg).await;
471            if self.reduce(now, msg).should_exit {
472                return DriveExit::Exited;
473            }
474        }
475    }
476}
477
478#[cfg(test)]
479mod tests;