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;