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