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(p.clone(), model))
1150                                as Arc<dyn crate::providers::AutoClassifier>
1151                        })
1152                    } else {
1153                        None
1154                    };
1155                let services = crate::providers::ctx::ToolServices {
1156                    workdir,
1157                    config,
1158                    task_id: self.task_id.clone(),
1159                    // Detached work (backgrounded subagents) reports back
1160                    // through the main msg channel after this turn's
1161                    // progress relay is gone.
1162                    notify: Some(self.msg_tx.clone()),
1163                    classifier,
1164                    approval: self.approval.clone(),
1165                    questions: self.questions.clone(),
1166                    tasks: Some(self.tasks.clone()),
1167                };
1168                let scope = self.scope_mut(turn);
1169                let signals = crate::providers::ctx::TurnSignals {
1170                    token: scope.token(),
1171                    background: scope.background_token(),
1172                    web_bytes: scope.web_bytes(),
1173                };
1174                scope.spawn(async move {
1175                    use futures::FutureExt;
1176                    let fallback_tx = tx.clone();
1177                    if std::panic::AssertUnwindSafe(dispatch_execute_tool(
1178                        tx, tools, turn, call_id, source, signals, dispatch, services,
1179                    ))
1180                    .catch_unwind()
1181                    .await
1182                    .is_err()
1183                    {
1184                        // The tool task panicked. Its turn waits on a
1185                        // `ToolFinished` for this `call_id` that will now never
1186                        // arrive; emit a terminal error outcome so the turn
1187                        // doesn't wedge (#43).
1188                        tracing::error!(
1189                            turn = %turn,
1190                            call_id = call_id.0,
1191                            "dispatch_execute_tool panicked"
1192                        );
1193                        let _ = fallback_tx
1194                            .send(Msg::ToolFinished {
1195                                turn,
1196                                call_id,
1197                                outcome: mermaid_domain::ToolOutcome::error(
1198                                    "internal error: the tool execution task panicked".to_string(),
1199                                    None,
1200                                ),
1201                            })
1202                            .await;
1203                    }
1204                });
1205            },
1206            Cmd::ResolveApproval { call_id, decision } => {
1207                // Deliver the user's inline decision to the parked tool task.
1208                // Not turn-scoped — fire-and-forget to the broker.
1209                if let Some(broker) = &self.approval {
1210                    broker.resolve(call_id, decision.into());
1211                }
1212            },
1213            Cmd::ResolveQuestion {
1214                call_id,
1215                resolution,
1216            } => {
1217                // Deliver the user's answers to the parked ask_user_question
1218                // task. Not turn-scoped — fire-and-forget to the broker.
1219                if let Some(broker) = &self.questions {
1220                    broker.resolve(call_id, resolution);
1221                }
1222            },
1223            Cmd::SyncTaskStore(store) => {
1224                // Reducer-initiated truth overwrite (rewind/fork, /clear,
1225                // startup resume). Synchronous; the broker does not publish
1226                // back — the reducer already holds this store.
1227                self.tasks.seed(store);
1228            },
1229            Cmd::EnsureScratchpad { session_id } => {
1230                let tx = self.msg_tx.clone();
1231                let workdir = self.workdir.clone();
1232                self.detached.spawn(async move {
1233                    match crate::session::scratchpad::ensure(&workdir, &session_id) {
1234                        Ok(path) => {
1235                            let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
1236                        },
1237                        Err(err) => {
1238                            // Non-fatal: the session runs without a scratch
1239                            // dir (`Session::scratchpad` stays `None`).
1240                            tracing::warn!(error = %err, "failed to create session scratchpad");
1241                        },
1242                    }
1243                    // Best-effort reap of unlocked scratchpads past retention —
1244                    // piggybacks on session startup, no separate timer.
1245                    if let Err(err) = crate::session::scratchpad::sweep_stale(
1246                        crate::session::scratchpad::RETENTION_DAYS,
1247                    ) {
1248                        tracing::warn!(error = %err, "scratchpad sweep failed");
1249                    }
1250                });
1251            },
1252            Cmd::ListScratchpad { path } => {
1253                // `/scratchpad` — bounded directory listing back into the
1254                // transcript. Blocking filesystem walk, so off the runner.
1255                let tx = self.msg_tx.clone();
1256                self.detached.spawn(async move {
1257                    let text = tokio::task::spawn_blocking(move || {
1258                        crate::session::scratchpad::list_text(&path)
1259                    })
1260                    .await
1261                    .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
1262                    let _ = tx.send(Msg::RuntimeText(text)).await;
1263                });
1264            },
1265            Cmd::UserTaskEdit(edit) => {
1266                // Route the user's /tasks edit through the broker (single
1267                // writer) so it serializes with any in-flight tool call. The
1268                // broker publishes the resulting snapshot; the outcome line
1269                // lands in the transcript as transient status.
1270                let broker = self.tasks.clone();
1271                let tx = self.msg_tx.clone();
1272                self.detached.spawn(async move {
1273                    let (line, _snapshot) = broker.user_edit(edit).await;
1274                    // The user sees the ack in the transcript; the model
1275                    // learns about it on its next request via the notice
1276                    // buffer (a checklist the model believes in but the user
1277                    // has edited is the worst of both).
1278                    let _ = tx
1279                        .send(Msg::TaskNotice {
1280                            text: format!(
1281                                "The user edited the task checklist: {line}. Acknowledge and \
1282                                 incorporate this into your plan."
1283                            ),
1284                        })
1285                        .await;
1286                    let _ = tx.send(Msg::TransientStatus { text: line }).await;
1287                });
1288            },
1289            Cmd::NotifyTaskCompleted {
1290                task,
1291                completed,
1292                total,
1293            } => {
1294                // Gated `task_completed` plugin hook: a denying hook VETOES
1295                // the completion — the task flips back to in_progress via the
1296                // broker (single writer; the publish refreshes the band) and
1297                // the reason reaches both the user (transcript) and the model
1298                // (notice buffer). Fail-open like every plugin hook: no
1299                // enabled hooks / timeout => allow, zero latency added
1300                // elsewhere because this runs detached.
1301                let payload = serde_json::json!({
1302                    "task_id": task.id,
1303                    "subject": task.subject,
1304                    "description": task.description,
1305                    "evidence": task.evidence,
1306                    "completed": completed,
1307                    "total": total,
1308                });
1309                let broker = self.tasks.clone();
1310                let tx = self.msg_tx.clone();
1311                self.detached.spawn(async move {
1312                    let gate = run_plugin_hooks_gated("task_completed", payload).await;
1313                    let Some((plugin, reason)) = gate.deny else {
1314                        return;
1315                    };
1316                    let reason = mermaid_model::utils::redact_secrets(&reason);
1317                    let _ = broker
1318                        .update(vec![mermaid_domain::ChecklistEdit {
1319                            id: task.id,
1320                            status: Some(mermaid_domain::ChecklistStatus::InProgress),
1321                            ..mermaid_domain::ChecklistEdit::default()
1322                        }])
1323                        .await;
1324                    let _ = tx
1325                        .send(Msg::TaskNotice {
1326                            text: format!(
1327                                "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
1328                                 {reason}. The task is back in_progress; address the reason \
1329                                 before completing it again.",
1330                                task.id, task.subject
1331                            ),
1332                        })
1333                        .await;
1334                    let _ = tx
1335                        .send(Msg::TransientStatus {
1336                            text: format!(
1337                                "task #{} completion vetoed by {plugin}: {reason}",
1338                                task.id
1339                            ),
1340                        })
1341                        .await;
1342                });
1343            },
1344            Cmd::CancelScope(turn) => {
1345                self.drop_scope(turn);
1346            },
1347            Cmd::BackgroundScope(turn) => {
1348                // Fire the scope's background token (don't drop the scope):
1349                // detachable tools move their child to a background process and
1350                // return a normal outcome, so the turn finishes naturally.
1351                self.scope_mut(turn).background();
1352            },
1353            Cmd::SaveConversation { snapshot, events } => {
1354                self.queue_persistence(PersistenceJob::Conversation {
1355                    snapshot: Box::new(snapshot),
1356                    events,
1357                });
1358            },
1359            Cmd::SaveCompaction {
1360                record,
1361                conversation,
1362                events,
1363            } => {
1364                self.queue_persistence(PersistenceJob::Compaction(Box::new(
1365                    PendingCompactionSave {
1366                        record,
1367                        conversation,
1368                        events,
1369                        events_appended: false,
1370                        task_id: self.task_id.clone(),
1371                    },
1372                )));
1373            },
1374            Cmd::SaveProcess(process) => {
1375                let task_id = self.task_id.clone();
1376                self.detached.spawn(async move {
1377                    let status = process.status;
1378                    let _ = mermaid_runtime::with_shared_store(|store| {
1379                        store.processes().upsert(mermaid_runtime::NewProcess {
1380                            id: Some(process.id),
1381                            task_id,
1382                            pid: process.pid,
1383                            command: process.command,
1384                            cwd: process.cwd,
1385                            log_path: Some(process.log_path),
1386                            detected_url: process.detected_url,
1387                            status,
1388                            health: None,
1389                        })
1390                    });
1391                });
1392            },
1393            Cmd::PersistLastModel(model) => {
1394                self.detached.spawn(async move {
1395                    if let Err(err) = crate::app::persist_last_model(&model) {
1396                        tracing::warn!(error = %err, "failed to persist last-used model");
1397                    }
1398                });
1399            },
1400            Cmd::PersistReasoningFor { model_id, level } => {
1401                self.detached.spawn(async move {
1402                    if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1403                        tracing::warn!(error = %err, "failed to persist reasoning level for model");
1404                    }
1405                });
1406            },
1407            Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1408                self.detached.spawn(async move {
1409                    if let Err(err) =
1410                        crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1411                    {
1412                        tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1413                    }
1414                });
1415            },
1416            Cmd::PersistOllamaOffload(enabled) => {
1417                self.detached.spawn(async move {
1418                    if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1419                        tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1420                    }
1421                });
1422            },
1423            Cmd::PersistUiTheme(theme) => {
1424                self.detached.spawn(async move {
1425                    if let Err(err) = crate::app::persist_ui_theme(theme) {
1426                        tracing::warn!(error = %err, "failed to persist theme");
1427                    }
1428                });
1429            },
1430            Cmd::PersistOutputStyle { style } => {
1431                self.detached.spawn(async move {
1432                    if let Err(err) = crate::app::persist_output_style(&style) {
1433                        tracing::warn!(error = %err, "failed to persist output style");
1434                    }
1435                });
1436            },
1437            Cmd::PersistProjectOutputStyle { style } => {
1438                let tx = self.msg_tx.clone();
1439                let workdir = self.workdir.clone();
1440                self.detached.spawn(async move {
1441                    if let Err(err) = crate::app::persist_project_output_style(&workdir, &style) {
1442                        let _ = tx
1443                            .send(Msg::TransientStatus {
1444                                text: format!("Couldn't save the project output style: {err}"),
1445                            })
1446                            .await;
1447                    }
1448                });
1449            },
1450            Cmd::ComposeInEditor { .. } => {
1451                // Run-loop-intercepted in the interactive TUI (it owns the
1452                // terminal + event stream). Reaching the effect runner means a
1453                // headless driver emitted it — nothing to suspend there.
1454                tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1455            },
1456            Cmd::ListMemory => {
1457                let tx = self.msg_tx.clone();
1458                let workdir = self.workdir.clone();
1459                self.detached.spawn(async move {
1460                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1461                    let text = match crate::app::memory::load(&workdir, &cfg) {
1462                        Some(mem) => mem.index,
1463                        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(),
1464                    };
1465                    let _ = tx.send(Msg::RuntimeText(text)).await;
1466                });
1467            },
1468            Cmd::RememberMemory { text } => {
1469                let tx = self.msg_tx.clone();
1470                let workdir = self.workdir.clone();
1471                self.detached.spawn(async move {
1472                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1473                    let name = memory_title_from_text(&text);
1474                    let status = match crate::app::memory::write_memory(
1475                        &workdir,
1476                        mermaid_domain::MemoryScope::ProjectPrivate,
1477                        &name,
1478                        &text,
1479                        &[],
1480                        &text,
1481                    ) {
1482                        Ok(_) => format!("Remembered: {name}"),
1483                        Err(e) => format!("Couldn't save memory: {e}"),
1484                    };
1485                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1486                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1487                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
1488                });
1489            },
1490            Cmd::ForgetMemory { id } => {
1491                let tx = self.msg_tx.clone();
1492                let workdir = self.workdir.clone();
1493                self.detached.spawn(async move {
1494                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1495                    let status = match crate::app::memory::delete_memory(&workdir, &id) {
1496                        Ok(Some(_)) => format!("Forgot: {id}"),
1497                        Ok(None) => format!("No memory named '{id}'"),
1498                        Err(e) => format!("Couldn't forget memory: {e}"),
1499                    };
1500                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1501                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1502                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
1503                });
1504            },
1505            Cmd::ConsolidateMemory { model_id } => {
1506                let tx = self.msg_tx.clone();
1507                let workdir = self.workdir.clone();
1508                let providers = self.providers.clone();
1509                self.detached.spawn(async move {
1510                    consolidate_memory(tx, providers, workdir, model_id).await;
1511                });
1512            },
1513            Cmd::Query(query) => self.dispatch_query(query),
1514            Cmd::ShowRuntimeProcessLogs { id } => {
1515                let tx = self.msg_tx.clone();
1516                self.detached.spawn_blocking(move || {
1517                    let text = crate::runtime_client::RuntimeClient::auto()
1518                        .process_log(&id, None)
1519                        .map(|log| format!("Process log {}\n\n{}", id, log.content))
1520                        .unwrap_or_else(|err| format!("Process log error: {err}"));
1521                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1522                });
1523            },
1524            Cmd::StopRuntimeProcess { id } => {
1525                let tx = self.msg_tx.clone();
1526                self.detached.spawn_blocking(move || {
1527                    let msg = match crate::runtime_client::RuntimeClient::auto().stop_process(&id) {
1528                        Ok(response) => Msg::TransientStatus {
1529                            text: format!("Stopped process {} (pid {})", id, response.item.pid),
1530                        },
1531                        Err(err) => Msg::TransientStatus {
1532                            text: format!("Process stop failed: {err}"),
1533                        },
1534                    };
1535                    let _ = tx.blocking_send(msg);
1536                });
1537            },
1538            Cmd::KillBackgroundAgent { agent_id } => {
1539                // Synchronous token fire — no task to spawn. Feedback flows
1540                // through the dying child's `Msg::BackgroundAgentFinished`
1541                // (the reducer already validated the id against its registry).
1542                let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1543                if let Some(spawner) = spawner {
1544                    match agent_id {
1545                        Some(id) => {
1546                            // Killing a finished child hands back the
1547                            // workspace it was holding for a continuation
1548                            // that can no longer happen. Discarding it needs
1549                            // to be async, so it rides a task.
1550                            if let crate::providers::tool::subagent::KillResult::Evicted(
1551                                workspace,
1552                            ) = spawner.kill_detached(&id)
1553                            {
1554                                // In `detached`, not a bare `tokio::spawn`: shutdown
1555                                // drains that set, so quitting mid-discard cannot
1556                                // leave the worktree on disk with no record of it.
1557                                self.detached.spawn(async move {
1558                                    workspace.discard().await;
1559                                });
1560                            }
1561                        },
1562                        None => {
1563                            spawner.kill_all_detached();
1564                        },
1565                    }
1566                }
1567            },
1568            Cmd::RestartRuntimeProcess { id } => {
1569                let tx = self.msg_tx.clone();
1570                self.detached.spawn_blocking(move || {
1571                    let msg = match crate::runtime_client::RuntimeClient::auto()
1572                        .restart_process(&id)
1573                    {
1574                        Ok(response) => Msg::TransientStatus {
1575                            text: format!("Restarted process {} (pid {})", id, response.item.pid),
1576                        },
1577                        Err(err) => Msg::TransientStatus {
1578                            text: format!("Process restart failed: {err}"),
1579                        },
1580                    };
1581                    let _ = tx.blocking_send(msg);
1582                });
1583            },
1584            Cmd::OpenRuntimeTarget { target } => {
1585                self.detached.spawn_blocking(move || {
1586                    let resolved = crate::runtime_client::RuntimeService::open_default()
1587                        .and_then(|service| service.resolve_open_target(&target))
1588                        .unwrap_or(target);
1589                    // #63: the resolved value can be a `detected_url`/`log_path`
1590                    // from a `processes` row — validate before the OS opener,
1591                    // exactly like `open_process`.
1592                    if let Err(err) = crate::runtime_client::validate_open_target(&resolved) {
1593                        tracing::warn!(error = %err, "refusing to open runtime target");
1594                        return;
1595                    }
1596                    mermaid_model::utils::open_file(resolved);
1597                });
1598            },
1599            Cmd::ShowRuntimePorts => {
1600                let tx = self.msg_tx.clone();
1601                self.detached.spawn_blocking(move || {
1602                    let text = crate::runtime_client::RuntimeClient::auto()
1603                        .ports()
1604                        .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1605                        .unwrap_or_else(|err| format!("Port inspection failed: {err}"));
1606                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1607                });
1608            },
1609            Cmd::DecideRuntimeApproval { id, decision } => {
1610                let tx = self.msg_tx.clone();
1611                self.detached.spawn_blocking(move || {
1612                    let result = if decision == "approved" {
1613                        crate::runtime_client::RuntimeClient::auto().approve(&id)
1614                    } else {
1615                        crate::runtime_client::RuntimeClient::auto().deny(&id)
1616                    };
1617                    let msg = match result {
1618                        Ok(result) => Msg::TransientStatus {
1619                            text: if result.replayed {
1620                                format!("Approval {} {}: {}", id, decision, result.summary)
1621                            } else {
1622                                format!("Approval {id} {decision}")
1623                            },
1624                        },
1625                        Err(err) => Msg::TransientStatus {
1626                            text: format!("Approval update failed: {err}"),
1627                        },
1628                    };
1629                    let _ = tx.blocking_send(msg);
1630                });
1631            },
1632            Cmd::UpdateRuntimeTaskStatus {
1633                id,
1634                status,
1635                final_report,
1636            } => {
1637                let tx = self.msg_tx.clone();
1638                self.detached.spawn_blocking(move || {
1639                    let msg = match mermaid_runtime::with_shared_store(|store| {
1640                        store
1641                            .tasks()
1642                            .update_status(&id, status, final_report.as_deref())
1643                    }) {
1644                        Ok(()) => Msg::TransientStatus {
1645                            text: format!("Task {id} -> {status}"),
1646                        },
1647                        Err(err) => Msg::TransientStatus {
1648                            text: format!("Task update failed: {err}"),
1649                        },
1650                    };
1651                    let _ = tx.blocking_send(msg);
1652                });
1653            },
1654            Cmd::CreateRuntimeCheckpoint { paths } => {
1655                let tx = self.msg_tx.clone();
1656                let workdir = self.workdir.clone();
1657                self.detached.spawn_blocking(move || {
1658                    let pending_action = Some(serde_json::json!({
1659                        "source": "tui",
1660                        "command": "checkpoint",
1661                    }));
1662                    let msg = match mermaid_runtime::create_checkpoint(
1663                        &workdir,
1664                        &paths,
1665                        pending_action,
1666                    ) {
1667                        Ok(manifest) => Msg::TransientStatus {
1668                            text: format!(
1669                                "Checkpoint {} created for {} path(s)",
1670                                manifest.id,
1671                                manifest.files.len()
1672                            ),
1673                        },
1674                        Err(err) => Msg::TransientStatus {
1675                            text: format!("Checkpoint failed: {err}"),
1676                        },
1677                    };
1678                    let _ = tx.blocking_send(msg);
1679                });
1680            },
1681            Cmd::RestoreRuntimeCheckpoint { id } => {
1682                let tx = self.msg_tx.clone();
1683                self.detached.spawn_blocking(move || {
1684                    let msg = match crate::runtime_client::RuntimeClient::auto()
1685                        .restore_checkpoint(&id)
1686                    {
1687                        Ok(result) => Msg::TransientStatus {
1688                            text: format!(
1689                                "Restored checkpoint {} ({} file(s)){}",
1690                                result.checkpoint.id,
1691                                result.checkpoint.files.len(),
1692                                if result.checkpoint.pending_action.is_some() {
1693                                    "; pending action available in checkpoint manifest"
1694                                } else {
1695                                    ""
1696                                }
1697                            ),
1698                        },
1699                        Err(err) => Msg::TransientStatus {
1700                            text: format!("Restore failed: {err}"),
1701                        },
1702                    };
1703                    let _ = tx.blocking_send(msg);
1704                });
1705            },
1706            Cmd::ShowRuntimeModelInfo { model } => {
1707                let tx = self.msg_tx.clone();
1708                self.detached.spawn_blocking(move || {
1709                    let text = runtime_model_info_text(&model);
1710                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1711                });
1712            },
1713            Cmd::InitMcpServers(configs) => {
1714                let tx = self.msg_tx.clone();
1715                self.detached
1716                    .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1717            },
1718            Cmd::StopMcpServer { name } => {
1719                let tx = self.msg_tx.clone();
1720                self.detached.spawn(async move {
1721                    // Actually kill the child before claiming it's stopped —
1722                    // otherwise the UI says "stopped" while the server runs on.
1723                    if let Some(mgr) = crate::mcp::manager_ref::get() {
1724                        mgr.stop_server(&name).await;
1725                    }
1726                    let _ = tx.send(Msg::McpServerStopped { name }).await;
1727                });
1728            },
1729            Cmd::PullOllamaModel { model } => {
1730                let tx = self.msg_tx.clone();
1731                self.detached.spawn(async move {
1732                    dispatch_pull_ollama_model(tx, model).await;
1733                });
1734            },
1735            Cmd::OpenInSystem(path) => {
1736                self.detached.spawn(async move {
1737                    let _ = tokio::task::spawn_blocking(move || {
1738                        mermaid_model::utils::open_file(&path);
1739                    })
1740                    .await;
1741                });
1742            },
1743            Cmd::WriteImageToTemp {
1744                path,
1745                bytes,
1746                format: _,
1747            } => {
1748                self.detached.spawn(async move {
1749                    if let Err(e) = tokio::fs::write(&path, &bytes).await {
1750                        tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1751                    }
1752                });
1753            },
1754            Cmd::ReadClipboard => {
1755                let tx = self.msg_tx.clone();
1756                self.detached.spawn(async move {
1757                    dispatch_read_clipboard(tx).await;
1758                });
1759            },
1760            Cmd::ProbeVision { model_id, warn } => {
1761                let tx = self.msg_tx.clone();
1762                let providers = self.providers.clone();
1763                self.detached.spawn(async move {
1764                    dispatch_probe_vision(model_id, warn, providers, tx).await;
1765                });
1766            },
1767            Cmd::CopyToClipboard(text) => {
1768                let tx = self.msg_tx.clone();
1769                self.detached.spawn(async move {
1770                    dispatch_copy_to_clipboard(text, tx).await;
1771                });
1772            },
1773            Cmd::Exit => {
1774                // The main loop observes `state.should_exit` after
1775                // the reducer returns; the runner doesn't need to
1776                // take any special action. Documented here for
1777                // exhaustiveness.
1778            },
1779            Cmd::SetTerminalTitle(title) => {
1780                if !self.terminal_title_enabled {
1781                    return;
1782                }
1783                // Offload the terminal write to the blocking pool: writing to
1784                // stdout can block when the terminal (or a downstream pipe) is
1785                // slow, and an async worker must not block on it (#44). The
1786                // OSC-2 title sequence is out-of-band relative to the renderer's
1787                // frame draws, so it doesn't corrupt them.
1788                self.detached.spawn_blocking(move || {
1789                    use std::io::Write;
1790                    let seq = format!("\x1b]2;{title}\x07");
1791                    let mut stdout = std::io::stdout();
1792                    let _ = stdout.write_all(seq.as_bytes());
1793                    let _ = stdout.flush();
1794                });
1795            },
1796            Cmd::AlertUser => {
1797                if !self.terminal_title_enabled {
1798                    return;
1799                }
1800                // A single BEL nudges the terminal to alert (dock bounce / tab
1801                // highlight). Offloaded to the blocking pool like the title.
1802                self.detached.spawn_blocking(|| {
1803                    use std::io::Write;
1804                    let mut stdout = std::io::stdout();
1805                    let _ = stdout.write_all(b"\x07");
1806                    let _ = stdout.flush();
1807                });
1808            },
1809        }
1810    }
1811
1812    fn queue_persistence(&mut self, job: PersistenceJob) {
1813        let previous = self.persistence_tail.take();
1814        let state = Arc::clone(&self.persistence_state);
1815        let tx = self.msg_tx.clone();
1816        self.persistence_tail = Some(tokio::spawn(async move {
1817            if let Some(previous) = previous
1818                && let Err(error) = previous.await
1819            {
1820                tracing::warn!(error = %error, "previous persistence job panicked");
1821            }
1822
1823            let result = tokio::task::spawn_blocking(move || {
1824                state
1825                    .lock()
1826                    .unwrap_or_else(|error| error.into_inner())
1827                    .process(job)
1828            })
1829            .await;
1830
1831            match result {
1832                Ok((events, outcome)) => {
1833                    // Events report durable writes even when the job as a
1834                    // whole failed — a partially drained barrier already
1835                    // persisted those archives, and they are never re-emitted.
1836                    if outcome.is_ok() || !events.is_empty() {
1837                        let _ = tx.send(Msg::SessionSaved).await;
1838                    }
1839                    for event in events {
1840                        fire_compaction_hook(&event).await;
1841                    }
1842                    if let Err(error) = outcome {
1843                        tracing::warn!(
1844                            error = %error,
1845                            "persistence job failed; compaction barriers remain queued"
1846                        );
1847                    }
1848                },
1849                Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1850            }
1851        }));
1852    }
1853
1854    /// Async shutdown: cancel every scope, then wait for all spawned
1855    /// work to drain. Bounded by 5 seconds — a hung task past that
1856    /// gets aborted outright by `JoinSet::drop`.
1857    pub async fn shutdown(mut self) {
1858        for (id, scope) in self.scopes.iter() {
1859            tracing::debug!(turn = %id, "shutdown: cancelling scope");
1860            scope.cancel();
1861        }
1862
1863        // The config watcher (#45) is a perpetual loop in `detached`; abort it
1864        // so the drain below doesn't block on it until the bounded timeout.
1865        if let Some(handle) = self.config_watch.take() {
1866            handle.abort();
1867        }
1868
1869        // Drain with a bounded timeout.
1870        let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1871
1872        let owns_global_mcp = self.owns_global_mcp;
1873        let persistence_tail = self.persistence_tail.take();
1874        let persistence_state = Arc::clone(&self.persistence_state);
1875        let drain = async {
1876            if let Some(tail) = persistence_tail
1877                && let Err(error) = tail.await
1878            {
1879                tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1880            }
1881            match tokio::task::spawn_blocking(move || {
1882                let mut state = persistence_state
1883                    .lock()
1884                    .unwrap_or_else(|error| error.into_inner());
1885                let drained = state.retry_all_blocked();
1886                // Then materialize whatever the ~200-event throttle has been
1887                // holding back, so a clean exit always leaves a current
1888                // checkpoint and the next resume replays nothing.
1889                if let Err(error) = state.flush_checkpoints() {
1890                    tracing::warn!(
1891                        error = %error,
1892                        "shutdown: could not flush a session checkpoint; the log still has everything, so the next resume just folds further"
1893                    );
1894                }
1895                drop(state);
1896                drained
1897            })
1898            .await
1899            {
1900                Ok((events, outcome)) => {
1901                    // A barrier drained at shutdown still owes its hooks —
1902                    // these events are never re-emitted.
1903                    for event in events {
1904                        fire_compaction_hook(&event).await;
1905                    }
1906                    if let Err(error) = outcome {
1907                        tracing::warn!(
1908                            error = %error,
1909                            "shutdown: compaction persistence barrier retry failed"
1910                        );
1911                    }
1912                },
1913                Err(error) => tracing::warn!(
1914                    error = %error,
1915                    "shutdown: compaction persistence barrier panicked"
1916                ),
1917            }
1918            // Only the top-level runner reaps the process-global MCP manager.
1919            // A subagent's child runner shares it; reaping here would kill the
1920            // parent's servers the moment the first subagent finished.
1921            if owns_global_mcp {
1922                // If an MCP init is still in flight, its child processes are
1923                // already spawned but `set_manager` hasn't run yet — `get()`
1924                // below would return `None` and we'd leak those children. Wait
1925                // (bounded) for init to settle so the manager is installed
1926                // before we reap it (#59).
1927                let _ = tokio::time::timeout(
1928                    std::time::Duration::from_secs(2),
1929                    crate::mcp::manager_ref::wait_ready(),
1930                )
1931                .await;
1932                // Gracefully shut down MCP server children (the stdin-EOF →
1933                // terminate → kill ladder in `McpServerManager::shutdown`). The
1934                // manager lives in a `'static OnceLock` that never drops, so
1935                // this explicit call on the exit path is the only thing that
1936                // reaps those child processes. No-op when no servers were
1937                // configured.
1938                if let Some(mgr) = crate::mcp::manager_ref::get() {
1939                    mgr.shutdown().await;
1940                }
1941                // Tear down the auto-managed SearXNG process (zero-config
1942                // web_search). Same ownership rule as MCP: only the top-level
1943                // runner reaps process-global services. No-op if none started.
1944                crate::searxng::shutdown().await;
1945            }
1946            // F42: bound each per-scope drain so one non-cooperative task can't
1947            // eat the whole shutdown budget and starve the remaining scopes'
1948            // drains (the scopes were all cancelled above, so a well-behaved task
1949            // unwinds well within this). On timeout, dropping `scope` aborts its
1950            // still-running `JoinSet` members via `TurnScope::drop`.
1951            for (id, mut scope) in self.scopes.drain() {
1952                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1953                    .await
1954                    .is_err()
1955                {
1956                    tracing::warn!(
1957                        turn = %id,
1958                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1959                        "shutdown: scope drain timed out; aborting its remaining tasks"
1960                    );
1961                }
1962            }
1963            while let Some(result) = self.detached.join_next().await {
1964                if let Err(e) = result
1965                    && !e.is_cancelled()
1966                {
1967                    tracing::warn!(error = %e, "shutdown: detached task panic");
1968                }
1969            }
1970        };
1971
1972        let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1973    }
1974}
1975
1976/// Dispatch a `CallModel` command. Resolves the provider (lazy,
1977/// cached) and streams its events onto the Msg channel. Without a
1978/// bound `ProviderFactory` (unit tests), emits a single
1979/// `UpstreamError` so the reducer ends the turn cleanly.
1980/// Report a completed request's completion tokens into the task broker's
1981/// cumulative counter, so task cost deltas (`tokens_spent`) can be computed
1982/// between `in_progress` and completed stamps.
1983fn note_stream_usage(
1984    tasks: &crate::providers::TaskBroker,
1985    usage: &Option<mermaid_model::models::TokenUsage>,
1986) {
1987    if let Some(usage) = usage {
1988        tasks.add_tokens(usage.completion_tokens as u64);
1989    }
1990}
1991
1992mod compaction;
1993mod memory;
1994mod model_call;
1995mod tool_call;
1996
1997use compaction::*;
1998use memory::*;
1999use model_call::*;
2000use tool_call::*;
2001
2002#[cfg(test)]
2003mod tests {
2004    /// Pins the domain-event -> durable-row field mapping. Only `id` and
2005    /// `archive_path` share a name between the two types, so every other line
2006    /// of `compaction_row` is a decision nothing else records.
2007    #[test]
2008    fn compaction_row_maps_every_field_it_claims_to() {
2009        use mermaid_domain::{CompactionEvent, CompactionTrigger};
2010        let record = CompactionEvent {
2011            id: "cmp-1".to_string(),
2012            trigger: CompactionTrigger::Manual,
2013            created_at: chrono::Local::now(),
2014            before_tokens: 9_000,
2015            after_tokens: 1_200,
2016            archived_message_count: 40,
2017            preserved_message_count: 6,
2018            preserved_turn_count: 3,
2019            summary_tokens: 450,
2020            duration_secs: 1.5,
2021            focus: None,
2022            archive_path: None,
2023        };
2024        let row = compaction_row(
2025            &record,
2026            std::path::Path::new("/tmp/archive.json"),
2027            Some("task-7".to_string()),
2028            "sess-3".to_string(),
2029        );
2030        assert_eq!(row.id.as_deref(), Some("cmp-1"));
2031        assert_eq!(row.task_id.as_deref(), Some("task-7"));
2032        assert_eq!(row.session_id.as_deref(), Some("sess-3"));
2033        assert_eq!(row.source_token_estimate, Some(9_000));
2034        assert_eq!(row.summary_token_count, Some(450));
2035        assert_eq!(row.preserved_turns, Some(3));
2036        assert!(row.archive_path.is_some_and(|p| p.contains("archive.json")));
2037    }
2038
2039    use super::*;
2040    use mermaid_domain::ToolCallId;
2041    use std::time::Duration;
2042
2043    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2044        EffectRunner::pair(PathBuf::from("/tmp"))
2045    }
2046
2047    #[test]
2048    fn runtime_tool_payloads_are_redacted_before_serialization() {
2049        let payload = serde_json::json!({
2050            "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
2051            "authorization": "opaque-secret-value",
2052            "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
2053        });
2054        let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
2055        assert!(
2056            !serialized.contains("hunter2"),
2057            "URL password leaked: {serialized}"
2058        );
2059        assert!(
2060            !serialized.contains("opaque-signature"),
2061            "signed URL leaked: {serialized}"
2062        );
2063        assert!(
2064            !serialized.contains("private"),
2065            "URL fragment leaked: {serialized}"
2066        );
2067        assert!(
2068            !serialized.contains("opaque-secret-value"),
2069            "credential-named field leaked: {serialized}"
2070        );
2071        assert!(
2072            !serialized.contains("abcdef123456ghijkl"),
2073            "bearer token leaked: {serialized}"
2074        );
2075        assert!(
2076            !serialized.contains("sk-abcdefghijklmnop1234"),
2077            "secret-shaped fetched content leaked: {serialized}"
2078        );
2079        assert!(serialized.contains("[REDACTED]"));
2080    }
2081
2082    #[test]
2083    fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
2084        let root = std::env::temp_dir().join(format!(
2085            "mermaid-walk-{}-{:?}",
2086            std::process::id(),
2087            std::thread::current().id()
2088        ));
2089        let _ = std::fs::remove_dir_all(&root);
2090        std::fs::create_dir_all(root.join("src")).unwrap();
2091        std::fs::create_dir_all(root.join("target")).unwrap();
2092        std::fs::create_dir_all(root.join(".git")).unwrap();
2093        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
2094        std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2095        std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
2096        std::fs::write(root.join("README.md"), "readme").unwrap();
2097        std::fs::write(root.join(".hidden"), "hidden").unwrap();
2098
2099        let files = walk_project_files(&root);
2100        assert_eq!(
2101            files,
2102            vec![
2103                "README.md".to_string(),
2104                "src/".to_string(),
2105                "src/main.rs".to_string(),
2106            ],
2107            "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
2108        );
2109        let _ = std::fs::remove_dir_all(&root);
2110    }
2111
2112    #[test]
2113    fn new_child_suppresses_terminal_title() {
2114        // A subagent's child runner must not emit OSC 2 terminal titles —
2115        // otherwise they leak into a headless parent's stdout and corrupt
2116        // `--format json`/`text` output (caught during live headless testing).
2117        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2118        let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2119        let tools = Arc::new(ToolRegistry::new());
2120        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2121        assert!(
2122            !child.terminal_title_enabled,
2123            "subagent child runner must suppress terminal-title escapes"
2124        );
2125    }
2126
2127    #[test]
2128    fn new_child_does_not_own_global_mcp_shutdown() {
2129        // The MCP manager is process-global and shared with the parent. A
2130        // child runner's shutdown (which runs after EVERY subagent) must not
2131        // reap it — that would kill the parent's MCP servers for the rest of
2132        // the session. Only the top-level runner owns the reap.
2133        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2134        let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2135        let tools = Arc::new(ToolRegistry::new());
2136        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2137        assert!(
2138            !child.owns_global_mcp,
2139            "child runner must not reap the shared global MCP manager"
2140        );
2141        let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
2142        assert!(
2143            top.owns_global_mcp,
2144            "top-level runner still owns the global MCP reap"
2145        );
2146    }
2147
2148    #[test]
2149    fn parse_prune_plan_extracts_json_amid_prose() {
2150        let plan = parse_prune_plan(
2151            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2152        )
2153        .expect("should parse");
2154        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2155        assert_eq!(plan.reason, "dupes");
2156    }
2157
2158    #[test]
2159    fn parse_prune_plan_handles_empty_and_garbage() {
2160        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2161            .expect("empty plan parses");
2162        assert!(empty.prune.is_empty());
2163        assert!(parse_prune_plan("no json here").is_none());
2164    }
2165
2166    #[test]
2167    fn memory_title_from_text_is_short_and_nonempty() {
2168        assert_eq!(
2169            memory_title_from_text("prefer ripgrep over grep"),
2170            "prefer ripgrep over grep"
2171        );
2172        assert_eq!(memory_title_from_text("   "), "memory");
2173        let long = memory_title_from_text("one two three four five six seven eight nine ten");
2174        assert!(long.split_whitespace().count() <= 8);
2175    }
2176
2177    #[tokio::test]
2178    async fn dispatch_exit_is_noop_on_runner_state() {
2179        let (mut r, _rx) = runner();
2180        r.dispatch(Cmd::Exit);
2181        assert_eq!(r.scope_count(), 0);
2182    }
2183
2184    #[tokio::test]
2185    async fn dispatch_save_emits_session_saved() {
2186        let (mut r, mut rx) = runner();
2187        r.dispatch(Cmd::SaveConversation {
2188            snapshot: mermaid_domain::ConversationHistory::new(
2189                "/p".to_string(),
2190                "m".to_string(),
2191                chrono::Local::now(),
2192            ),
2193            events: Vec::new(),
2194        });
2195        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2196            .await
2197            .expect("sender emits")
2198            .expect("channel alive");
2199        assert!(matches!(msg, Msg::SessionSaved));
2200    }
2201
2202    #[cfg(unix)]
2203    #[tokio::test]
2204    async fn init_mcp_servers_emits_incremental_errored_msgs() {
2205        // Two servers that both fail fast (nonexistent binaries): each
2206        // resolves independently and emits its own Errored msg; init
2207        // completes after both. Also exercises the empty-manager install.
2208        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
2209        let mut configs = std::collections::HashMap::new();
2210        for name in ["one", "two"] {
2211            configs.insert(
2212                name.to_string(),
2213                mermaid_domain::McpServerConfig {
2214                    command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
2215                    ..Default::default()
2216                },
2217            );
2218        }
2219        dispatch_init_mcp_servers(configs, tx).await;
2220        let mut errored = Vec::new();
2221        while let Ok(msg) = rx.try_recv() {
2222            match msg {
2223                Msg::McpServerErrored { name, .. } => errored.push(name),
2224                other => panic!("unexpected msg: {other:?}"),
2225            }
2226        }
2227        errored.sort();
2228        assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
2229        assert!(crate::mcp::manager_ref::is_ready());
2230    }
2231
2232    #[tokio::test]
2233    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2234        let (mut r, mut rx) = runner();
2235        let turn = TurnId(77);
2236        {
2237            let scope = r.scope_mut(turn);
2238            scope.spawn(async {
2239                std::future::pending::<()>().await;
2240            });
2241        }
2242        assert_eq!(r.scope_count(), 1);
2243
2244        let start = std::time::Instant::now();
2245        r.dispatch(Cmd::CancelScope(turn));
2246        assert_eq!(r.scope_count(), 0);
2247        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2248            .await
2249            .expect("bounded cancel should emit terminal message")
2250            .expect("channel alive");
2251        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2252        assert!(
2253            start.elapsed() < Duration::from_millis(500),
2254            "cancel terminal message took {:?}",
2255            start.elapsed()
2256        );
2257    }
2258
2259    #[tokio::test]
2260    async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2261        // Regression (Axis 1 #9): if a turn's tasks complete and
2262        // `reap_empty_scopes` removes the now-empty scope before the user's
2263        // cancel lands, `drop_scope` used to be a silent no-op and the reducer
2264        // stuck forever in `Cancelling`. The terminal `TurnCancelled` must fire
2265        // even when the scope is already gone.
2266        let (mut r, mut rx) = runner();
2267        let turn = TurnId(88);
2268        {
2269            let scope = r.scope_mut(turn);
2270            scope.spawn(async {}); // completes immediately
2271        }
2272        assert_eq!(r.scope_count(), 1);
2273
2274        // Let the task finish, then any dispatch reaps the now-empty scope.
2275        tokio::time::sleep(Duration::from_millis(20)).await;
2276        r.dispatch(Cmd::Exit);
2277        assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2278
2279        // The scope is gone, but the reducer is still `Cancelling`: cancel must
2280        // still produce a terminal message.
2281        r.dispatch(Cmd::CancelScope(turn));
2282        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2283            .await
2284            .expect("cancel on a reaped scope must still emit a terminal message")
2285            .expect("channel alive");
2286        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2287    }
2288
2289    #[tokio::test]
2290    async fn dispatch_call_model_creates_scope() {
2291        let (mut r, _rx) = runner();
2292        let turn = TurnId(7);
2293        let request = mermaid_domain::ChatRequest {
2294            model_id: "test/m".to_string(),
2295            messages: vec![],
2296            system_prompt: String::new(),
2297            instructions: None,
2298            reasoning: mermaid_model::models::ReasoningLevel::Medium,
2299            temperature: 0.7,
2300            max_tokens: 4096,
2301            tools: vec![],
2302
2303            ollama_num_ctx: None,
2304            ollama_allow_ram_offload: None,
2305            resolved_context_window: None,
2306            resolved_max_output: None,
2307            output_schema: None,
2308            suppress_auto_compact: false,
2309            requested_compaction: None,
2310            native_compaction: None,
2311        };
2312        r.dispatch(Cmd::CallModel { turn, request });
2313        assert_eq!(r.scope_count(), 1);
2314    }
2315
2316    /// F12: after a spawned task completes (here via the
2317    /// no-ProviderFactory error path), the next `dispatch` call reaps
2318    /// the empty scope instead of leaving an orphan entry in the map.
2319    #[tokio::test]
2320    async fn empty_scopes_are_reaped_on_next_dispatch() {
2321        let (mut r, mut rx) = runner();
2322        let turn = TurnId(42);
2323        let request = mermaid_domain::ChatRequest {
2324            model_id: "test/m".to_string(),
2325            messages: vec![],
2326            system_prompt: String::new(),
2327            instructions: None,
2328            reasoning: mermaid_model::models::ReasoningLevel::Medium,
2329            temperature: 0.7,
2330            max_tokens: 4096,
2331            tools: vec![],
2332
2333            ollama_num_ctx: None,
2334            ollama_allow_ram_offload: None,
2335            resolved_context_window: None,
2336            resolved_max_output: None,
2337            output_schema: None,
2338            suppress_auto_compact: false,
2339            requested_compaction: None,
2340            native_compaction: None,
2341        };
2342        r.dispatch(Cmd::CallModel { turn, request });
2343        assert_eq!(r.scope_count(), 1);
2344
2345        // Runner has no provider bindings → dispatch_call_model hits
2346        // the "not wired" error path and emits UpstreamError, then the
2347        // spawned task returns. Drain that message so we know the task
2348        // ran to completion.
2349        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2350            .await
2351            .expect("upstream error arrived")
2352            .expect("channel alive");
2353        assert!(matches!(msg, Msg::UpstreamError { .. }));
2354
2355        // Give the JoinSet a tick to notice the task finished.
2356        tokio::task::yield_now().await;
2357
2358        // Any subsequent dispatch reaps the now-empty scope.
2359        r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2360        assert_eq!(
2361            r.scope_count(),
2362            0,
2363            "completed scope must be reaped on next dispatch"
2364        );
2365    }
2366
2367    #[tokio::test]
2368    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2369        let (mut r, mut rx) = runner();
2370        let turn = TurnId(7);
2371        let call_id = ToolCallId(1);
2372        let source = mermaid_model::models::tool_call::ToolCall {
2373            id: Some("c1".to_string()),
2374            function: mermaid_model::models::tool_call::FunctionCall {
2375                name: "read_file".to_string(),
2376                arguments: serde_json::json!({"path": "x"}),
2377            },
2378        };
2379        r.dispatch(Cmd::ExecuteTool {
2380            turn,
2381            call_id,
2382            source,
2383            dispatch: mermaid_domain::ToolDispatch {
2384                model_id: "ollama/test".to_string(),
2385                safety_mode: mermaid_runtime::SafetyMode::Ask,
2386                intent: None,
2387                session_id: "sess-test".to_string(),
2388                message_index: 0,
2389                scratchpad: None,
2390            },
2391        });
2392        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2393            .await
2394            .expect("some msg")
2395            .expect("channel alive");
2396        assert!(matches!(
2397            first,
2398            Msg::ToolStarted {
2399                turn: t,
2400                call_id: c,
2401            } if t == turn && c == call_id
2402        ));
2403    }
2404
2405    #[tokio::test]
2406    async fn cancel_scope_before_execute_tool_drops_pending_work() {
2407        let (mut r, _rx) = runner();
2408        let turn = TurnId(9);
2409        r.dispatch(Cmd::CallModel {
2410            turn,
2411            request: mermaid_domain::ChatRequest {
2412                model_id: "m".to_string(),
2413                messages: vec![],
2414                system_prompt: String::new(),
2415                instructions: None,
2416                reasoning: mermaid_model::models::ReasoningLevel::Medium,
2417                temperature: 0.7,
2418                max_tokens: 4096,
2419                tools: vec![],
2420
2421                ollama_num_ctx: None,
2422                ollama_allow_ram_offload: None,
2423                resolved_context_window: None,
2424                resolved_max_output: None,
2425                output_schema: None,
2426                suppress_auto_compact: false,
2427                requested_compaction: None,
2428                native_compaction: None,
2429            },
2430        });
2431        assert_eq!(r.scope_count(), 1);
2432
2433        r.dispatch(Cmd::CancelScope(turn));
2434        assert_eq!(r.scope_count(), 0);
2435    }
2436
2437    #[tokio::test]
2438    async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2439        // F38: once a turn's scope has been cancelled (dropped + tombstoned), a
2440        // stray turn-scoped Cmd bearing the same TurnId must be dropped — not
2441        // used to spin up a fresh, un-cancelled scope via `scope_mut`'s
2442        // `or_insert_with`. Turn ids are monotonic and never reused, so such a
2443        // Cmd can only be a post-cancel straggler.
2444        let (mut r, _rx) = runner();
2445        let req = || mermaid_domain::ChatRequest {
2446            model_id: "test/m".to_string(),
2447            messages: vec![],
2448            system_prompt: String::new(),
2449            instructions: None,
2450            reasoning: mermaid_model::models::ReasoningLevel::Medium,
2451            temperature: 0.7,
2452            max_tokens: 4096,
2453            tools: vec![],
2454            ollama_num_ctx: None,
2455            ollama_allow_ram_offload: None,
2456            resolved_context_window: None,
2457            resolved_max_output: None,
2458            output_schema: None,
2459            suppress_auto_compact: false,
2460            requested_compaction: None,
2461            native_compaction: None,
2462        };
2463        let turn = TurnId(123);
2464
2465        r.dispatch(Cmd::CallModel {
2466            turn,
2467            request: req(),
2468        });
2469        assert_eq!(r.scope_count(), 1);
2470
2471        // Cancel: drops the scope and tombstones the turn.
2472        r.dispatch(Cmd::CancelScope(turn));
2473        assert_eq!(r.scope_count(), 0);
2474
2475        // A late scoped Cmd for the now-tombstoned turn must be dropped.
2476        r.dispatch(Cmd::CallModel {
2477            turn,
2478            request: req(),
2479        });
2480        assert_eq!(
2481            r.scope_count(),
2482            0,
2483            "a cancelled turn must not be resurrected by a late scoped Cmd"
2484        );
2485
2486        // A fresh, higher turn id is unaffected by the tombstone.
2487        r.dispatch(Cmd::CallModel {
2488            turn: TurnId(124),
2489            request: req(),
2490        });
2491        assert_eq!(
2492            r.scope_count(),
2493            1,
2494            "a fresh turn must still create its scope normally"
2495        );
2496    }
2497
2498    #[tokio::test]
2499    async fn shutdown_drains_pending_saves() {
2500        let (mut r, _rx) = runner();
2501        for _ in 0..5 {
2502            r.dispatch(Cmd::SaveConversation {
2503                snapshot: mermaid_domain::ConversationHistory::new(
2504                    "/p".to_string(),
2505                    "m".to_string(),
2506                    chrono::Local::now(),
2507                ),
2508                events: Vec::new(),
2509            });
2510        }
2511        // Shutdown waits for all five to complete (should be instant).
2512        let start = std::time::Instant::now();
2513        r.shutdown().await;
2514        assert!(start.elapsed() < Duration::from_secs(2));
2515    }
2516
2517    fn persistence_fixture(
2518        root: &std::path::Path,
2519        record_id: &str,
2520    ) -> (mermaid_domain::ConversationHistory, PendingCompactionSave) {
2521        let now = chrono::Local::now();
2522        let mut full = mermaid_domain::ConversationHistory::new(
2523            root.display().to_string(),
2524            "test/model".to_string(),
2525            now,
2526        );
2527        full.add_messages(
2528            &[mermaid_model::models::ChatMessage::user("raw history")],
2529            now,
2530        );
2531        let mut compacted = full.clone();
2532        compacted.replace_messages(
2533            vec![mermaid_model::models::ChatMessage::user(
2534                "compacted checkpoint",
2535            )],
2536            now,
2537        );
2538        let record = mermaid_domain::CompactionEvent {
2539            id: record_id.to_string(),
2540            trigger: mermaid_domain::CompactionTrigger::Manual,
2541            created_at: now,
2542            before_tokens: 100,
2543            after_tokens: 20,
2544            archived_message_count: 1,
2545            preserved_message_count: 1,
2546            preserved_turn_count: 1,
2547            summary_tokens: 10,
2548            duration_secs: 0.1,
2549            focus: None,
2550            archive_path: None,
2551        };
2552        (
2553            full,
2554            PendingCompactionSave {
2555                record,
2556                conversation: compacted,
2557                // The boundary event the save must land before it overwrites
2558                // the snapshot.
2559                events: vec![mermaid_domain::SessionEvent::Input {
2560                    text: "compaction boundary".to_string(),
2561                }],
2562                events_appended: false,
2563                task_id: None,
2564            },
2565        )
2566    }
2567
2568    /// Make every event append for `id` fail, by planting a directory where
2569    /// its log file goes. This is the failure the barrier exists for now
2570    /// that the boundary event -- not an archive file -- is the only record
2571    /// of a compaction's dropped messages.
2572    fn block_event_log(root: &std::path::Path, id: &str) {
2573        let dir = root.join(".mermaid").join("conversations");
2574        std::fs::create_dir_all(&dir).expect("conversations dir");
2575        std::fs::create_dir_all(dir.join(format!("{id}.jsonl"))).expect("plant a blocker");
2576    }
2577
2578    /// One `Message` event plus the snapshot that now contains it.
2579    fn one_message_save(
2580        conversation: &mut mermaid_domain::ConversationHistory,
2581        text: &str,
2582    ) -> PersistenceJob {
2583        let message = mermaid_model::models::ChatMessage::user(text);
2584        conversation.add_messages(std::slice::from_ref(&message), chrono::Local::now());
2585        PersistenceJob::Conversation {
2586            snapshot: Box::new(conversation.clone()),
2587            events: vec![mermaid_domain::SessionEvent::Message { message }],
2588        }
2589    }
2590
2591    #[test]
2592    fn the_checkpoint_stops_being_written_on_every_save() {
2593        // The point of the throttle: appends stay O(1) per message while
2594        // the whole-transcript rewrite happens on a coarse cadence. What
2595        // must NOT change is what a resume sees.
2596        let root = std::env::temp_dir().join(format!(
2597            "mermaid-throttle-{}-{:?}",
2598            std::process::id(),
2599            std::thread::current().id()
2600        ));
2601        let _ = std::fs::remove_dir_all(&root);
2602        let manager = crate::session::ConversationManager::new(&root).unwrap();
2603        let mut conversation = mermaid_domain::ConversationHistory::new(
2604            root.display().to_string(),
2605            "test/model".to_string(),
2606            chrono::Local::now(),
2607        );
2608        let mut state = PersistenceState::new(root.clone());
2609
2610        // The first save creates the log (and its backfill) but no
2611        // checkpoint: nowhere near the threshold.
2612        state
2613            .process(one_message_save(&mut conversation, "first"))
2614            .1
2615            .unwrap();
2616        let checkpoint = manager
2617            .conversations_dir()
2618            .join(format!("{}.json", conversation.id));
2619        assert!(
2620            !checkpoint.exists(),
2621            "a single save must not rewrite the transcript"
2622        );
2623
2624        // ...and resume still sees it, because the log is the truth.
2625        let resumed = manager.load_conversation(&conversation.id).unwrap();
2626        assert_eq!(resumed.messages().len(), 1);
2627        assert_eq!(resumed.messages()[0].content, "first");
2628
2629        // Crossing the threshold materializes one.
2630        for i in 0..CHECKPOINT_EVERY_EVENTS {
2631            state
2632                .process(one_message_save(&mut conversation, &format!("m{i}")))
2633                .1
2634                .unwrap();
2635        }
2636        assert!(
2637            checkpoint.exists(),
2638            "crossing {CHECKPOINT_EVERY_EVENTS} events must materialize a checkpoint"
2639        );
2640        let resumed = manager.load_conversation(&conversation.id).unwrap();
2641        assert_eq!(resumed.messages().len(), CHECKPOINT_EVERY_EVENTS + 1);
2642        let _ = std::fs::remove_dir_all(root);
2643    }
2644
2645    #[test]
2646    fn shutdown_flushes_the_checkpoint_it_was_holding() {
2647        let root = std::env::temp_dir().join(format!(
2648            "mermaid-flush-{}-{:?}",
2649            std::process::id(),
2650            std::thread::current().id()
2651        ));
2652        let _ = std::fs::remove_dir_all(&root);
2653        let manager = crate::session::ConversationManager::new(&root).unwrap();
2654        let mut conversation = mermaid_domain::ConversationHistory::new(
2655            root.display().to_string(),
2656            "test/model".to_string(),
2657            chrono::Local::now(),
2658        );
2659        let mut state = PersistenceState::new(root.clone());
2660        state
2661            .process(one_message_save(&mut conversation, "only message"))
2662            .1
2663            .unwrap();
2664
2665        let checkpoint = manager
2666            .conversations_dir()
2667            .join(format!("{}.json", conversation.id));
2668        assert!(!checkpoint.exists());
2669        state.flush_checkpoints().unwrap();
2670        assert!(
2671            checkpoint.exists(),
2672            "a clean exit must leave a current checkpoint"
2673        );
2674        // And it carries the watermark, so the next resume replays nothing.
2675        let raw = std::fs::read_to_string(&checkpoint).unwrap();
2676        let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2677        assert!(
2678            value.get("checkpoint_seq").is_some(),
2679            "a flushed checkpoint must be placeable in its log: {raw}"
2680        );
2681        let _ = std::fs::remove_dir_all(root);
2682    }
2683
2684    #[test]
2685    fn a_failed_append_keeps_its_events_for_the_next_save() {
2686        // The append is the save now, so a dropped batch is lost data --
2687        // the reducer drains its buffer at emission and never re-offers it.
2688        let root = std::env::temp_dir().join(format!(
2689            "mermaid-unappended-{}-{:?}",
2690            std::process::id(),
2691            std::thread::current().id()
2692        ));
2693        let _ = std::fs::remove_dir_all(&root);
2694        let manager = crate::session::ConversationManager::new(&root).unwrap();
2695        let mut conversation = mermaid_domain::ConversationHistory::new(
2696            root.display().to_string(),
2697            "test/model".to_string(),
2698            chrono::Local::now(),
2699        );
2700        let mut state = PersistenceState::new(root.clone());
2701
2702        // Block the log: a directory where its file goes.
2703        std::fs::create_dir_all(
2704            manager
2705                .conversations_dir()
2706                .join(format!("{}.jsonl", conversation.id)),
2707        )
2708        .expect("plant a blocker");
2709        let job = one_message_save(&mut conversation, "must survive");
2710        assert!(state.process(job).1.is_err(), "the append must fail");
2711        assert_eq!(
2712            state.unappended.get(&conversation.id).map(Vec::len),
2713            Some(1),
2714            "the batch must be held, not dropped"
2715        );
2716
2717        // Unblock, save again: the held event goes first and both land.
2718        std::fs::remove_dir_all(
2719            manager
2720                .conversations_dir()
2721                .join(format!("{}.jsonl", conversation.id)),
2722        )
2723        .expect("unblock");
2724        state
2725            .process(one_message_save(&mut conversation, "and this one"))
2726            .1
2727            .unwrap();
2728        assert!(state.unappended.is_empty(), "the hold must clear");
2729        state.flush_checkpoints().unwrap();
2730
2731        let resumed = manager.load_conversation(&conversation.id).unwrap();
2732        let texts: Vec<&str> = resumed
2733            .messages()
2734            .iter()
2735            .map(|m| m.content.as_str())
2736            .collect();
2737        assert!(
2738            texts.contains(&"must survive"),
2739            "the event held over a failed append must reach the log: {texts:?}"
2740        );
2741        assert!(texts.contains(&"and this one"), "{texts:?}");
2742        let _ = std::fs::remove_dir_all(root);
2743    }
2744
2745    #[test]
2746    fn persistence_orders_compaction_before_newer_conversation_save() {
2747        let root = std::env::temp_dir().join(format!(
2748            "mermaid-persistence-order-{}-{:?}",
2749            std::process::id(),
2750            std::thread::current().id()
2751        ));
2752        let _ = std::fs::remove_dir_all(&root);
2753        let (full, compaction) = persistence_fixture(&root, "compact_ordered");
2754        let manager = crate::session::ConversationManager::new(&root).unwrap();
2755        manager.save_conversation(&full).unwrap();
2756
2757        let mut state = PersistenceState::new(root.clone());
2758        let (events, outcome) =
2759            state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
2760        outcome.unwrap();
2761        assert_eq!(events.len(), 1);
2762        let mut newer = compaction.conversation;
2763        let reply = mermaid_model::models::ChatMessage::assistant("new assistant reply");
2764        newer.add_messages(std::slice::from_ref(&reply), chrono::Local::now());
2765        // The event, not just the snapshot: the log is the truth now, and a
2766        // save below the checkpoint threshold writes nothing else. A fixture
2767        // that passed an empty batch would be asserting against a file this
2768        // save no longer touches.
2769        let (_, outcome) = state.process(PersistenceJob::Conversation {
2770            snapshot: Box::new(newer),
2771            events: vec![mermaid_domain::SessionEvent::Message { message: reply }],
2772        });
2773        outcome.unwrap();
2774
2775        let loaded = crate::session::ConversationManager::new(&root)
2776            .unwrap()
2777            .load_conversation(&full.id)
2778            .unwrap();
2779        assert!(
2780            loaded
2781                .messages()
2782                .iter()
2783                .any(|message| message.content == "new assistant reply")
2784        );
2785        let _ = std::fs::remove_dir_all(root);
2786    }
2787
2788    #[test]
2789    fn failed_event_append_blocks_later_stripped_conversation_save() {
2790        let root = std::env::temp_dir().join(format!(
2791            "mermaid-persistence-barrier-{}-{:?}",
2792            std::process::id(),
2793            std::thread::current().id()
2794        ));
2795        let _ = std::fs::remove_dir_all(&root);
2796        let (full, compaction) = persistence_fixture(&root, "compact_blocked");
2797        let manager = crate::session::ConversationManager::new(&root).unwrap();
2798        manager.save_conversation(&full).unwrap();
2799        block_event_log(&root, &full.id);
2800
2801        let mut state = PersistenceState::new(root.clone());
2802        assert!(
2803            state
2804                .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
2805                .1
2806                .is_err()
2807        );
2808        assert!(
2809            state
2810                .process(PersistenceJob::Conversation {
2811                    snapshot: Box::new(compaction.conversation),
2812                    events: Vec::new(),
2813                })
2814                .1
2815                .is_err()
2816        );
2817        assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2818
2819        let loaded = crate::session::ConversationManager::new(&root)
2820            .unwrap()
2821            .load_conversation(&full.id)
2822            .unwrap();
2823        assert_eq!(loaded.messages()[0].content, "raw history");
2824        let _ = std::fs::remove_dir_all(root);
2825    }
2826
2827    #[test]
2828    fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
2829        let root = std::env::temp_dir().join(format!(
2830            "mermaid-persistence-queue-{}-{:?}",
2831            std::process::id(),
2832            std::thread::current().id()
2833        ));
2834        let _ = std::fs::remove_dir_all(&root);
2835        let (full, first) = persistence_fixture(&root, "compact_first");
2836        let mut second = first.clone();
2837        second.record.id = "compact_second".to_string();
2838        block_event_log(&root, &full.id);
2839
2840        let mut state = PersistenceState::new(root.clone());
2841        assert!(
2842            state
2843                .process(PersistenceJob::Compaction(Box::new(first)))
2844                .1
2845                .is_err()
2846        );
2847        // The older barrier still fails; the new save must queue behind it —
2848        // its boundary event is the only record of the stripped messages.
2849        assert!(
2850            state
2851                .process(PersistenceJob::Compaction(Box::new(second)))
2852                .1
2853                .is_err()
2854        );
2855        let queued = state.blocked.get(&full.id).expect("barrier queue");
2856        assert_eq!(queued.len(), 2);
2857        assert_eq!(queued[0].record.id, "compact_first");
2858        assert_eq!(queued[1].record.id, "compact_second");
2859        let _ = std::fs::remove_dir_all(root);
2860    }
2861
2862    #[test]
2863    fn retry_all_blocked_attempts_every_conversation() {
2864        let root = std::env::temp_dir().join(format!(
2865            "mermaid-persistence-drain-{}-{:?}",
2866            std::process::id(),
2867            std::thread::current().id()
2868        ));
2869        let _ = std::fs::remove_dir_all(&root);
2870        let (bad_full, bad) = persistence_fixture(&root, "compact_bad");
2871        let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
2872        block_event_log(&root, &bad_full.id);
2873        // Conversation ids are millisecond timestamps; two fixtures minted in
2874        // the same instant would collide into one barrier queue. Force the
2875        // second conversation onto a distinct (still format-valid) id.
2876        good_full.id = "20990101_000000_001".to_string();
2877        good.conversation.id = good_full.id.clone();
2878
2879        let mut state = PersistenceState::new(root.clone());
2880        state
2881            .blocked
2882            .entry(bad_full.id.clone())
2883            .or_default()
2884            .push_back(bad);
2885        state
2886            .blocked
2887            .entry(good_full.id.clone())
2888            .or_default()
2889            .push_back(good);
2890
2891        // One conversation's bad disk state must not strand the other's
2892        // barrier at shutdown: the error surfaces, but the good save lands —
2893        // and its durably persisted event is reported alongside the error.
2894        let (events, outcome) = state.retry_all_blocked();
2895        assert!(outcome.is_err());
2896        assert_eq!(events.len(), 1);
2897        assert_eq!(events[0].id, "compact_good");
2898        assert!(!state.blocked.contains_key(&good_full.id));
2899        assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
2900        let loaded = crate::session::ConversationManager::new(&root)
2901            .unwrap()
2902            .load_conversation(&good_full.id)
2903            .unwrap();
2904        assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
2905        let _ = std::fs::remove_dir_all(root);
2906    }
2907
2908    #[test]
2909    fn partially_drained_barrier_reports_its_persisted_events() {
2910        let root = std::env::temp_dir().join(format!(
2911            "mermaid-persistence-partial-{}-{:?}",
2912            std::process::id(),
2913            std::thread::current().id()
2914        ));
2915        let _ = std::fs::remove_dir_all(&root);
2916        let (full, good) = persistence_fixture(&root, "compact_good");
2917        let mut bad = good.clone();
2918        bad.record.id = "compact_bad".to_string();
2919        // Both saves sit in ONE queue, so the blocker cannot be the shared
2920        // log path: give the tail an id that fails validation instead.
2921        bad.conversation.id = "../invalid".to_string();
2922
2923        let mut state = PersistenceState::new(root.clone());
2924        let queue = state.blocked.entry(full.id.clone()).or_default();
2925        queue.push_back(good);
2926        queue.push_back(bad);
2927
2928        // The good save at the head of the queue persists durably before the
2929        // bad one fails. Its event must surface with the error — it is popped
2930        // and would otherwise never fire SessionSaved or the compaction hook.
2931        let (events, outcome) = state.retry_blocked(&full.id);
2932        assert!(outcome.is_err());
2933        assert_eq!(events.len(), 1);
2934        assert_eq!(events[0].id, "compact_good");
2935        assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2936        let _ = std::fs::remove_dir_all(root);
2937    }
2938}