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