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