Skip to main content

mermaid_cli/effect/
mod.rs

1//! The effect runner: dispatches `Cmd` values into tokio tasks.
2//!
3//! There are exactly two places in the codebase that spawn a tokio
4//! task: this module and tests. Everywhere else asks the
5//! reducer to return a `Cmd`, and the runner handles it. That
6//! centralization is what makes structured concurrency per turn
7//! actually work — nothing can accidentally spawn a detached task
8//! that outlives the turn it was started for.
9//!
10//! Architecture:
11//!
12//! ```text
13//!   main loop ── reducer ── Cmd ── dispatch ── EffectRunner
14//!                                                 ├── TurnScope(turn A) ── JoinSet
15//!                                                 ├── TurnScope(turn B) ── JoinSet
16//!                                                 └── detached effects (Save, Exit, …)
17//!                                                       ↓
18//!                                              Msg via mpsc::Sender<Msg>
19//!                                                       ↓
20//!                                                 main loop (next iteration)
21//! ```
22//!
23//! The runner dispatches every `Cmd` variant to a real handler —
24//! model streaming (`CallModel` → `ModelProvider::chat`), tool
25//! execution (`ExecuteTool` → `ToolExecutor::execute`), persistence
26//! (`SaveConversation`, `LoadConversation`, `PersistLastModel`,
27//! `PersistReasoningFor`), MCP lifecycle
28//! (`InitMcpServers`, `StopMcpServer`), local side-effects
29//! (`WriteImageToTemp`, `OpenInSystem`, `PullOllamaModel`,
30//! `SetTerminalTitle`). Cancellation flows
31//! through `Cmd::CancelScope(TurnId)` → the scope's
32//! `CancellationToken`.
33
34mod config_watch;
35mod turn_scope;
36
37use std::collections::HashMap;
38use std::collections::VecDeque;
39use std::path::PathBuf;
40use std::sync::Arc;
41use std::sync::Mutex;
42
43use tokio::sync::mpsc;
44
45use crate::providers::ctx::{ExecContext, StreamContext};
46use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
47use mermaid_domain::{
48    Cmd, CompactionRequest, CompactionResult, CompactionTrigger, Msg, Query, QueryResult, TurnId,
49};
50use mermaid_domain::{Config, MemoryConfig};
51
52pub use turn_scope::TurnScope;
53
54#[cfg(not(test))]
55const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
56#[cfg(test)]
57const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
58
59/// F38: how many recently-cancelled `TurnId`s to remember as tombstones.
60/// Turn ids are strictly monotonic and never reused, so a stray turn-scoped
61/// `Cmd` for a cancelled turn can only ever be a post-cancel straggler that
62/// lands within a few turns of the cancel. A small bounded ring is plenty;
63/// older entries age out so the set never grows across a long session.
64const CANCELLED_TOMBSTONE_CAP: usize = 256;
65
66/// Single channel back to the reducer. `EffectRunner` holds the
67/// sender; every spawned task clones this so it can emit `Msg` as
68/// work progresses. Bounded capacity applies natural backpressure —
69/// if the main loop can't keep up, the provider's streaming send
70/// `.await`s and the whole pipeline throttles.
71pub type MsgSender = mpsc::Sender<Msg>;
72
73/// Bounded channel capacity for the effect → reducer stream. 512 is
74/// generous — a single streaming chunk fits comfortably, and the
75/// main loop drains at ~60 Hz so backlog rarely grows. Bigger wastes
76/// RAM; smaller introduces spurious backpressure on bursty tool
77/// output.
78pub const MSG_CHANNEL_CAPACITY: usize = 512;
79
80/// Translate a domain `CompactionEvent` into the durable row.
81///
82/// The two types share a name-adjacent concept and almost nothing else: the
83/// domain event carries 14 fields describing what compaction did, the row
84/// carries 9 describing what a later reader needs. Only `id` and
85/// `archive_path` overlap. This lived as an anonymous struct literal inline in
86/// `persist_compaction`, which is where a field mapping goes to rot unnoticed.
87///
88/// It stays in `effect`, not in the pure core: "domain value -> SQLite row" is
89/// precisely what this layer is for, and the orphan rule permitting it
90/// elsewhere is not a reason to put row-shaped knowledge in the reducer.
91fn compaction_row(
92    record: &mermaid_domain::CompactionEvent,
93    archive_path: &std::path::Path,
94    task_id: Option<String>,
95    session_id: String,
96) -> mermaid_runtime::NewCompaction {
97    mermaid_runtime::NewCompaction {
98        id: Some(record.id.clone()),
99        task_id,
100        session_id: Some(session_id),
101        source_token_estimate: Some(record.before_tokens as i64),
102        summary_token_count: Some(record.summary_tokens as i64),
103        preserved_turns: Some(record.preserved_turn_count as i64),
104        archive_path: Some(archive_path.display().to_string()),
105        // Nothing verifies a checkpoint any more: one model call writes it.
106    }
107}
108
109/// Feed the cross-project session index off a successful snapshot save.
110/// Best-effort by design: the row is an index over the files, never the
111/// truth, so a store hiccup must not fail the save that just succeeded.
112/// Feed the cross-project session index off a successful append. Best-effort
113/// by design: the row is an index over the files, never the truth, so a store
114/// hiccup must not fail the save that just succeeded; the daemon rebuilds the
115/// index from disk on its next start regardless.
116fn upsert_session_index(
117    manager: &crate::session::ConversationManager,
118    snapshot: &mermaid_domain::ConversationHistory,
119) {
120    let row = crate::session::session_row(manager.conversations_dir(), snapshot);
121    let _ = mermaid_runtime::with_shared_store(|store| store.sessions().upsert(row));
122}
123
124#[derive(Clone)]
125enum PersistenceJob {
126    Conversation {
127        snapshot: Box<mermaid_domain::ConversationHistory>,
128        events: Vec<mermaid_domain::SessionEvent>,
129    },
130    Compaction(Box<PendingCompactionSave>),
131}
132
133#[derive(Clone)]
134struct PendingCompactionSave {
135    record: mermaid_domain::CompactionEvent,
136    conversation: mermaid_domain::ConversationHistory,
137    events: Vec<mermaid_domain::SessionEvent>,
138    /// Set once the events are durably appended, so a retry after a later
139    /// failure in the same save re-runs only what did not land — appending
140    /// twice would duplicate the boundary in the log.
141    events_appended: bool,
142    task_id: Option<String>,
143}
144
145struct PersistedCompaction {
146    id: String,
147    task_id: Option<String>,
148    session_id: String,
149    archive_path: PathBuf,
150}
151
152/// How many appended events may accumulate before the checkpoint is
153/// rewritten.
154///
155/// The quantity being bounded is REPLAY LENGTH — a checkpoint's whole job is
156/// to cap how much log a resume has to fold — so the trigger counts events
157/// rather than turns or seconds. At ~30 events for a tool-heavy turn this is
158/// roughly seven turns of replay in the worst case, against a rewrite that
159/// used to happen after every single message.
160const CHECKPOINT_EVERY_EVENTS: usize = 200;
161
162struct PersistenceState {
163    workdir: PathBuf,
164    manager: Option<crate::session::ConversationManager>,
165    blocked: HashMap<String, VecDeque<PendingCompactionSave>>,
166    /// Per session: events appended since its last checkpoint, and the
167    /// newest snapshot that has not been written as one. Shutdown flushes
168    /// these, so a clean exit always leaves a current checkpoint.
169    dirty: HashMap<String, DirtySession>,
170    /// Events whose append FAILED, kept in order for the next attempt.
171    /// Without this they are simply gone: the reducer drains its buffer at
172    /// emission, so a dropped batch is never re-offered — which stopped
173    /// mattering the moment the log became the truth rather than a copy.
174    unappended: HashMap<String, Vec<mermaid_domain::SessionEvent>>,
175}
176
177struct DirtySession {
178    snapshot: mermaid_domain::ConversationHistory,
179    events_since_checkpoint: usize,
180}
181
182impl PersistenceState {
183    fn new(workdir: PathBuf) -> Self {
184        Self {
185            workdir,
186            manager: None,
187            blocked: HashMap::new(),
188            dirty: HashMap::new(),
189            unappended: HashMap::new(),
190        }
191    }
192
193    fn manager(&mut self) -> anyhow::Result<&crate::session::ConversationManager> {
194        if self.manager.is_none() {
195            self.manager = Some(crate::session::ConversationManager::new(&self.workdir)?);
196        }
197        Ok(self.manager.as_ref().expect("manager initialized"))
198    }
199
200    /// Run one job. Returns every compaction event that persisted durably —
201    /// even when the job as a whole failed — so partially-drained barriers
202    /// still fire their hooks and `SessionSaved`; a dropped event would never
203    /// be re-emitted (its save is already popped).
204    fn process(&mut self, job: PersistenceJob) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
205        match job {
206            PersistenceJob::Conversation { snapshot, events } => {
207                // Barrier: a still-blocked compaction must persist before any
208                // newer (stripped) conversation snapshot may overwrite the file.
209                let (persisted, retried) = self.retry_blocked(&snapshot.id);
210                if retried.is_err() {
211                    return (persisted, retried);
212                }
213                let saved = self.save_session(*snapshot, events);
214                (persisted, saved)
215            },
216            PersistenceJob::Compaction(save) => {
217                // Queue first, then drain. The boundary event is the only
218                // record that the dropped messages ever existed, so the save
219                // must survive an Err AND a panic in the write path (pop
220                // happens only after success), and it must land behind any
221                // older still-blocked saves (FIFO).
222                let conversation_id = save.conversation.id.clone();
223                self.blocked
224                    .entry(conversation_id.clone())
225                    .or_default()
226                    .push_back(*save);
227                self.retry_blocked(&conversation_id)
228            },
229        }
230    }
231
232    fn retry_blocked(
233        &mut self,
234        conversation_id: &str,
235    ) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
236        let mut persisted = Vec::new();
237        if !self.blocked.contains_key(conversation_id) {
238            return (persisted, Ok(()));
239        }
240        if let Err(error) = self.manager() {
241            return (persisted, Err(error));
242        }
243        // Disjoint field borrows: the manager stays immutably borrowed while
244        // the queue is drained in place — no per-retry clone of the (large)
245        // pending conversation snapshots.
246        let manager = self.manager.as_ref().expect("manager initialized");
247        let dirty = &mut self.dirty;
248        let queue = self
249            .blocked
250            .get_mut(conversation_id)
251            .expect("checked above");
252        while let Some(save) = queue.front_mut() {
253            match Self::persist_compaction(manager, dirty, save) {
254                // Pop only after a successful write: `persist_compaction` runs
255                // inside `spawn_blocking`, and a panic there must not lose the
256                // save (the mutex is poison-tolerant, so the state survives).
257                Ok(event) => {
258                    persisted.push(event);
259                    queue.pop_front();
260                },
261                Err(error) => return (persisted, Err(error)),
262            }
263        }
264        self.blocked.remove(conversation_id);
265        (persisted, Ok(()))
266    }
267
268    /// Append a save's events, then write the checkpoint only when enough
269    /// have accumulated (see [`CHECKPOINT_EVERY_EVENTS`]).
270    ///
271    /// The append is the save now: it is the write that reaches the truth,
272    /// so a failure keeps the batch for the next attempt rather than
273    /// dropping it, and leaves the checkpoint alone — advancing a cache past
274    /// a log that did not take the events is how a "successful" save loses
275    /// them.
276    fn save_session(
277        &mut self,
278        snapshot: mermaid_domain::ConversationHistory,
279        events: Vec<mermaid_domain::SessionEvent>,
280    ) -> anyhow::Result<()> {
281        let id = snapshot.id.clone();
282        // Anything a previous attempt could not land goes first, in order.
283        let mut batch = self.unappended.remove(&id).unwrap_or_default();
284        batch.extend(events);
285
286        let manager = self.manager()?;
287        if let Err(error) = manager.append_session_events(&snapshot, &batch) {
288            tracing::warn!(
289                id = %id,
290                pending = batch.len(),
291                %error,
292                "session event append failed; holding the events for the next save"
293            );
294            self.unappended.insert(id, batch);
295            return Err(error);
296        }
297        upsert_session_index(manager, &snapshot);
298
299        let entry = self
300            .dirty
301            .entry(id.clone())
302            .or_insert_with(|| DirtySession {
303                snapshot: snapshot.clone(),
304                events_since_checkpoint: 0,
305            });
306        entry.snapshot = snapshot;
307        entry.events_since_checkpoint += batch.len();
308        if entry.events_since_checkpoint >= CHECKPOINT_EVERY_EVENTS {
309            return self.write_checkpoint(&id);
310        }
311        Ok(())
312    }
313
314    /// Materialize a session's checkpoint and clear its dirty counter. A
315    /// session with nothing outstanding is a no-op.
316    fn write_checkpoint(&mut self, id: &str) -> anyhow::Result<()> {
317        let Some(dirty) = self.dirty.remove(id) else {
318            return Ok(());
319        };
320        let manager = self.manager()?;
321        if let Err(error) = manager.save_conversation(&dirty.snapshot) {
322            // Put it back: the events are durable in the log either way, so
323            // this costs a longer replay, not data — but the next save
324            // should still try.
325            self.dirty.insert(id.to_string(), dirty);
326            return Err(error);
327        }
328        Ok(())
329    }
330
331    /// Write every outstanding checkpoint. Called at shutdown so a clean
332    /// exit always leaves a current one, which is what keeps the common
333    /// resume path short.
334    fn flush_checkpoints(&mut self) -> anyhow::Result<()> {
335        let ids: Vec<String> = self.dirty.keys().cloned().collect();
336        let mut first_error = None;
337        for id in ids {
338            if let Err(error) = self.write_checkpoint(&id) {
339                first_error.get_or_insert(error);
340            }
341        }
342        first_error.map_or(Ok(()), Err)
343    }
344
345    fn retry_all_blocked(&mut self) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
346        let ids: Vec<String> = self.blocked.keys().cloned().collect();
347        let mut persisted = Vec::new();
348        let mut first_error = None;
349        for id in ids {
350            // Keep draining the other conversations' barriers; one
351            // conversation's bad disk state must not strand the rest.
352            let (events, result) = self.retry_blocked(&id);
353            persisted.extend(events);
354            if let Err(error) = result {
355                first_error.get_or_insert(error);
356            }
357        }
358        match first_error {
359            None => (persisted, Ok(())),
360            Some(error) => (persisted, Err(error)),
361        }
362    }
363
364    fn persist_compaction(
365        manager: &crate::session::ConversationManager,
366        dirty: &mut HashMap<String, DirtySession>,
367        save: &mut PendingCompactionSave,
368    ) -> anyhow::Result<PersistedCompaction> {
369        // Order is the whole guarantee. A compaction drops messages from the
370        // conversation, and the only remaining record of them is this
371        // session's log — the earlier `message` events plus the `compaction`
372        // event that marks the boundary. So the append must land BEFORE the
373        // stripped snapshot overwrites the file that still holds the fuller
374        // history, and a failed append must abort the save (this is where
375        // the archive file's `?` used to be), leaving the queue blocked and
376        // the old snapshot intact for the retry.
377        if !save.events_appended {
378            manager.append_session_events(&save.conversation, &save.events)?;
379            save.events_appended = true;
380        }
381        manager.save_conversation(&save.conversation)?;
382        upsert_session_index(manager, &save.conversation);
383        // A compaction always checkpoints: it is a structural boundary, it
384        // is rare, and the transcript it leaves behind is the one a resume
385        // should start from rather than replay its way to.
386        dirty.remove(&save.conversation.id);
387
388        let log_path = manager.event_log_path(&save.conversation.id);
389        let _ = mermaid_runtime::with_shared_store(|store| {
390            store.compactions().create(compaction_row(
391                &save.record,
392                &log_path,
393                save.task_id.clone(),
394                save.conversation.id.clone(),
395            ))
396        });
397
398        Ok(PersistedCompaction {
399            id: save.record.id.clone(),
400            task_id: save.task_id.clone(),
401            session_id: save.conversation.id.clone(),
402            archive_path: log_path,
403        })
404    }
405}
406
407/// Fire the plugin `compaction` hook for one durably persisted archive.
408async fn fire_compaction_hook(event: &PersistedCompaction) {
409    fire_plugin_hooks(
410        "compaction",
411        serde_json::json!({
412            "id": event.id,
413            "task_id": event.task_id,
414            "session_id": event.session_id,
415            "archive_path": event.archive_path.display().to_string(),
416        }),
417    )
418    .await;
419}
420
421/// The runner. One instance per process, constructed by
422/// `app::run` and consumed when the main loop exits.
423pub struct EffectRunner {
424    msg_tx: MsgSender,
425    /// Per-turn scopes. Populated lazily: the first `Cmd` bearing a
426    /// `TurnId` creates a scope; `Cmd::CancelScope` tears it down.
427    /// Empty (drained) scopes are reaped by `reap_empty_scopes`, which
428    /// runs at the top of every `dispatch` call so the map stays
429    /// bounded across long sessions (F12).
430    scopes: HashMap<TurnId, TurnScope>,
431    /// F38: bounded tombstone ring of `TurnId`s whose scope has been
432    /// cancelled+dropped. A turn-scoped `Cmd` (`CallModel` / `ExecuteTool` /
433    /// `CompactConversation`) bearing a tombstoned id is dropped in `dispatch`
434    /// instead of resurrecting a fresh, un-cancelled scope through
435    /// `scope_mut`'s `or_insert_with`. Bounded to `CANCELLED_TOMBSTONE_CAP`.
436    cancelled_turns: VecDeque<TurnId>,
437    /// Detached work (saves, persists, MCP lifecycle) lives here.
438    /// This one set never gets cancelled piecemeal — shutdown drains
439    /// it during `EffectRunner::shutdown`.
440    detached: tokio::task::JoinSet<()>,
441    /// FIFO chain for conversation and compaction writes. Keeping persistence
442    /// separate from `detached` prevents an older compaction snapshot from
443    /// racing a newer normal save and winning last-write-wins.
444    persistence_state: Arc<Mutex<PersistenceState>>,
445    persistence_tail: Option<tokio::task::JoinHandle<()>>,
446    /// MCP manager handle is held elsewhere (`crate::mcp` has a
447    /// `OnceLock` for its global manager); we just note workdir so
448    /// handlers can construct absolute paths.
449    workdir: PathBuf,
450    /// Lazy provider registry. `CallModel` resolves through this.
451    /// Tests that don't care about real providers leave this `None`
452    /// and observe the fallback `UpstreamError` Msg; production
453    /// construction via `with_bindings` sets it.
454    providers: Option<Arc<ProviderFactory>>,
455    /// Shared tool registry. See `providers` — same optionality
456    /// rationale for unit tests.
457    tools: Option<Arc<ToolRegistry>>,
458    /// Durable runtime task that owns work launched by this runner.
459    task_id: Option<String>,
460    /// Interactive TUI runners write OSC 2 terminal-title updates.
461    /// Headless `mermaid run` must suppress them so stdout stays
462    /// machine-readable for JSON/markdown/text output modes.
463    terminal_title_enabled: bool,
464    /// Whether this runner's `shutdown` reaps the PROCESS-GLOBAL MCP manager
465    /// (`crate::mcp::manager_ref`). True only for the top-level runner. A
466    /// subagent's child runner shares the global manager, so it must NOT reap
467    /// it — otherwise the first subagent to finish would kill every MCP
468    /// server out from under the parent for the rest of the session.
469    owns_global_mcp: bool,
470    /// Inline-approval broker. `Some` only for interactive TUI runs (set via
471    /// `with_interactive_approvals`); headless + child runners leave it `None`,
472    /// so the gate falls back to the out-of-band DB-approval flow.
473    approval: Option<crate::providers::ApprovalBroker>,
474    /// Inline-question broker for `ask_user_question`. `Some` only for
475    /// interactive TUI runs (set via `with_interactive_questions`); headless +
476    /// child runners leave it `None`, so the tool proceeds without asking.
477    questions: Option<crate::providers::QuestionBroker>,
478    /// Checklist broker for the task tools. Built unconditionally — unlike
479    /// `questions`, task tracking works headless, and a subagent's child
480    /// runner minting its own broker (bound to the CHILD's msg channel) is
481    /// exactly what isolates its checklist from the parent's.
482    tasks: crate::providers::TaskBroker,
483    /// Abort handle for the background config watcher (#45). It's a perpetual
484    /// loop living in `detached`, so `shutdown` aborts it explicitly before
485    /// draining — otherwise the drain would block on it until the timeout.
486    config_watch: Option<tokio::task::AbortHandle>,
487}
488
489impl EffectRunner {
490    /// Create an unused runner. Pair with `msg_rx` from `channel()`.
491    #[must_use]
492    pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
493        let persistence_state = Arc::new(Mutex::new(PersistenceState::new(workdir.clone())));
494        Self {
495            tasks: crate::providers::TaskBroker::new(msg_tx.clone()),
496            msg_tx,
497            scopes: HashMap::new(),
498            cancelled_turns: VecDeque::new(),
499            detached: tokio::task::JoinSet::new(),
500            persistence_state,
501            persistence_tail: None,
502            workdir,
503            providers: None,
504            tools: None,
505            task_id: None,
506            terminal_title_enabled: true,
507            owns_global_mcp: true,
508            approval: None,
509            questions: None,
510            config_watch: None,
511        }
512    }
513
514    /// The channel every effect result arrives on.
515    ///
516    /// Cloning it is how the brokers already deliver a user's approval or
517    /// answer back into the reducer; `crate::engine::EngineHandle` uses it to
518    /// give the same reach to something outside the process's own effects.
519    #[must_use]
520    pub fn sender(&self) -> MsgSender {
521        self.msg_tx.clone()
522    }
523
524    /// Enable inline approval prompts (interactive TUI only). The gate then
525    /// pauses gated tools and routes the user's decision through the
526    /// `ApprovalBroker` instead of writing an out-of-band DB approval row.
527    #[must_use]
528    pub fn with_interactive_approvals(mut self) -> Self {
529        self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
530        self
531    }
532
533    /// Enable inline `ask_user_question` prompts (interactive TUI only). The tool
534    /// then parks on the `QuestionBroker` and routes the user's answers back
535    /// through it instead of proceeding without asking.
536    #[must_use]
537    pub fn with_interactive_questions(mut self) -> Self {
538        self.questions = Some(crate::providers::QuestionBroker::new(self.msg_tx.clone()));
539        self
540    }
541
542    /// Start the background config watcher (#45): it polls `MERMAID.md` + memory
543    /// and emits `Msg::InstructionsChanged`/`MemoryChanged` on change, so the
544    /// reducer reads them as injected data instead of refreshing inline. Call
545    /// once at startup. Live-loop only — a replay driver feeds the recorded
546    /// Changed Msgs rather than polling.
547    pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
548        let handle = self.detached.spawn(config_watch::config_watcher(
549            self.msg_tx.clone(),
550            cwd,
551            memory,
552        ));
553        self.config_watch = Some(handle);
554    }
555
556    /// Attach a durable runtime task id so tool runs, approvals,
557    /// checkpoints, compactions, and background processes can be linked.
558    #[must_use]
559    pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
560        self.task_id = task_id;
561        self
562    }
563
564    /// Disable terminal-title writes for non-interactive callers.
565    #[must_use]
566    pub fn without_terminal_title(mut self) -> Self {
567        self.terminal_title_enabled = false;
568        self
569    }
570
571    /// Leave the process-global MCP manager alone on `shutdown`. Child
572    /// (subagent) runners share it with the parent and must not reap it.
573    #[must_use]
574    pub fn without_global_mcp_shutdown(mut self) -> Self {
575        self.owns_global_mcp = false;
576        self
577    }
578
579    /// Attach provider + tool registries. Production wiring uses
580    /// this; unit tests that don't need real dispatch can skip.
581    /// Without bindings, `CallModel` / `ExecuteTool` emit well-
582    /// formed error Msgs so the reducer still transitions cleanly.
583    pub fn with_bindings(
584        mut self,
585        providers: Arc<ProviderFactory>,
586        tools: Arc<ToolRegistry>,
587    ) -> Self {
588        self.providers = Some(providers);
589        self.tools = Some(tools);
590        self
591    }
592
593    /// Pair-constructor: returns both the runner and the receiving
594    /// end of the Msg channel. Preferred for production wiring
595    /// because it keeps the channel capacity constant in one place.
596    #[must_use]
597    pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
598        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
599        (Self::new(tx, workdir), rx)
600    }
601
602    /// Pair constructor that also wires the real provider factory +
603    /// tool registry. Used by `app::run_interactive`.
604    #[must_use]
605    pub fn pair_with_bindings(
606        workdir: PathBuf,
607        config: Config,
608        tools: Arc<ToolRegistry>,
609    ) -> (Self, mpsc::Receiver<Msg>) {
610        let providers = Arc::new(ProviderFactory::new(config));
611        Self::pair_from(workdir, providers, tools)
612    }
613
614    /// Pair constructor that takes a pre-built `ProviderFactory`.
615    /// Used when the caller needs to share a `ProviderFactory` with
616    /// the `SubagentSpawner` so subagents can issue model calls
617    /// through the same cache.
618    pub fn pair_from(
619        workdir: PathBuf,
620        providers: Arc<ProviderFactory>,
621        tools: Arc<ToolRegistry>,
622    ) -> (Self, mpsc::Receiver<Msg>) {
623        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
624        (Self::new(tx, workdir).with_bindings(providers, tools), rx)
625    }
626
627    pub fn pair_from_with_task(
628        workdir: PathBuf,
629        providers: Arc<ProviderFactory>,
630        tools: Arc<ToolRegistry>,
631        task_id: Option<String>,
632    ) -> (Self, mpsc::Receiver<Msg>) {
633        let (runner, rx) = Self::pair_from(workdir, providers, tools);
634        (runner.with_task_id(task_id), rx)
635    }
636
637    /// Construct a runner that shares a pre-derived cancellation
638    /// token for its turn scopes. Used by `SubagentSpawner` so the
639    /// child runner's work aborts as soon as the parent's `ctx.token`
640    /// fires.
641    pub fn new_child(
642        msg_tx: MsgSender,
643        workdir: PathBuf,
644        providers: Arc<ProviderFactory>,
645        tools: Arc<ToolRegistry>,
646    ) -> Self {
647        // A subagent's runner is never the interactive top-level, so it must
648        // NOT emit OSC 2 terminal-title escapes: in a headless `mermaid run`
649        // the parent suppresses them, but an un-suppressed child leaks
650        // `\x1b]2;…\x07` into stdout and corrupts `--format json`/`text` output.
651        // It must also leave the process-global MCP manager running — the
652        // child shares the parent's servers, and reaping them here would kill
653        // MCP for the whole session the moment the first subagent finished.
654        Self::new(msg_tx, workdir)
655            .with_bindings(providers, tools)
656            .without_terminal_title()
657            .without_global_mcp_shutdown()
658    }
659
660    /// Get or create the scope for a turn. Idempotent. The scope is
661    /// retained until `CancelScope` tears it down or it naturally
662    /// drains.
663    fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
664        self.scopes
665            .entry(turn)
666            .or_insert_with(|| TurnScope::new(turn))
667    }
668
669    /// F38: record a cancelled turn in the bounded tombstone ring, evicting the
670    /// oldest id at capacity. Skips duplicates so a re-cancel doesn't churn the
671    /// ring (membership is all `is_tombstoned` checks).
672    fn tombstone_turn(&mut self, turn: TurnId) {
673        if self.cancelled_turns.contains(&turn) {
674            return;
675        }
676        if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
677            self.cancelled_turns.pop_front();
678        }
679        self.cancelled_turns.push_back(turn);
680    }
681
682    /// F38: true iff `turn`'s scope was cancelled (tombstoned). New turn-scoped
683    /// work for such a turn is dropped rather than spinning up a fresh scope.
684    fn is_tombstoned(&self, turn: TurnId) -> bool {
685        self.cancelled_turns.contains(&turn)
686    }
687
688    /// Run one read-only `Cmd::Query` lookup and answer with
689    /// `Msg::QueryResult`. Conversation reads and provider discovery run
690    /// async; everything touching the runtime store or the filesystem walk
691    /// goes through [`Self::send_blocking_query`] so a synchronous read never
692    /// stalls an async worker thread (#40).
693    fn dispatch_query(&mut self, query: Query) {
694        let tx = self.msg_tx.clone();
695        match query {
696            Query::LoadConversation { id } => self.query_load_conversation(id, tx),
697            Query::ListConversations => self.query_list_conversations(tx),
698            Query::ListAvailableModels => {
699                let providers = self.providers.clone();
700                self.detached.spawn(async move {
701                    let choices = discover_available_models(providers).await;
702                    let _ = tx
703                        .send(Msg::QueryResult(QueryResult::AvailableModelsListed(
704                            choices,
705                        )))
706                        .await;
707                });
708            },
709            Query::ListProjectFiles => {
710                let workdir = self.workdir.clone();
711                self.send_blocking_query(move || {
712                    QueryResult::ProjectFilesListed(walk_project_files(&workdir))
713                });
714            },
715            Query::ListOutputStyles => self.dispatch_list_output_styles(),
716            Query::LoadOutputStyle { name, project } => {
717                self.dispatch_load_output_style(name, project);
718            },
719            Query::ListRuntimeTasks { limit } => self.send_blocking_query(move || {
720                QueryResult::RuntimeTasksListed(
721                    crate::runtime_client::RuntimeClient::auto()
722                        .list_tasks(limit)
723                        .map(|read| read.value)
724                        .unwrap_or_default(),
725                )
726            }),
727            Query::LoadRuntimeTask { id } => self.send_blocking_query(move || {
728                let (task, events) = crate::runtime_client::RuntimeClient::auto()
729                    .task_detail(&id)
730                    .map(|read| (Some(Box::new(read.value.task)), read.value.events))
731                    .unwrap_or((None, Vec::new()));
732                QueryResult::RuntimeTaskLoaded { task, events }
733            }),
734            Query::ListRuntimeProcesses { limit } => self.send_blocking_query(move || {
735                QueryResult::RuntimeProcessesListed(
736                    crate::runtime_client::RuntimeClient::auto()
737                        .list_processes(limit)
738                        .map(|read| read.value)
739                        .unwrap_or_default(),
740                )
741            }),
742            Query::ListRuntimeApprovals => self.send_blocking_query(move || {
743                QueryResult::RuntimeApprovalsListed(
744                    crate::runtime_client::RuntimeClient::auto()
745                        .list_approvals()
746                        .map(|read| read.value)
747                        .unwrap_or_default(),
748                )
749            }),
750            Query::ListRuntimeCheckpoints { limit } => self.send_blocking_query(move || {
751                QueryResult::RuntimeCheckpointsListed(
752                    crate::runtime_client::RuntimeClient::auto()
753                        .list_checkpoints(limit)
754                        .map(|read| read.value)
755                        .unwrap_or_default(),
756                )
757            }),
758            Query::ListForkCheckpoints {
759                session_id,
760                message_index,
761            } => self.send_blocking_query(move || {
762                QueryResult::ForkCheckpointsFound(
763                    mermaid_runtime::with_shared_store(|store| {
764                        store
765                            .checkpoints()
766                            .list_for_session(&session_id, message_index as i64)
767                    })
768                    .unwrap_or_default(),
769                )
770            }),
771            Query::ListRuntimePlugins => self.send_blocking_query(move || {
772                QueryResult::RuntimePluginsListed(
773                    crate::runtime_client::RuntimeClient::auto()
774                        .list_plugins()
775                        .map(|read| read.value)
776                        .unwrap_or_default(),
777                )
778            }),
779        }
780    }
781
782    /// `Query::ListOutputStyles` — every selectable output style
783    /// (built-ins plus user/project files).
784    fn dispatch_list_output_styles(&mut self) {
785        let workdir = self.workdir.clone();
786        self.send_blocking_query(move || {
787            QueryResult::OutputStylesListed(crate::app::output_styles::list_styles(&workdir))
788        });
789    }
790
791    /// `Query::LoadOutputStyle` — one style's body for the `/output-style`
792    /// switch. Answers `found: false` when nothing carries the name; the
793    /// reducer falls back to `default`.
794    fn dispatch_load_output_style(&mut self, name: String, project: bool) {
795        let workdir = self.workdir.clone();
796        self.send_blocking_query(move || {
797            let loaded = crate::app::output_styles::load_style_body(&workdir, &name);
798            let (found, body, keep, custom, source) = match loaded {
799                Some((body, keep, custom, source)) => {
800                    (true, body, keep, custom, source.to_string())
801                },
802                None => (false, String::new(), true, false, String::new()),
803            };
804            QueryResult::OutputStyleLoaded {
805                name,
806                project,
807                found,
808                body,
809                keep_coding_instructions: keep,
810                custom,
811                source,
812            }
813        });
814    }
815
816    /// `Query::LoadConversation` — read one saved conversation off disk. A
817    /// missing/corrupt file answers nothing: the failure is logged and the
818    /// picker simply does not advance.
819    fn query_load_conversation(&mut self, id: String, tx: MsgSender) {
820        let workdir = self.workdir.clone();
821        self.detached.spawn(async move {
822            match crate::session::ConversationManager::new(&workdir) {
823                Ok(mgr) => match mgr.load_conversation(&id) {
824                    Ok(history) => {
825                        let _ = tx
826                            .send(Msg::QueryResult(QueryResult::ConversationLoaded(Box::new(
827                                history,
828                            ))))
829                            .await;
830                    },
831                    Err(e) => {
832                        tracing::warn!(id = %id, error = %e, "LoadConversation failed");
833                    },
834                },
835                Err(e) => {
836                    tracing::warn!(error = %e, "ConversationManager init failed");
837                },
838            }
839        });
840    }
841
842    /// `Query::ListConversations` — scan the conversations directory for the
843    /// `/load` picker (newest first).
844    fn query_list_conversations(&mut self, tx: MsgSender) {
845        let workdir = self.workdir.clone();
846        self.detached.spawn(async move {
847            let summaries = match crate::session::ConversationManager::new(&workdir) {
848                Ok(mgr) => mgr
849                    .list_conversation_metas()
850                    .unwrap_or_default()
851                    .into_iter()
852                    .map(|m| mermaid_domain::ConversationSummary {
853                        id: m.id,
854                        title: m.title,
855                        message_count: m.message_count,
856                        updated_at: m.updated_at.to_rfc3339(),
857                    })
858                    .collect(),
859                Err(_) => Vec::new(),
860            };
861            let _ = tx
862                .send(Msg::QueryResult(QueryResult::ConversationsListed(
863                    summaries,
864                )))
865                .await;
866        });
867    }
868
869    /// Run a synchronous lookup on the blocking pool and deliver its
870    /// `Msg::QueryResult` — the shared plumbing of every store/filesystem
871    /// query (rusqlite reads and the project walk must never stall an async
872    /// worker thread, #40).
873    fn send_blocking_query(&mut self, run: impl FnOnce() -> QueryResult + Send + 'static) {
874        let tx = self.msg_tx.clone();
875        self.detached.spawn_blocking(move || {
876            let _ = tx.blocking_send(Msg::QueryResult(run()));
877        });
878    }
879
880    /// Drop the scope for a turn, signalling cancellation to every
881    /// child first. Safe to call for non-existent turns.
882    ///
883    /// After the scope is cancelled, a detached task moves it off the
884    /// runner, drains its `JoinSet` (so child tasks unwind), then emits
885    /// `Msg::TurnCancelled(turn)` so the reducer can transition
886    /// `Cancelling → Idle`. Without this terminal event the TUI would
887    /// stick in `Cancelling` — the reducer has no other way to learn
888    /// that the abort fully landed.
889    fn drop_scope(&mut self, turn: TurnId) {
890        // F38: tombstone this turn so a stray post-cancel turn-scoped Cmd can't
891        // resurrect an un-cancelled scope for it. Recorded for both the live and
892        // already-reaped branches below — once cancelled, a turn is dead either
893        // way (turn ids are monotonic and never reused).
894        self.tombstone_turn(turn);
895        if let Some(mut scope) = self.scopes.remove(&turn) {
896            scope.cancel();
897            let tx = self.msg_tx.clone();
898            self.detached.spawn(async move {
899                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
900                    .await
901                    .is_err()
902                {
903                    tracing::warn!(
904                        turn = %turn,
905                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
906                        "cancel drain timed out; aborting remaining scoped tasks"
907                    );
908                }
909                let _ = tx.send(Msg::TurnCancelled(turn)).await;
910            });
911        } else {
912            // The scope was already reaped — its `JoinSet` drained to empty
913            // and `reap_empty_scopes` (top of `dispatch`) removed it before
914            // this cancel landed. The reducer is still in `Cancelling` with
915            // no other way to learn the turn ended, so emit the terminal
916            // event anyway. Idempotent: `handle_turn_cancelled` no-ops on
917            // any turn that isn't currently `Cancelling`.
918            let tx = self.msg_tx.clone();
919            self.detached.spawn(async move {
920                let _ = tx.send(Msg::TurnCancelled(turn)).await;
921            });
922        }
923    }
924
925    /// Number of active per-turn scopes. Tests use this to observe
926    /// lifecycle without racing on internal state.
927    #[must_use]
928    pub fn scope_count(&self) -> usize {
929        self.scopes.len()
930    }
931
932    /// F12: remove scope entries whose `JoinSet` is empty — every
933    /// child task has completed, so the scope is just an orphan key
934    /// in the map. Called at the top of `dispatch` so the map stays
935    /// bounded over long sessions. Cheap: one linear walk, no async.
936    ///
937    /// `JoinSet::is_empty` only returns true after completed tasks are
938    /// harvested via `join_next`/`try_join_next`, so we first drain
939    /// any ready completions per scope.
940    fn reap_empty_scopes(&mut self) {
941        self.reap_detached();
942        self.scopes.retain(|_, scope| {
943            scope.drain_completed();
944            !scope.is_empty()
945        });
946    }
947
948    /// Harvest finished detached tasks. Without this the `detached` `JoinSet`
949    /// grows for the whole session (every fire-and-forget effect lingers as a
950    /// completed-but-unjoined handle), and a panicking detached task vanishes
951    /// without a trace. Non-blocking — only already-finished tasks are taken (#38).
952    fn reap_detached(&mut self) {
953        while let Some(result) = self.detached.try_join_next() {
954            if let Err(e) = result
955                && !e.is_cancelled()
956            {
957                tracing::warn!(error = %e, "effect: detached task panicked");
958            }
959        }
960    }
961
962    /// Route a single `Cmd` into the appropriate spawn + handler.
963    /// Returns immediately; handlers work asynchronously and emit
964    /// `Msg` back through the sender channel.
965    #[expect(
966        clippy::too_many_lines,
967        reason = "the effect router: one arm per Cmd variant, each spawning or calling the \
968         handler that owns that effect; the arms are short and the routing table is the point, so \
969         the length is the Cmd count and drops only as commands are retired"
970    )]
971    pub fn dispatch(&mut self, cmd: Cmd) {
972        // F12: reap any drained scopes before touching the map. Keeps
973        // `scope_count()` bounded as the session grows.
974        self.reap_empty_scopes();
975        tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
976
977        // F38: refuse to spawn fresh work for a turn we've already cancelled.
978        // Only the scope-spawning variants carry a `scope_turn()`; `CancelScope`
979        // returns `None` here so a re-cancel still reaches `drop_scope` (which
980        // re-emits the terminal `TurnCancelled` the reducer needs). Turn ids are
981        // monotonic and never reused, so a tombstoned id can only be a stray
982        // post-cancel straggler — dropping it stops `scope_mut`'s `or_insert_with`
983        // from resurrecting an un-cancelled scope.
984        if let Some(turn) = cmd.scope_turn()
985            && self.is_tombstoned(turn)
986        {
987            tracing::debug!(
988                cmd = %cmd.summary(),
989                turn = %turn,
990                "effect: dropping turn-scoped cmd for an already-cancelled turn"
991            );
992            return;
993        }
994
995        match cmd {
996            Cmd::CallModel { turn, mut request } => {
997                let tx = self.msg_tx.clone();
998                let providers = self.providers.clone();
999                // Enrich `request.tools` with every user-facing
1000                // tool in the bound registry. The reducer has
1001                // already populated MCP tools from `state.mcp`;
1002                // built-ins come from the runner (which holds the
1003                // registry). This keeps `ChatRequest.tools` the
1004                // single source of truth for what the model sees.
1005                // Formatting turns (`output_schema`) advertise NO tools —
1006                // the reducer already sent none; don't re-add built-ins.
1007                if let Some(tools) = &self.tools
1008                    && request.output_schema.is_none()
1009                {
1010                    let mut enriched = tools.describe_all();
1011                    // Report the built-in tool-schema token cost so the
1012                    // reducer's /context preview can fold it into its MCP-only
1013                    // estimate and agree with what the model actually sees.
1014                    let builtin_tokens = mermaid_domain::estimate_tool_schema_tokens(&enriched);
1015                    // Best-effort and cosmetic (the /context preview). This is the
1016                    // synchronous dispatch path so we can't await; if the bounded
1017                    // channel is momentarily full under heavy streaming, log the
1018                    // drop rather than swallowing it silently — the estimate just
1019                    // stays briefly stale (#F43).
1020                    if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
1021                        tracing::debug!(
1022                            error = %e,
1023                            "effect: dropped builtin tool-schema token estimate (channel full); \
1024                             /context preview may be briefly stale"
1025                        );
1026                    }
1027                    enriched.append(&mut request.tools);
1028                    request.tools = enriched;
1029                }
1030                // Detached + off the blocking pool: never run a plugin hook on
1031                // the synchronous dispatch path (it would freeze input/render).
1032                self.detached.spawn(fire_plugin_hooks(
1033                    "prompt_submit",
1034                    serde_json::json!({
1035                        "turn_id": turn.0,
1036                        "model_id": request.model_id.clone(),
1037                        "message_count": request.messages.len(),
1038                        "tool_count": request.tools.len(),
1039                    }),
1040                ));
1041                // Task cost attribution: model dispatch reports each request's
1042                // completion tokens into the broker's cumulative counter.
1043                let task_usage = self.tasks.clone();
1044                let scope = self.scope_mut(turn);
1045                let token = scope.token();
1046                scope.spawn(async move {
1047                    use futures::FutureExt;
1048                    let fallback_tx = tx.clone();
1049                    if std::panic::AssertUnwindSafe(dispatch_call_model(
1050                        tx, providers, turn, request, token, task_usage,
1051                    ))
1052                    .catch_unwind()
1053                    .await
1054                    .is_err()
1055                    {
1056                        // The dispatch task panicked. A turn whose model call
1057                        // never emits a terminal Msg stays in `Generating`
1058                        // forever; emit one so the reducer can leave that state
1059                        // instead of wedging (#43).
1060                        tracing::error!(turn = %turn, "dispatch_call_model panicked");
1061                        let _ = fallback_tx
1062                            .send(Msg::UpstreamError {
1063                                turn,
1064                                error: mermaid_model::models::UserFacingError {
1065                                    summary: "Internal error".to_string(),
1066                                    message: "The model dispatch task panicked unexpectedly."
1067                                        .to_string(),
1068                                    suggestion: "This is a bug. Please retry; if it persists, \
1069                                                 check the logs."
1070                                        .to_string(),
1071                                    category: mermaid_model::models::ErrorCategory::Internal,
1072                                    recoverable: true,
1073                                },
1074                            })
1075                            .await;
1076                    }
1077                });
1078            },
1079            Cmd::CompactConversation { turn, mut request } => {
1080                let tx = self.msg_tx.clone();
1081                let providers = self.providers.clone();
1082                if let Some(tools) = &self.tools {
1083                    let mut enriched = tools.describe_all();
1084                    enriched.append(&mut request.chat.tools);
1085                    request.chat.tools = enriched;
1086                }
1087                // Capture the trigger before `request` moves into the task, so a
1088                // panic fallback can still name which compaction failed.
1089                let trigger = request.trigger;
1090                let scope = self.scope_mut(turn);
1091                let token = scope.token();
1092                scope.spawn(async move {
1093                    use futures::FutureExt;
1094                    let fallback_tx = tx.clone();
1095                    if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
1096                        tx, providers, turn, request, token,
1097                    ))
1098                    .catch_unwind()
1099                    .await
1100                    .is_err()
1101                    {
1102                        // The compaction task panicked. Without a terminal
1103                        // `CompactionFinished`/`CompactionFailed`, the reducer
1104                        // wedges in `Compacting` until Ctrl+C; emit a failure so
1105                        // it can recover, mirroring `CallModel`/`ExecuteTool`
1106                        // (#43, F37).
1107                        tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
1108                        let _ = fallback_tx
1109                            .send(Msg::CompactionFailed {
1110                                turn,
1111                                trigger,
1112                                message: "the compaction task panicked unexpectedly".to_string(),
1113                                kind: mermaid_domain::StatusKind::Error,
1114                            })
1115                            .await;
1116                    }
1117                });
1118            },
1119            Cmd::ExecuteTool {
1120                turn,
1121                call_id,
1122                source,
1123                dispatch,
1124            } => {
1125                let tx = self.msg_tx.clone();
1126                let tools = self.tools.clone();
1127                let workdir = self.workdir.clone();
1128                // Pass the shared Config from ProviderFactory so
1129                // subagents inherit it (F7). Falls back to
1130                // Config::default() when providers aren't bound (unit
1131                // tests without real wiring).
1132                let config = self
1133                    .providers
1134                    .as_ref()
1135                    .map(|p| Arc::new(p.config().clone()))
1136                    .unwrap_or_else(|| Arc::new(mermaid_domain::Config::default()));
1137                // Auto mode: build an LLM classifier to vet borderline
1138                // actions. Only when a provider is bound (real wiring); the
1139                // gate fails safe to "escalate" when it's `None`. The vet
1140                // uses the configured classifier model, else the session model.
1141                let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
1142                    if dispatch.safety_mode == mermaid_runtime::SafetyMode::Auto {
1143                        self.providers.as_ref().map(|p| {
1144                            let model = config
1145                                .safety
1146                                .auto_classifier_model
1147                                .clone()
1148                                .unwrap_or_else(|| dispatch.model_id.clone());
1149                            Arc::new(crate::providers::ModelAutoClassifier::new(
1150                                p.clone(),
1151                                model,
1152                                dispatch.reasoning,
1153                            ))
1154                                as Arc<dyn crate::providers::AutoClassifier>
1155                        })
1156                    } else {
1157                        None
1158                    };
1159                let services = crate::providers::ctx::ToolServices {
1160                    workdir,
1161                    config,
1162                    task_id: self.task_id.clone(),
1163                    // Detached work (backgrounded subagents) reports back
1164                    // through the main msg channel after this turn's
1165                    // progress relay is gone.
1166                    notify: Some(self.msg_tx.clone()),
1167                    classifier,
1168                    approval: self.approval.clone(),
1169                    questions: self.questions.clone(),
1170                    tasks: Some(self.tasks.clone()),
1171                };
1172                let scope = self.scope_mut(turn);
1173                let signals = crate::providers::ctx::TurnSignals {
1174                    token: scope.token(),
1175                    background: scope.background_token(),
1176                    web_bytes: scope.web_bytes(),
1177                };
1178                scope.spawn(async move {
1179                    use futures::FutureExt;
1180                    let fallback_tx = tx.clone();
1181                    if std::panic::AssertUnwindSafe(dispatch_execute_tool(
1182                        tx, tools, turn, call_id, source, signals, dispatch, services,
1183                    ))
1184                    .catch_unwind()
1185                    .await
1186                    .is_err()
1187                    {
1188                        // The tool task panicked. Its turn waits on a
1189                        // `ToolFinished` for this `call_id` that will now never
1190                        // arrive; emit a terminal error outcome so the turn
1191                        // doesn't wedge (#43).
1192                        tracing::error!(
1193                            turn = %turn,
1194                            call_id = call_id.0,
1195                            "dispatch_execute_tool panicked"
1196                        );
1197                        let _ = fallback_tx
1198                            .send(Msg::ToolFinished {
1199                                turn,
1200                                call_id,
1201                                outcome: mermaid_domain::ToolOutcome::error(
1202                                    "internal error: the tool execution task panicked".to_string(),
1203                                    None,
1204                                ),
1205                            })
1206                            .await;
1207                    }
1208                });
1209            },
1210            Cmd::ResolveApproval { call_id, decision } => {
1211                // Deliver the user's inline decision to the parked tool task.
1212                // Not turn-scoped — fire-and-forget to the broker.
1213                if let Some(broker) = &self.approval {
1214                    broker.resolve(call_id, decision.into());
1215                }
1216            },
1217            Cmd::ResolveQuestion {
1218                call_id,
1219                resolution,
1220            } => {
1221                // Deliver the user's answers to the parked ask_user_question
1222                // task. Not turn-scoped — fire-and-forget to the broker.
1223                if let Some(broker) = &self.questions {
1224                    broker.resolve(call_id, resolution);
1225                }
1226            },
1227            Cmd::SyncTaskStore(store) => {
1228                // Reducer-initiated truth overwrite (rewind/fork, /clear,
1229                // startup resume). Synchronous; the broker does not publish
1230                // back — the reducer already holds this store.
1231                self.tasks.seed(store);
1232            },
1233            Cmd::EnsureScratchpad { session_id } => {
1234                let tx = self.msg_tx.clone();
1235                let workdir = self.workdir.clone();
1236                self.detached.spawn(async move {
1237                    match crate::session::scratchpad::ensure(&workdir, &session_id) {
1238                        Ok(path) => {
1239                            let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
1240                        },
1241                        Err(err) => {
1242                            // Non-fatal: the session runs without a scratch
1243                            // dir (`Session::scratchpad` stays `None`).
1244                            tracing::warn!(error = %err, "failed to create session scratchpad");
1245                        },
1246                    }
1247                    // Best-effort reap of unlocked scratchpads past retention —
1248                    // piggybacks on session startup, no separate timer.
1249                    if let Err(err) = crate::session::scratchpad::sweep_stale(
1250                        crate::session::scratchpad::RETENTION_DAYS,
1251                    ) {
1252                        tracing::warn!(error = %err, "scratchpad sweep failed");
1253                    }
1254                });
1255            },
1256            Cmd::ListScratchpad { path } => {
1257                // `/scratchpad` — bounded directory listing back into the
1258                // transcript. Blocking filesystem walk, so off the runner.
1259                let tx = self.msg_tx.clone();
1260                self.detached.spawn(async move {
1261                    let text = tokio::task::spawn_blocking(move || {
1262                        crate::session::scratchpad::list_text(&path)
1263                    })
1264                    .await
1265                    .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
1266                    let _ = tx.send(Msg::RuntimeText(text)).await;
1267                });
1268            },
1269            Cmd::UserTaskEdit(edit) => {
1270                // Route the user's /tasks edit through the broker (single
1271                // writer) so it serializes with any in-flight tool call. The
1272                // broker publishes the resulting snapshot; the outcome line
1273                // lands in the transcript as transient status.
1274                let broker = self.tasks.clone();
1275                let tx = self.msg_tx.clone();
1276                self.detached.spawn(async move {
1277                    let (line, _snapshot) = broker.user_edit(edit).await;
1278                    // The user sees the ack in the transcript; the model
1279                    // learns about it on its next request via the notice
1280                    // buffer (a checklist the model believes in but the user
1281                    // has edited is the worst of both).
1282                    let _ = tx
1283                        .send(Msg::TaskNotice {
1284                            text: format!("The user edited the task checklist: {line}."),
1285                        })
1286                        .await;
1287                    let _ = tx.send(Msg::TransientStatus { text: line }).await;
1288                });
1289            },
1290            Cmd::NotifyTaskCompleted {
1291                task,
1292                completed,
1293                total,
1294            } => {
1295                // Gated `task_completed` plugin hook: a denying hook VETOES
1296                // the completion — the task flips back to in_progress via the
1297                // broker (single writer; the publish refreshes the band) and
1298                // the reason reaches both the user (transcript) and the model
1299                // (notice buffer). Fail-open like every plugin hook: no
1300                // enabled hooks / timeout => allow, zero latency added
1301                // elsewhere because this runs detached.
1302                let payload = serde_json::json!({
1303                    "task_id": task.id,
1304                    "subject": task.subject,
1305                    "description": task.description,
1306                    "evidence": task.evidence,
1307                    "completed": completed,
1308                    "total": total,
1309                });
1310                let broker = self.tasks.clone();
1311                let tx = self.msg_tx.clone();
1312                self.detached.spawn(async move {
1313                    let gate = run_plugin_hooks_gated("task_completed", payload).await;
1314                    let Some((plugin, reason)) = gate.deny else {
1315                        return;
1316                    };
1317                    let reason = mermaid_model::utils::redact_secrets(&reason);
1318                    let _ = broker
1319                        .update(vec![mermaid_domain::ChecklistEdit {
1320                            id: task.id,
1321                            status: Some(mermaid_domain::ChecklistStatus::InProgress),
1322                            ..mermaid_domain::ChecklistEdit::default()
1323                        }])
1324                        .await;
1325                    let _ = tx
1326                        .send(Msg::TaskNotice {
1327                            text: format!(
1328                                "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
1329                                 {reason}. The task is back in_progress; address the reason \
1330                                 before completing it again.",
1331                                task.id, task.subject
1332                            ),
1333                        })
1334                        .await;
1335                    let _ = tx
1336                        .send(Msg::TransientStatus {
1337                            text: format!(
1338                                "task #{} completion vetoed by {plugin}: {reason}",
1339                                task.id
1340                            ),
1341                        })
1342                        .await;
1343                });
1344            },
1345            Cmd::CancelScope(turn) => {
1346                self.drop_scope(turn);
1347            },
1348            Cmd::BackgroundScope(turn) => {
1349                // Fire the scope's background token (don't drop the scope):
1350                // detachable tools move their child to a background process and
1351                // return a normal outcome, so the turn finishes naturally.
1352                self.scope_mut(turn).background();
1353            },
1354            Cmd::SaveConversation { snapshot, events } => {
1355                self.queue_persistence(PersistenceJob::Conversation {
1356                    snapshot: Box::new(snapshot),
1357                    events,
1358                });
1359            },
1360            Cmd::SaveCompaction {
1361                record,
1362                conversation,
1363                events,
1364            } => {
1365                self.queue_persistence(PersistenceJob::Compaction(Box::new(
1366                    PendingCompactionSave {
1367                        record,
1368                        conversation,
1369                        events,
1370                        events_appended: false,
1371                        task_id: self.task_id.clone(),
1372                    },
1373                )));
1374            },
1375            Cmd::SaveProcess(process) => {
1376                let task_id = self.task_id.clone();
1377                self.detached.spawn(async move {
1378                    let status = process.status;
1379                    let _ = mermaid_runtime::with_shared_store(|store| {
1380                        store.processes().upsert(mermaid_runtime::NewProcess {
1381                            id: Some(process.id),
1382                            task_id,
1383                            pid: process.pid,
1384                            command: process.command,
1385                            cwd: process.cwd,
1386                            log_path: Some(process.log_path),
1387                            detected_url: process.detected_url,
1388                            status,
1389                            health: None,
1390                        })
1391                    });
1392                });
1393            },
1394            Cmd::PersistLastModel(model) => {
1395                self.detached.spawn(async move {
1396                    if let Err(err) = crate::app::persist_last_model(&model) {
1397                        tracing::warn!(error = %err, "failed to persist last-used model");
1398                    }
1399                });
1400            },
1401            Cmd::PersistReasoningFor { model_id, level } => {
1402                self.detached.spawn(async move {
1403                    if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1404                        tracing::warn!(error = %err, "failed to persist reasoning level for model");
1405                    }
1406                });
1407            },
1408            Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1409                self.detached.spawn(async move {
1410                    if let Err(err) =
1411                        crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1412                    {
1413                        tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1414                    }
1415                });
1416            },
1417            Cmd::PersistOllamaOffload(enabled) => {
1418                self.detached.spawn(async move {
1419                    if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1420                        tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1421                    }
1422                });
1423            },
1424            Cmd::PersistUiTheme(theme) => {
1425                self.detached.spawn(async move {
1426                    if let Err(err) = crate::app::persist_ui_theme(theme) {
1427                        tracing::warn!(error = %err, "failed to persist theme");
1428                    }
1429                });
1430            },
1431            Cmd::PersistOutputStyle { style } => {
1432                self.detached.spawn(async move {
1433                    if let Err(err) = crate::app::persist_output_style(&style) {
1434                        tracing::warn!(error = %err, "failed to persist output style");
1435                    }
1436                });
1437            },
1438            Cmd::PersistProjectOutputStyle { style } => {
1439                let tx = self.msg_tx.clone();
1440                let workdir = self.workdir.clone();
1441                self.detached.spawn(async move {
1442                    if let Err(err) = crate::app::persist_project_output_style(&workdir, &style) {
1443                        let _ = tx
1444                            .send(Msg::TransientStatus {
1445                                text: format!("Couldn't save the project output style: {err}"),
1446                            })
1447                            .await;
1448                    }
1449                });
1450            },
1451            Cmd::ComposeInEditor { .. } => {
1452                // Run-loop-intercepted in the interactive TUI (it owns the
1453                // terminal + event stream). Reaching the effect runner means a
1454                // headless driver emitted it — nothing to suspend there.
1455                tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1456            },
1457            Cmd::ListMemory => {
1458                let tx = self.msg_tx.clone();
1459                let workdir = self.workdir.clone();
1460                self.detached.spawn(async move {
1461                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1462                    let text = match crate::app::memory::load(&workdir, &cfg) {
1463                        Some(mem) => mem.index,
1464                        None => "No memories saved yet. Durable facts (yours or mine) show up here — use `/remember <fact>` or just ask me to remember something.".to_string(),
1465                    };
1466                    let _ = tx.send(Msg::RuntimeText(text)).await;
1467                });
1468            },
1469            Cmd::RememberMemory { text } => {
1470                let tx = self.msg_tx.clone();
1471                let workdir = self.workdir.clone();
1472                self.detached.spawn(async move {
1473                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1474                    let name = memory_title_from_text(&text);
1475                    let status = match crate::app::memory::write_memory(
1476                        &workdir,
1477                        mermaid_domain::MemoryScope::ProjectPrivate,
1478                        &name,
1479                        &text,
1480                        &[],
1481                        &text,
1482                    ) {
1483                        Ok(_) => format!("Remembered: {name}"),
1484                        Err(e) => format!("Couldn't save memory: {e}"),
1485                    };
1486                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1487                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1488                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
1489                });
1490            },
1491            Cmd::ForgetMemory { id } => {
1492                let tx = self.msg_tx.clone();
1493                let workdir = self.workdir.clone();
1494                self.detached.spawn(async move {
1495                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1496                    let status = match crate::app::memory::delete_memory(&workdir, &id) {
1497                        Ok(Some(_)) => format!("Forgot: {id}"),
1498                        Ok(None) => format!("No memory named '{id}'"),
1499                        Err(e) => format!("Couldn't forget memory: {e}"),
1500                    };
1501                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1502                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1503                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
1504                });
1505            },
1506            Cmd::ConsolidateMemory { model_id } => {
1507                let tx = self.msg_tx.clone();
1508                let workdir = self.workdir.clone();
1509                let providers = self.providers.clone();
1510                self.detached.spawn(async move {
1511                    consolidate_memory(tx, providers, workdir, model_id).await;
1512                });
1513            },
1514            Cmd::Query(query) => self.dispatch_query(query),
1515            Cmd::ShowRuntimeProcessLogs { id } => {
1516                let tx = self.msg_tx.clone();
1517                self.detached.spawn_blocking(move || {
1518                    let text = crate::runtime_client::RuntimeClient::auto()
1519                        .process_log(&id, None)
1520                        .map(|log| format!("Process log {}\n\n{}", id, log.content))
1521                        .unwrap_or_else(|err| format!("Process log error: {err}"));
1522                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1523                });
1524            },
1525            Cmd::StopRuntimeProcess { id } => {
1526                let tx = self.msg_tx.clone();
1527                self.detached.spawn_blocking(move || {
1528                    let msg = match crate::runtime_client::RuntimeClient::auto().stop_process(&id) {
1529                        Ok(response) => Msg::TransientStatus {
1530                            text: format!("Stopped process {} (pid {})", id, response.item.pid),
1531                        },
1532                        Err(err) => Msg::TransientStatus {
1533                            text: format!("Process stop failed: {err}"),
1534                        },
1535                    };
1536                    let _ = tx.blocking_send(msg);
1537                });
1538            },
1539            Cmd::KillBackgroundAgent { agent_id } => {
1540                // Synchronous token fire — no task to spawn. Feedback flows
1541                // through the dying child's `Msg::BackgroundAgentFinished`
1542                // (the reducer already validated the id against its registry).
1543                let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1544                if let Some(spawner) = spawner {
1545                    match agent_id {
1546                        Some(id) => {
1547                            // Killing a finished child hands back the
1548                            // workspace it was holding for a continuation
1549                            // that can no longer happen. Discarding it needs
1550                            // to be async, so it rides a task.
1551                            if let crate::providers::tool::subagent::KillResult::Evicted(
1552                                workspace,
1553                            ) = spawner.kill_detached(&id)
1554                            {
1555                                // In `detached`, not a bare `tokio::spawn`: shutdown
1556                                // drains that set, so quitting mid-discard cannot
1557                                // leave the worktree on disk with no record of it.
1558                                self.detached.spawn(async move {
1559                                    workspace.discard().await;
1560                                });
1561                            }
1562                        },
1563                        None => {
1564                            spawner.kill_all_detached();
1565                        },
1566                    }
1567                }
1568            },
1569            Cmd::RestartRuntimeProcess { id } => {
1570                let tx = self.msg_tx.clone();
1571                self.detached.spawn_blocking(move || {
1572                    let msg = match crate::runtime_client::RuntimeClient::auto()
1573                        .restart_process(&id)
1574                    {
1575                        Ok(response) => Msg::TransientStatus {
1576                            text: format!("Restarted process {} (pid {})", id, response.item.pid),
1577                        },
1578                        Err(err) => Msg::TransientStatus {
1579                            text: format!("Process restart failed: {err}"),
1580                        },
1581                    };
1582                    let _ = tx.blocking_send(msg);
1583                });
1584            },
1585            Cmd::OpenRuntimeTarget { target } => {
1586                self.detached.spawn_blocking(move || {
1587                    let resolved = crate::runtime_client::RuntimeService::open_default()
1588                        .and_then(|service| service.resolve_open_target(&target))
1589                        .unwrap_or(target);
1590                    // #63: the resolved value can be a `detected_url`/`log_path`
1591                    // from a `processes` row — validate before the OS opener,
1592                    // exactly like `open_process`.
1593                    if let Err(err) = crate::runtime_client::validate_open_target(&resolved) {
1594                        tracing::warn!(error = %err, "refusing to open runtime target");
1595                        return;
1596                    }
1597                    mermaid_model::utils::open_file(resolved);
1598                });
1599            },
1600            Cmd::ShowRuntimePorts => {
1601                let tx = self.msg_tx.clone();
1602                self.detached.spawn_blocking(move || {
1603                    let text = crate::runtime_client::RuntimeClient::auto()
1604                        .ports()
1605                        .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1606                        .unwrap_or_else(|err| format!("Port inspection failed: {err}"));
1607                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1608                });
1609            },
1610            Cmd::DecideRuntimeApproval { id, decision } => {
1611                let tx = self.msg_tx.clone();
1612                self.detached.spawn_blocking(move || {
1613                    let result = if decision == "approved" {
1614                        crate::runtime_client::RuntimeClient::auto().approve(&id)
1615                    } else {
1616                        crate::runtime_client::RuntimeClient::auto().deny(&id)
1617                    };
1618                    let msg = match result {
1619                        Ok(result) => Msg::TransientStatus {
1620                            text: if result.replayed {
1621                                format!("Approval {} {}: {}", id, decision, result.summary)
1622                            } else {
1623                                format!("Approval {id} {decision}")
1624                            },
1625                        },
1626                        Err(err) => Msg::TransientStatus {
1627                            text: format!("Approval update failed: {err}"),
1628                        },
1629                    };
1630                    let _ = tx.blocking_send(msg);
1631                });
1632            },
1633            Cmd::UpdateRuntimeTaskStatus {
1634                id,
1635                status,
1636                final_report,
1637            } => {
1638                let tx = self.msg_tx.clone();
1639                self.detached.spawn_blocking(move || {
1640                    let msg = match mermaid_runtime::with_shared_store(|store| {
1641                        store
1642                            .tasks()
1643                            .update_status(&id, status, final_report.as_deref())
1644                    }) {
1645                        Ok(()) => Msg::TransientStatus {
1646                            text: format!("Task {id} -> {status}"),
1647                        },
1648                        Err(err) => Msg::TransientStatus {
1649                            text: format!("Task update failed: {err}"),
1650                        },
1651                    };
1652                    let _ = tx.blocking_send(msg);
1653                });
1654            },
1655            Cmd::CreateRuntimeCheckpoint { paths } => {
1656                let tx = self.msg_tx.clone();
1657                let workdir = self.workdir.clone();
1658                self.detached.spawn_blocking(move || {
1659                    let pending_action = Some(serde_json::json!({
1660                        "source": "tui",
1661                        "command": "checkpoint",
1662                    }));
1663                    let msg = match mermaid_runtime::create_checkpoint(
1664                        &workdir,
1665                        &paths,
1666                        pending_action,
1667                    ) {
1668                        Ok(manifest) => Msg::TransientStatus {
1669                            text: format!(
1670                                "Checkpoint {} created for {} path(s)",
1671                                manifest.id,
1672                                manifest.files.len()
1673                            ),
1674                        },
1675                        Err(err) => Msg::TransientStatus {
1676                            text: format!("Checkpoint failed: {err}"),
1677                        },
1678                    };
1679                    let _ = tx.blocking_send(msg);
1680                });
1681            },
1682            Cmd::RestoreRuntimeCheckpoint { id } => {
1683                let tx = self.msg_tx.clone();
1684                self.detached.spawn_blocking(move || {
1685                    let msg = match crate::runtime_client::RuntimeClient::auto()
1686                        .restore_checkpoint(&id)
1687                    {
1688                        Ok(result) => Msg::TransientStatus {
1689                            text: format!(
1690                                "Restored checkpoint {} ({} file(s)){}",
1691                                result.checkpoint.id,
1692                                result.checkpoint.files.len(),
1693                                if result.checkpoint.pending_action.is_some() {
1694                                    "; pending action available in checkpoint manifest"
1695                                } else {
1696                                    ""
1697                                }
1698                            ),
1699                        },
1700                        Err(err) => Msg::TransientStatus {
1701                            text: format!("Restore failed: {err}"),
1702                        },
1703                    };
1704                    let _ = tx.blocking_send(msg);
1705                });
1706            },
1707            Cmd::ShowRuntimeModelInfo { model } => {
1708                let tx = self.msg_tx.clone();
1709                self.detached.spawn_blocking(move || {
1710                    let text = runtime_model_info_text(&model);
1711                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1712                });
1713            },
1714            Cmd::InitMcpServers(configs) => {
1715                let tx = self.msg_tx.clone();
1716                self.detached
1717                    .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1718            },
1719            Cmd::StopMcpServer { name } => {
1720                let tx = self.msg_tx.clone();
1721                self.detached.spawn(async move {
1722                    // Actually kill the child before claiming it's stopped —
1723                    // otherwise the UI says "stopped" while the server runs on.
1724                    if let Some(mgr) = crate::mcp::manager_ref::get() {
1725                        mgr.stop_server(&name).await;
1726                    }
1727                    let _ = tx.send(Msg::McpServerStopped { name }).await;
1728                });
1729            },
1730            Cmd::PullOllamaModel { model } => {
1731                let tx = self.msg_tx.clone();
1732                self.detached.spawn(async move {
1733                    dispatch_pull_ollama_model(tx, model).await;
1734                });
1735            },
1736            Cmd::OpenInSystem(path) => {
1737                self.detached.spawn(async move {
1738                    let _ = tokio::task::spawn_blocking(move || {
1739                        mermaid_model::utils::open_file(&path);
1740                    })
1741                    .await;
1742                });
1743            },
1744            Cmd::WriteImageToTemp {
1745                path,
1746                bytes,
1747                format: _,
1748            } => {
1749                self.detached.spawn(async move {
1750                    if let Err(e) = tokio::fs::write(&path, &bytes).await {
1751                        tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1752                    }
1753                });
1754            },
1755            Cmd::ReadClipboard => {
1756                let tx = self.msg_tx.clone();
1757                self.detached.spawn(async move {
1758                    dispatch_read_clipboard(tx).await;
1759                });
1760            },
1761            Cmd::ProbeVision { model_id, warn } => {
1762                let tx = self.msg_tx.clone();
1763                let providers = self.providers.clone();
1764                self.detached.spawn(async move {
1765                    dispatch_probe_vision(model_id, warn, providers, tx).await;
1766                });
1767            },
1768            Cmd::CopyToClipboard(text) => {
1769                let tx = self.msg_tx.clone();
1770                self.detached.spawn(async move {
1771                    dispatch_copy_to_clipboard(text, tx).await;
1772                });
1773            },
1774            Cmd::Exit => {
1775                // The main loop observes `state.should_exit` after
1776                // the reducer returns; the runner doesn't need to
1777                // take any special action. Documented here for
1778                // exhaustiveness.
1779            },
1780            Cmd::SetTerminalTitle(title) => {
1781                if !self.terminal_title_enabled {
1782                    return;
1783                }
1784                // Offload the terminal write to the blocking pool: writing to
1785                // stdout can block when the terminal (or a downstream pipe) is
1786                // slow, and an async worker must not block on it (#44). The
1787                // OSC-2 title sequence is out-of-band relative to the renderer's
1788                // frame draws, so it doesn't corrupt them.
1789                self.detached.spawn_blocking(move || {
1790                    use std::io::Write;
1791                    let seq = format!("\x1b]2;{title}\x07");
1792                    let mut stdout = std::io::stdout();
1793                    let _ = stdout.write_all(seq.as_bytes());
1794                    let _ = stdout.flush();
1795                });
1796            },
1797            Cmd::AlertUser => {
1798                if !self.terminal_title_enabled {
1799                    return;
1800                }
1801                // A single BEL nudges the terminal to alert (dock bounce / tab
1802                // highlight). Offloaded to the blocking pool like the title.
1803                self.detached.spawn_blocking(|| {
1804                    use std::io::Write;
1805                    let mut stdout = std::io::stdout();
1806                    let _ = stdout.write_all(b"\x07");
1807                    let _ = stdout.flush();
1808                });
1809            },
1810        }
1811    }
1812
1813    fn queue_persistence(&mut self, job: PersistenceJob) {
1814        let previous = self.persistence_tail.take();
1815        let state = Arc::clone(&self.persistence_state);
1816        let tx = self.msg_tx.clone();
1817        self.persistence_tail = Some(tokio::spawn(async move {
1818            if let Some(previous) = previous
1819                && let Err(error) = previous.await
1820            {
1821                tracing::warn!(error = %error, "previous persistence job panicked");
1822            }
1823
1824            let result = tokio::task::spawn_blocking(move || {
1825                state
1826                    .lock()
1827                    .unwrap_or_else(|error| error.into_inner())
1828                    .process(job)
1829            })
1830            .await;
1831
1832            match result {
1833                Ok((events, outcome)) => {
1834                    // Events report durable writes even when the job as a
1835                    // whole failed — a partially drained barrier already
1836                    // persisted those archives, and they are never re-emitted.
1837                    if outcome.is_ok() || !events.is_empty() {
1838                        let _ = tx.send(Msg::SessionSaved).await;
1839                    }
1840                    for event in events {
1841                        fire_compaction_hook(&event).await;
1842                    }
1843                    if let Err(error) = outcome {
1844                        tracing::warn!(
1845                            error = %error,
1846                            "persistence job failed; compaction barriers remain queued"
1847                        );
1848                    }
1849                },
1850                Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1851            }
1852        }));
1853    }
1854
1855    /// Async shutdown: cancel every scope, then wait for all spawned
1856    /// work to drain. Bounded by 5 seconds — a hung task past that
1857    /// gets aborted outright by `JoinSet::drop`.
1858    pub async fn shutdown(mut self) {
1859        for (id, scope) in self.scopes.iter() {
1860            tracing::debug!(turn = %id, "shutdown: cancelling scope");
1861            scope.cancel();
1862        }
1863
1864        // The config watcher (#45) is a perpetual loop in `detached`; abort it
1865        // so the drain below doesn't block on it until the bounded timeout.
1866        if let Some(handle) = self.config_watch.take() {
1867            handle.abort();
1868        }
1869
1870        // Drain with a bounded timeout.
1871        let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1872
1873        let owns_global_mcp = self.owns_global_mcp;
1874        let persistence_tail = self.persistence_tail.take();
1875        let persistence_state = Arc::clone(&self.persistence_state);
1876        let drain = async {
1877            if let Some(tail) = persistence_tail
1878                && let Err(error) = tail.await
1879            {
1880                tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1881            }
1882            match tokio::task::spawn_blocking(move || {
1883                let mut state = persistence_state
1884                    .lock()
1885                    .unwrap_or_else(|error| error.into_inner());
1886                let drained = state.retry_all_blocked();
1887                // Then materialize whatever the ~200-event throttle has been
1888                // holding back, so a clean exit always leaves a current
1889                // checkpoint and the next resume replays nothing.
1890                if let Err(error) = state.flush_checkpoints() {
1891                    tracing::warn!(
1892                        error = %error,
1893                        "shutdown: could not flush a session checkpoint; the log still has everything, so the next resume just folds further"
1894                    );
1895                }
1896                drop(state);
1897                drained
1898            })
1899            .await
1900            {
1901                Ok((events, outcome)) => {
1902                    // A barrier drained at shutdown still owes its hooks —
1903                    // these events are never re-emitted.
1904                    for event in events {
1905                        fire_compaction_hook(&event).await;
1906                    }
1907                    if let Err(error) = outcome {
1908                        tracing::warn!(
1909                            error = %error,
1910                            "shutdown: compaction persistence barrier retry failed"
1911                        );
1912                    }
1913                },
1914                Err(error) => tracing::warn!(
1915                    error = %error,
1916                    "shutdown: compaction persistence barrier panicked"
1917                ),
1918            }
1919            // Only the top-level runner reaps the process-global MCP manager.
1920            // A subagent's child runner shares it; reaping here would kill the
1921            // parent's servers the moment the first subagent finished.
1922            if owns_global_mcp {
1923                // If an MCP init is still in flight, its child processes are
1924                // already spawned but `set_manager` hasn't run yet — `get()`
1925                // below would return `None` and we'd leak those children. Wait
1926                // (bounded) for init to settle so the manager is installed
1927                // before we reap it (#59).
1928                let _ = tokio::time::timeout(
1929                    std::time::Duration::from_secs(2),
1930                    crate::mcp::manager_ref::wait_ready(),
1931                )
1932                .await;
1933                // Gracefully shut down MCP server children (the stdin-EOF →
1934                // terminate → kill ladder in `McpServerManager::shutdown`). The
1935                // manager lives in a `'static OnceLock` that never drops, so
1936                // this explicit call on the exit path is the only thing that
1937                // reaps those child processes. No-op when no servers were
1938                // configured.
1939                if let Some(mgr) = crate::mcp::manager_ref::get() {
1940                    mgr.shutdown().await;
1941                }
1942                // Tear down the auto-managed SearXNG process (zero-config
1943                // web_search). Same ownership rule as MCP: only the top-level
1944                // runner reaps process-global services. No-op if none started.
1945                crate::searxng::shutdown().await;
1946            }
1947            // F42: bound each per-scope drain so one non-cooperative task can't
1948            // eat the whole shutdown budget and starve the remaining scopes'
1949            // drains (the scopes were all cancelled above, so a well-behaved task
1950            // unwinds well within this). On timeout, dropping `scope` aborts its
1951            // still-running `JoinSet` members via `TurnScope::drop`.
1952            for (id, mut scope) in self.scopes.drain() {
1953                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1954                    .await
1955                    .is_err()
1956                {
1957                    tracing::warn!(
1958                        turn = %id,
1959                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1960                        "shutdown: scope drain timed out; aborting its remaining tasks"
1961                    );
1962                }
1963            }
1964            while let Some(result) = self.detached.join_next().await {
1965                if let Err(e) = result
1966                    && !e.is_cancelled()
1967                {
1968                    tracing::warn!(error = %e, "shutdown: detached task panic");
1969                }
1970            }
1971        };
1972
1973        let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1974    }
1975}
1976
1977/// Dispatch a `CallModel` command. Resolves the provider (lazy,
1978/// cached) and streams its events onto the Msg channel. Without a
1979/// bound `ProviderFactory` (unit tests), emits a single
1980/// `UpstreamError` so the reducer ends the turn cleanly.
1981/// Report a completed request's completion tokens into the task broker's
1982/// cumulative counter, so task cost deltas (`tokens_spent`) can be computed
1983/// between `in_progress` and completed stamps.
1984fn note_stream_usage(
1985    tasks: &crate::providers::TaskBroker,
1986    usage: &Option<mermaid_model::models::TokenUsage>,
1987) {
1988    if let Some(usage) = usage {
1989        tasks.add_tokens(usage.completion_tokens as u64);
1990    }
1991}
1992
1993mod compaction;
1994mod memory;
1995mod model_call;
1996mod tool_call;
1997
1998use compaction::*;
1999use memory::*;
2000use model_call::*;
2001use tool_call::*;
2002
2003#[cfg(test)]
2004mod tests {
2005    /// Pins the domain-event -> durable-row field mapping. Only `id` and
2006    /// `archive_path` share a name between the two types, so every other line
2007    /// of `compaction_row` is a decision nothing else records.
2008    #[test]
2009    fn compaction_row_maps_every_field_it_claims_to() {
2010        use mermaid_domain::{CompactionEvent, CompactionTrigger};
2011        let record = CompactionEvent {
2012            id: "cmp-1".to_string(),
2013            trigger: CompactionTrigger::Manual,
2014            created_at: chrono::Local::now(),
2015            before_tokens: 9_000,
2016            after_tokens: 1_200,
2017            archived_message_count: 40,
2018            preserved_message_count: 6,
2019            preserved_turn_count: 3,
2020            summary_tokens: 450,
2021            duration_secs: 1.5,
2022            focus: None,
2023            archive_path: None,
2024        };
2025        let row = compaction_row(
2026            &record,
2027            std::path::Path::new("/tmp/archive.json"),
2028            Some("task-7".to_string()),
2029            "sess-3".to_string(),
2030        );
2031        assert_eq!(row.id.as_deref(), Some("cmp-1"));
2032        assert_eq!(row.task_id.as_deref(), Some("task-7"));
2033        assert_eq!(row.session_id.as_deref(), Some("sess-3"));
2034        assert_eq!(row.source_token_estimate, Some(9_000));
2035        assert_eq!(row.summary_token_count, Some(450));
2036        assert_eq!(row.preserved_turns, Some(3));
2037        assert!(row.archive_path.is_some_and(|p| p.contains("archive.json")));
2038    }
2039
2040    use super::*;
2041    use mermaid_domain::ToolCallId;
2042    use std::time::Duration;
2043
2044    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2045        EffectRunner::pair(PathBuf::from("/tmp"))
2046    }
2047
2048    #[test]
2049    fn runtime_tool_payloads_are_redacted_before_serialization() {
2050        let payload = serde_json::json!({
2051            "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
2052            "authorization": "opaque-secret-value",
2053            "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
2054        });
2055        let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
2056        assert!(
2057            !serialized.contains("hunter2"),
2058            "URL password leaked: {serialized}"
2059        );
2060        assert!(
2061            !serialized.contains("opaque-signature"),
2062            "signed URL leaked: {serialized}"
2063        );
2064        assert!(
2065            !serialized.contains("private"),
2066            "URL fragment leaked: {serialized}"
2067        );
2068        assert!(
2069            !serialized.contains("opaque-secret-value"),
2070            "credential-named field leaked: {serialized}"
2071        );
2072        assert!(
2073            !serialized.contains("abcdef123456ghijkl"),
2074            "bearer token leaked: {serialized}"
2075        );
2076        assert!(
2077            !serialized.contains("sk-abcdefghijklmnop1234"),
2078            "secret-shaped fetched content leaked: {serialized}"
2079        );
2080        assert!(serialized.contains("[REDACTED]"));
2081    }
2082
2083    #[test]
2084    fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
2085        let root = std::env::temp_dir().join(format!(
2086            "mermaid-walk-{}-{:?}",
2087            std::process::id(),
2088            std::thread::current().id()
2089        ));
2090        let _ = std::fs::remove_dir_all(&root);
2091        std::fs::create_dir_all(root.join("src")).unwrap();
2092        std::fs::create_dir_all(root.join("target")).unwrap();
2093        std::fs::create_dir_all(root.join(".git")).unwrap();
2094        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
2095        std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2096        std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
2097        std::fs::write(root.join("README.md"), "readme").unwrap();
2098        std::fs::write(root.join(".hidden"), "hidden").unwrap();
2099
2100        let files = walk_project_files(&root);
2101        assert_eq!(
2102            files,
2103            vec![
2104                "README.md".to_string(),
2105                "src/".to_string(),
2106                "src/main.rs".to_string(),
2107            ],
2108            "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
2109        );
2110        let _ = std::fs::remove_dir_all(&root);
2111    }
2112
2113    #[test]
2114    fn new_child_suppresses_terminal_title() {
2115        // A subagent's child runner must not emit OSC 2 terminal titles —
2116        // otherwise they leak into a headless parent's stdout and corrupt
2117        // `--format json`/`text` output (caught during live headless testing).
2118        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2119        let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2120        let tools = Arc::new(ToolRegistry::new());
2121        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2122        assert!(
2123            !child.terminal_title_enabled,
2124            "subagent child runner must suppress terminal-title escapes"
2125        );
2126    }
2127
2128    #[test]
2129    fn new_child_does_not_own_global_mcp_shutdown() {
2130        // The MCP manager is process-global and shared with the parent. A
2131        // child runner's shutdown (which runs after EVERY subagent) must not
2132        // reap it — that would kill the parent's MCP servers for the rest of
2133        // the session. Only the top-level runner owns the reap.
2134        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2135        let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2136        let tools = Arc::new(ToolRegistry::new());
2137        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2138        assert!(
2139            !child.owns_global_mcp,
2140            "child runner must not reap the shared global MCP manager"
2141        );
2142        let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
2143        assert!(
2144            top.owns_global_mcp,
2145            "top-level runner still owns the global MCP reap"
2146        );
2147    }
2148
2149    #[test]
2150    fn parse_prune_plan_extracts_json_amid_prose() {
2151        let plan = parse_prune_plan(
2152            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2153        )
2154        .expect("should parse");
2155        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2156        assert_eq!(plan.reason, "dupes");
2157    }
2158
2159    #[test]
2160    fn parse_prune_plan_handles_empty_and_garbage() {
2161        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2162            .expect("empty plan parses");
2163        assert!(empty.prune.is_empty());
2164        assert!(parse_prune_plan("no json here").is_none());
2165    }
2166
2167    #[test]
2168    fn memory_title_from_text_is_short_and_nonempty() {
2169        assert_eq!(
2170            memory_title_from_text("prefer ripgrep over grep"),
2171            "prefer ripgrep over grep"
2172        );
2173        assert_eq!(memory_title_from_text("   "), "memory");
2174        let long = memory_title_from_text("one two three four five six seven eight nine ten");
2175        assert!(long.split_whitespace().count() <= 8);
2176    }
2177
2178    #[tokio::test]
2179    async fn dispatch_exit_is_noop_on_runner_state() {
2180        let (mut r, _rx) = runner();
2181        r.dispatch(Cmd::Exit);
2182        assert_eq!(r.scope_count(), 0);
2183    }
2184
2185    #[tokio::test]
2186    async fn dispatch_save_emits_session_saved() {
2187        let (mut r, mut rx) = runner();
2188        r.dispatch(Cmd::SaveConversation {
2189            snapshot: mermaid_domain::ConversationHistory::new(
2190                "/p".to_string(),
2191                "m".to_string(),
2192                chrono::Local::now(),
2193            ),
2194            events: Vec::new(),
2195        });
2196        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2197            .await
2198            .expect("sender emits")
2199            .expect("channel alive");
2200        assert!(matches!(msg, Msg::SessionSaved));
2201    }
2202
2203    #[cfg(unix)]
2204    #[tokio::test]
2205    async fn init_mcp_servers_emits_incremental_errored_msgs() {
2206        // Two servers that both fail fast (nonexistent binaries): each
2207        // resolves independently and emits its own Errored msg; init
2208        // completes after both. Also exercises the empty-manager install.
2209        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
2210        let mut configs = std::collections::HashMap::new();
2211        for name in ["one", "two"] {
2212            configs.insert(
2213                name.to_string(),
2214                mermaid_domain::McpServerConfig {
2215                    command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
2216                    ..Default::default()
2217                },
2218            );
2219        }
2220        dispatch_init_mcp_servers(configs, tx).await;
2221        let mut errored = Vec::new();
2222        while let Ok(msg) = rx.try_recv() {
2223            match msg {
2224                Msg::McpServerErrored { name, .. } => errored.push(name),
2225                other => panic!("unexpected msg: {other:?}"),
2226            }
2227        }
2228        errored.sort();
2229        assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
2230        assert!(crate::mcp::manager_ref::is_ready());
2231    }
2232
2233    #[tokio::test]
2234    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2235        let (mut r, mut rx) = runner();
2236        let turn = TurnId(77);
2237        {
2238            let scope = r.scope_mut(turn);
2239            scope.spawn(async {
2240                std::future::pending::<()>().await;
2241            });
2242        }
2243        assert_eq!(r.scope_count(), 1);
2244
2245        let start = std::time::Instant::now();
2246        r.dispatch(Cmd::CancelScope(turn));
2247        assert_eq!(r.scope_count(), 0);
2248        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2249            .await
2250            .expect("bounded cancel should emit terminal message")
2251            .expect("channel alive");
2252        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2253        assert!(
2254            start.elapsed() < Duration::from_millis(500),
2255            "cancel terminal message took {:?}",
2256            start.elapsed()
2257        );
2258    }
2259
2260    #[tokio::test]
2261    async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2262        // Regression (Axis 1 #9): if a turn's tasks complete and
2263        // `reap_empty_scopes` removes the now-empty scope before the user's
2264        // cancel lands, `drop_scope` used to be a silent no-op and the reducer
2265        // stuck forever in `Cancelling`. The terminal `TurnCancelled` must fire
2266        // even when the scope is already gone.
2267        let (mut r, mut rx) = runner();
2268        let turn = TurnId(88);
2269        {
2270            let scope = r.scope_mut(turn);
2271            scope.spawn(async {}); // completes immediately
2272        }
2273        assert_eq!(r.scope_count(), 1);
2274
2275        // Let the task finish, then any dispatch reaps the now-empty scope.
2276        tokio::time::sleep(Duration::from_millis(20)).await;
2277        r.dispatch(Cmd::Exit);
2278        assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2279
2280        // The scope is gone, but the reducer is still `Cancelling`: cancel must
2281        // still produce a terminal message.
2282        r.dispatch(Cmd::CancelScope(turn));
2283        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2284            .await
2285            .expect("cancel on a reaped scope must still emit a terminal message")
2286            .expect("channel alive");
2287        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2288    }
2289
2290    #[tokio::test]
2291    async fn dispatch_call_model_creates_scope() {
2292        let (mut r, _rx) = runner();
2293        let turn = TurnId(7);
2294        let request = mermaid_domain::ChatRequest {
2295            model_id: "test/m".to_string(),
2296            messages: vec![],
2297            system_prompt: String::new(),
2298            instructions: None,
2299            reasoning: mermaid_model::models::ReasoningLevel::Medium,
2300            temperature: 0.7,
2301            max_tokens: 4096,
2302            tools: vec![],
2303
2304            ollama_num_ctx: None,
2305            ollama_allow_ram_offload: None,
2306            resolved_context_window: None,
2307            resolved_max_output: None,
2308            output_schema: None,
2309            suppress_auto_compact: false,
2310            requested_compaction: None,
2311            native_compaction: None,
2312            native_tools: mermaid_model::models::NativeTools::default(),
2313        };
2314        r.dispatch(Cmd::CallModel { turn, request });
2315        assert_eq!(r.scope_count(), 1);
2316    }
2317
2318    /// F12: after a spawned task completes (here via the
2319    /// no-ProviderFactory error path), the next `dispatch` call reaps
2320    /// the empty scope instead of leaving an orphan entry in the map.
2321    #[tokio::test]
2322    async fn empty_scopes_are_reaped_on_next_dispatch() {
2323        let (mut r, mut rx) = runner();
2324        let turn = TurnId(42);
2325        let request = mermaid_domain::ChatRequest {
2326            model_id: "test/m".to_string(),
2327            messages: vec![],
2328            system_prompt: String::new(),
2329            instructions: None,
2330            reasoning: mermaid_model::models::ReasoningLevel::Medium,
2331            temperature: 0.7,
2332            max_tokens: 4096,
2333            tools: vec![],
2334
2335            ollama_num_ctx: None,
2336            ollama_allow_ram_offload: None,
2337            resolved_context_window: None,
2338            resolved_max_output: None,
2339            output_schema: None,
2340            suppress_auto_compact: false,
2341            requested_compaction: None,
2342            native_compaction: None,
2343            native_tools: mermaid_model::models::NativeTools::default(),
2344        };
2345        r.dispatch(Cmd::CallModel { turn, request });
2346        assert_eq!(r.scope_count(), 1);
2347
2348        // Runner has no provider bindings → dispatch_call_model hits
2349        // the "not wired" error path and emits UpstreamError, then the
2350        // spawned task returns. Drain that message so we know the task
2351        // ran to completion.
2352        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2353            .await
2354            .expect("upstream error arrived")
2355            .expect("channel alive");
2356        assert!(matches!(msg, Msg::UpstreamError { .. }));
2357
2358        // Give the JoinSet a tick to notice the task finished.
2359        tokio::task::yield_now().await;
2360
2361        // Any subsequent dispatch reaps the now-empty scope.
2362        r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2363        assert_eq!(
2364            r.scope_count(),
2365            0,
2366            "completed scope must be reaped on next dispatch"
2367        );
2368    }
2369
2370    #[tokio::test]
2371    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2372        let (mut r, mut rx) = runner();
2373        let turn = TurnId(7);
2374        let call_id = ToolCallId(1);
2375        let source = mermaid_model::models::tool_call::ToolCall {
2376            id: Some("c1".to_string()),
2377            function: mermaid_model::models::tool_call::FunctionCall {
2378                name: "read_file".to_string(),
2379                arguments: serde_json::json!({"path": "x"}),
2380            },
2381        };
2382        r.dispatch(Cmd::ExecuteTool {
2383            turn,
2384            call_id,
2385            source,
2386            dispatch: mermaid_domain::ToolDispatch {
2387                model_id: "ollama/test".to_string(),
2388                safety_mode: mermaid_runtime::SafetyMode::Ask,
2389                goal: mermaid_domain::UserGoal::default(),
2390                reasoning: mermaid_model::models::ReasoningLevel::default(),
2391                session_id: "sess-test".to_string(),
2392                message_index: 0,
2393                scratchpad: None,
2394            },
2395        });
2396        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2397            .await
2398            .expect("some msg")
2399            .expect("channel alive");
2400        assert!(matches!(
2401            first,
2402            Msg::ToolStarted {
2403                turn: t,
2404                call_id: c,
2405            } if t == turn && c == call_id
2406        ));
2407    }
2408
2409    #[tokio::test]
2410    async fn cancel_scope_before_execute_tool_drops_pending_work() {
2411        let (mut r, _rx) = runner();
2412        let turn = TurnId(9);
2413        r.dispatch(Cmd::CallModel {
2414            turn,
2415            request: mermaid_domain::ChatRequest {
2416                model_id: "m".to_string(),
2417                messages: vec![],
2418                system_prompt: String::new(),
2419                instructions: None,
2420                reasoning: mermaid_model::models::ReasoningLevel::Medium,
2421                temperature: 0.7,
2422                max_tokens: 4096,
2423                tools: vec![],
2424
2425                ollama_num_ctx: None,
2426                ollama_allow_ram_offload: None,
2427                resolved_context_window: None,
2428                resolved_max_output: None,
2429                output_schema: None,
2430                suppress_auto_compact: false,
2431                requested_compaction: None,
2432                native_compaction: None,
2433                native_tools: mermaid_model::models::NativeTools::default(),
2434            },
2435        });
2436        assert_eq!(r.scope_count(), 1);
2437
2438        r.dispatch(Cmd::CancelScope(turn));
2439        assert_eq!(r.scope_count(), 0);
2440    }
2441
2442    #[tokio::test]
2443    async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2444        // F38: once a turn's scope has been cancelled (dropped + tombstoned), a
2445        // stray turn-scoped Cmd bearing the same TurnId must be dropped — not
2446        // used to spin up a fresh, un-cancelled scope via `scope_mut`'s
2447        // `or_insert_with`. Turn ids are monotonic and never reused, so such a
2448        // Cmd can only be a post-cancel straggler.
2449        let (mut r, _rx) = runner();
2450        let req = || mermaid_domain::ChatRequest {
2451            model_id: "test/m".to_string(),
2452            messages: vec![],
2453            system_prompt: String::new(),
2454            instructions: None,
2455            reasoning: mermaid_model::models::ReasoningLevel::Medium,
2456            temperature: 0.7,
2457            max_tokens: 4096,
2458            tools: vec![],
2459            ollama_num_ctx: None,
2460            ollama_allow_ram_offload: None,
2461            resolved_context_window: None,
2462            resolved_max_output: None,
2463            output_schema: None,
2464            suppress_auto_compact: false,
2465            requested_compaction: None,
2466            native_compaction: None,
2467            native_tools: mermaid_model::models::NativeTools::default(),
2468        };
2469        let turn = TurnId(123);
2470
2471        r.dispatch(Cmd::CallModel {
2472            turn,
2473            request: req(),
2474        });
2475        assert_eq!(r.scope_count(), 1);
2476
2477        // Cancel: drops the scope and tombstones the turn.
2478        r.dispatch(Cmd::CancelScope(turn));
2479        assert_eq!(r.scope_count(), 0);
2480
2481        // A late scoped Cmd for the now-tombstoned turn must be dropped.
2482        r.dispatch(Cmd::CallModel {
2483            turn,
2484            request: req(),
2485        });
2486        assert_eq!(
2487            r.scope_count(),
2488            0,
2489            "a cancelled turn must not be resurrected by a late scoped Cmd"
2490        );
2491
2492        // A fresh, higher turn id is unaffected by the tombstone.
2493        r.dispatch(Cmd::CallModel {
2494            turn: TurnId(124),
2495            request: req(),
2496        });
2497        assert_eq!(
2498            r.scope_count(),
2499            1,
2500            "a fresh turn must still create its scope normally"
2501        );
2502    }
2503
2504    #[tokio::test]
2505    async fn shutdown_drains_pending_saves() {
2506        let (mut r, _rx) = runner();
2507        for _ in 0..5 {
2508            r.dispatch(Cmd::SaveConversation {
2509                snapshot: mermaid_domain::ConversationHistory::new(
2510                    "/p".to_string(),
2511                    "m".to_string(),
2512                    chrono::Local::now(),
2513                ),
2514                events: Vec::new(),
2515            });
2516        }
2517        // Shutdown waits for all five to complete (should be instant).
2518        let start = std::time::Instant::now();
2519        r.shutdown().await;
2520        assert!(start.elapsed() < Duration::from_secs(2));
2521    }
2522
2523    fn persistence_fixture(
2524        root: &std::path::Path,
2525        record_id: &str,
2526    ) -> (mermaid_domain::ConversationHistory, PendingCompactionSave) {
2527        let now = chrono::Local::now();
2528        let mut full = mermaid_domain::ConversationHistory::new(
2529            root.display().to_string(),
2530            "test/model".to_string(),
2531            now,
2532        );
2533        full.add_messages(
2534            &[mermaid_model::models::ChatMessage::user("raw history")],
2535            now,
2536        );
2537        let mut compacted = full.clone();
2538        compacted.replace_messages(
2539            vec![mermaid_model::models::ChatMessage::user(
2540                "compacted checkpoint",
2541            )],
2542            now,
2543        );
2544        let record = mermaid_domain::CompactionEvent {
2545            id: record_id.to_string(),
2546            trigger: mermaid_domain::CompactionTrigger::Manual,
2547            created_at: now,
2548            before_tokens: 100,
2549            after_tokens: 20,
2550            archived_message_count: 1,
2551            preserved_message_count: 1,
2552            preserved_turn_count: 1,
2553            summary_tokens: 10,
2554            duration_secs: 0.1,
2555            focus: None,
2556            archive_path: None,
2557        };
2558        (
2559            full,
2560            PendingCompactionSave {
2561                record,
2562                conversation: compacted,
2563                // The boundary event the save must land before it overwrites
2564                // the snapshot.
2565                events: vec![mermaid_domain::SessionEvent::Input {
2566                    text: "compaction boundary".to_string(),
2567                }],
2568                events_appended: false,
2569                task_id: None,
2570            },
2571        )
2572    }
2573
2574    /// Make every event append for `id` fail, by planting a directory where
2575    /// its log file goes. This is the failure the barrier exists for now
2576    /// that the boundary event -- not an archive file -- is the only record
2577    /// of a compaction's dropped messages.
2578    fn block_event_log(root: &std::path::Path, id: &str) {
2579        let dir = root.join(".mermaid").join("conversations");
2580        std::fs::create_dir_all(&dir).expect("conversations dir");
2581        std::fs::create_dir_all(dir.join(format!("{id}.jsonl"))).expect("plant a blocker");
2582    }
2583
2584    /// One `Message` event plus the snapshot that now contains it.
2585    fn one_message_save(
2586        conversation: &mut mermaid_domain::ConversationHistory,
2587        text: &str,
2588    ) -> PersistenceJob {
2589        let message = mermaid_model::models::ChatMessage::user(text);
2590        conversation.add_messages(std::slice::from_ref(&message), chrono::Local::now());
2591        PersistenceJob::Conversation {
2592            snapshot: Box::new(conversation.clone()),
2593            events: vec![mermaid_domain::SessionEvent::Message { message }],
2594        }
2595    }
2596
2597    #[test]
2598    fn the_checkpoint_stops_being_written_on_every_save() {
2599        // The point of the throttle: appends stay O(1) per message while
2600        // the whole-transcript rewrite happens on a coarse cadence. What
2601        // must NOT change is what a resume sees.
2602        let root = std::env::temp_dir().join(format!(
2603            "mermaid-throttle-{}-{:?}",
2604            std::process::id(),
2605            std::thread::current().id()
2606        ));
2607        let _ = std::fs::remove_dir_all(&root);
2608        let manager = crate::session::ConversationManager::new(&root).unwrap();
2609        let mut conversation = mermaid_domain::ConversationHistory::new(
2610            root.display().to_string(),
2611            "test/model".to_string(),
2612            chrono::Local::now(),
2613        );
2614        let mut state = PersistenceState::new(root.clone());
2615
2616        // The first save creates the log (and its backfill) but no
2617        // checkpoint: nowhere near the threshold.
2618        state
2619            .process(one_message_save(&mut conversation, "first"))
2620            .1
2621            .unwrap();
2622        let checkpoint = manager
2623            .conversations_dir()
2624            .join(format!("{}.json", conversation.id));
2625        assert!(
2626            !checkpoint.exists(),
2627            "a single save must not rewrite the transcript"
2628        );
2629
2630        // ...and resume still sees it, because the log is the truth.
2631        let resumed = manager.load_conversation(&conversation.id).unwrap();
2632        assert_eq!(resumed.messages().len(), 1);
2633        assert_eq!(resumed.messages()[0].content, "first");
2634
2635        // Crossing the threshold materializes one.
2636        for i in 0..CHECKPOINT_EVERY_EVENTS {
2637            state
2638                .process(one_message_save(&mut conversation, &format!("m{i}")))
2639                .1
2640                .unwrap();
2641        }
2642        assert!(
2643            checkpoint.exists(),
2644            "crossing {CHECKPOINT_EVERY_EVENTS} events must materialize a checkpoint"
2645        );
2646        let resumed = manager.load_conversation(&conversation.id).unwrap();
2647        assert_eq!(resumed.messages().len(), CHECKPOINT_EVERY_EVENTS + 1);
2648        let _ = std::fs::remove_dir_all(root);
2649    }
2650
2651    #[test]
2652    fn shutdown_flushes_the_checkpoint_it_was_holding() {
2653        let root = std::env::temp_dir().join(format!(
2654            "mermaid-flush-{}-{:?}",
2655            std::process::id(),
2656            std::thread::current().id()
2657        ));
2658        let _ = std::fs::remove_dir_all(&root);
2659        let manager = crate::session::ConversationManager::new(&root).unwrap();
2660        let mut conversation = mermaid_domain::ConversationHistory::new(
2661            root.display().to_string(),
2662            "test/model".to_string(),
2663            chrono::Local::now(),
2664        );
2665        let mut state = PersistenceState::new(root.clone());
2666        state
2667            .process(one_message_save(&mut conversation, "only message"))
2668            .1
2669            .unwrap();
2670
2671        let checkpoint = manager
2672            .conversations_dir()
2673            .join(format!("{}.json", conversation.id));
2674        assert!(!checkpoint.exists());
2675        state.flush_checkpoints().unwrap();
2676        assert!(
2677            checkpoint.exists(),
2678            "a clean exit must leave a current checkpoint"
2679        );
2680        // And it carries the watermark, so the next resume replays nothing.
2681        let raw = std::fs::read_to_string(&checkpoint).unwrap();
2682        let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2683        assert!(
2684            value.get("checkpoint_seq").is_some(),
2685            "a flushed checkpoint must be placeable in its log: {raw}"
2686        );
2687        let _ = std::fs::remove_dir_all(root);
2688    }
2689
2690    #[test]
2691    fn a_failed_append_keeps_its_events_for_the_next_save() {
2692        // The append is the save now, so a dropped batch is lost data --
2693        // the reducer drains its buffer at emission and never re-offers it.
2694        let root = std::env::temp_dir().join(format!(
2695            "mermaid-unappended-{}-{:?}",
2696            std::process::id(),
2697            std::thread::current().id()
2698        ));
2699        let _ = std::fs::remove_dir_all(&root);
2700        let manager = crate::session::ConversationManager::new(&root).unwrap();
2701        let mut conversation = mermaid_domain::ConversationHistory::new(
2702            root.display().to_string(),
2703            "test/model".to_string(),
2704            chrono::Local::now(),
2705        );
2706        let mut state = PersistenceState::new(root.clone());
2707
2708        // Block the log: a directory where its file goes.
2709        std::fs::create_dir_all(
2710            manager
2711                .conversations_dir()
2712                .join(format!("{}.jsonl", conversation.id)),
2713        )
2714        .expect("plant a blocker");
2715        let job = one_message_save(&mut conversation, "must survive");
2716        assert!(state.process(job).1.is_err(), "the append must fail");
2717        assert_eq!(
2718            state.unappended.get(&conversation.id).map(Vec::len),
2719            Some(1),
2720            "the batch must be held, not dropped"
2721        );
2722
2723        // Unblock, save again: the held event goes first and both land.
2724        std::fs::remove_dir_all(
2725            manager
2726                .conversations_dir()
2727                .join(format!("{}.jsonl", conversation.id)),
2728        )
2729        .expect("unblock");
2730        state
2731            .process(one_message_save(&mut conversation, "and this one"))
2732            .1
2733            .unwrap();
2734        assert!(state.unappended.is_empty(), "the hold must clear");
2735        state.flush_checkpoints().unwrap();
2736
2737        let resumed = manager.load_conversation(&conversation.id).unwrap();
2738        let texts: Vec<&str> = resumed
2739            .messages()
2740            .iter()
2741            .map(|m| m.content.as_str())
2742            .collect();
2743        assert!(
2744            texts.contains(&"must survive"),
2745            "the event held over a failed append must reach the log: {texts:?}"
2746        );
2747        assert!(texts.contains(&"and this one"), "{texts:?}");
2748        let _ = std::fs::remove_dir_all(root);
2749    }
2750
2751    #[test]
2752    fn persistence_orders_compaction_before_newer_conversation_save() {
2753        let root = std::env::temp_dir().join(format!(
2754            "mermaid-persistence-order-{}-{:?}",
2755            std::process::id(),
2756            std::thread::current().id()
2757        ));
2758        let _ = std::fs::remove_dir_all(&root);
2759        let (full, compaction) = persistence_fixture(&root, "compact_ordered");
2760        let manager = crate::session::ConversationManager::new(&root).unwrap();
2761        manager.save_conversation(&full).unwrap();
2762
2763        let mut state = PersistenceState::new(root.clone());
2764        let (events, outcome) =
2765            state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
2766        outcome.unwrap();
2767        assert_eq!(events.len(), 1);
2768        let mut newer = compaction.conversation;
2769        let reply = mermaid_model::models::ChatMessage::assistant("new assistant reply");
2770        newer.add_messages(std::slice::from_ref(&reply), chrono::Local::now());
2771        // The event, not just the snapshot: the log is the truth now, and a
2772        // save below the checkpoint threshold writes nothing else. A fixture
2773        // that passed an empty batch would be asserting against a file this
2774        // save no longer touches.
2775        let (_, outcome) = state.process(PersistenceJob::Conversation {
2776            snapshot: Box::new(newer),
2777            events: vec![mermaid_domain::SessionEvent::Message { message: reply }],
2778        });
2779        outcome.unwrap();
2780
2781        let loaded = crate::session::ConversationManager::new(&root)
2782            .unwrap()
2783            .load_conversation(&full.id)
2784            .unwrap();
2785        assert!(
2786            loaded
2787                .messages()
2788                .iter()
2789                .any(|message| message.content == "new assistant reply")
2790        );
2791        let _ = std::fs::remove_dir_all(root);
2792    }
2793
2794    #[test]
2795    fn failed_event_append_blocks_later_stripped_conversation_save() {
2796        let root = std::env::temp_dir().join(format!(
2797            "mermaid-persistence-barrier-{}-{:?}",
2798            std::process::id(),
2799            std::thread::current().id()
2800        ));
2801        let _ = std::fs::remove_dir_all(&root);
2802        let (full, compaction) = persistence_fixture(&root, "compact_blocked");
2803        let manager = crate::session::ConversationManager::new(&root).unwrap();
2804        manager.save_conversation(&full).unwrap();
2805        block_event_log(&root, &full.id);
2806
2807        let mut state = PersistenceState::new(root.clone());
2808        assert!(
2809            state
2810                .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
2811                .1
2812                .is_err()
2813        );
2814        assert!(
2815            state
2816                .process(PersistenceJob::Conversation {
2817                    snapshot: Box::new(compaction.conversation),
2818                    events: Vec::new(),
2819                })
2820                .1
2821                .is_err()
2822        );
2823        assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2824
2825        let loaded = crate::session::ConversationManager::new(&root)
2826            .unwrap()
2827            .load_conversation(&full.id)
2828            .unwrap();
2829        assert_eq!(loaded.messages()[0].content, "raw history");
2830        let _ = std::fs::remove_dir_all(root);
2831    }
2832
2833    #[test]
2834    fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
2835        let root = std::env::temp_dir().join(format!(
2836            "mermaid-persistence-queue-{}-{:?}",
2837            std::process::id(),
2838            std::thread::current().id()
2839        ));
2840        let _ = std::fs::remove_dir_all(&root);
2841        let (full, first) = persistence_fixture(&root, "compact_first");
2842        let mut second = first.clone();
2843        second.record.id = "compact_second".to_string();
2844        block_event_log(&root, &full.id);
2845
2846        let mut state = PersistenceState::new(root.clone());
2847        assert!(
2848            state
2849                .process(PersistenceJob::Compaction(Box::new(first)))
2850                .1
2851                .is_err()
2852        );
2853        // The older barrier still fails; the new save must queue behind it —
2854        // its boundary event is the only record of the stripped messages.
2855        assert!(
2856            state
2857                .process(PersistenceJob::Compaction(Box::new(second)))
2858                .1
2859                .is_err()
2860        );
2861        let queued = state.blocked.get(&full.id).expect("barrier queue");
2862        assert_eq!(queued.len(), 2);
2863        assert_eq!(queued[0].record.id, "compact_first");
2864        assert_eq!(queued[1].record.id, "compact_second");
2865        let _ = std::fs::remove_dir_all(root);
2866    }
2867
2868    #[test]
2869    fn retry_all_blocked_attempts_every_conversation() {
2870        let root = std::env::temp_dir().join(format!(
2871            "mermaid-persistence-drain-{}-{:?}",
2872            std::process::id(),
2873            std::thread::current().id()
2874        ));
2875        let _ = std::fs::remove_dir_all(&root);
2876        let (bad_full, bad) = persistence_fixture(&root, "compact_bad");
2877        let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
2878        block_event_log(&root, &bad_full.id);
2879        // Conversation ids are millisecond timestamps; two fixtures minted in
2880        // the same instant would collide into one barrier queue. Force the
2881        // second conversation onto a distinct (still format-valid) id.
2882        good_full.id = "20990101_000000_001".to_string();
2883        good.conversation.id = good_full.id.clone();
2884
2885        let mut state = PersistenceState::new(root.clone());
2886        state
2887            .blocked
2888            .entry(bad_full.id.clone())
2889            .or_default()
2890            .push_back(bad);
2891        state
2892            .blocked
2893            .entry(good_full.id.clone())
2894            .or_default()
2895            .push_back(good);
2896
2897        // One conversation's bad disk state must not strand the other's
2898        // barrier at shutdown: the error surfaces, but the good save lands —
2899        // and its durably persisted event is reported alongside the error.
2900        let (events, outcome) = state.retry_all_blocked();
2901        assert!(outcome.is_err());
2902        assert_eq!(events.len(), 1);
2903        assert_eq!(events[0].id, "compact_good");
2904        assert!(!state.blocked.contains_key(&good_full.id));
2905        assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
2906        let loaded = crate::session::ConversationManager::new(&root)
2907            .unwrap()
2908            .load_conversation(&good_full.id)
2909            .unwrap();
2910        assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
2911        let _ = std::fs::remove_dir_all(root);
2912    }
2913
2914    #[test]
2915    fn partially_drained_barrier_reports_its_persisted_events() {
2916        let root = std::env::temp_dir().join(format!(
2917            "mermaid-persistence-partial-{}-{:?}",
2918            std::process::id(),
2919            std::thread::current().id()
2920        ));
2921        let _ = std::fs::remove_dir_all(&root);
2922        let (full, good) = persistence_fixture(&root, "compact_good");
2923        let mut bad = good.clone();
2924        bad.record.id = "compact_bad".to_string();
2925        // Both saves sit in ONE queue, so the blocker cannot be the shared
2926        // log path: give the tail an id that fails validation instead.
2927        bad.conversation.id = "../invalid".to_string();
2928
2929        let mut state = PersistenceState::new(root.clone());
2930        let queue = state.blocked.entry(full.id.clone()).or_default();
2931        queue.push_back(good);
2932        queue.push_back(bad);
2933
2934        // The good save at the head of the queue persists durably before the
2935        // bad one fails. Its event must surface with the error — it is popped
2936        // and would otherwise never fire SessionSaved or the compaction hook.
2937        let (events, outcome) = state.retry_blocked(&full.id);
2938        assert!(outcome.is_err());
2939        assert_eq!(events.len(), 1);
2940        assert_eq!(events[0].id, "compact_good");
2941        assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2942        let _ = std::fs::remove_dir_all(root);
2943    }
2944}