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 middleware;
36mod turn_scope;
37
38use std::collections::HashMap;
39use std::collections::VecDeque;
40use std::path::PathBuf;
41use std::sync::Arc;
42use std::sync::Mutex;
43use std::time::Instant;
44
45use tokio::sync::mpsc;
46
47use crate::app::{Config, MemoryConfig};
48use crate::domain::{
49    Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
50};
51use crate::models::{ModelError, TokenUsage};
52use crate::providers::ctx::{ExecContext, StreamContext};
53use crate::providers::model::ModelProvider;
54use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
55use crate::utils::{join_logged, spawn_guarded};
56
57pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
58pub use turn_scope::TurnScope;
59
60#[cfg(not(test))]
61const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
62#[cfg(test)]
63const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
64
65/// F38: how many recently-cancelled `TurnId`s to remember as tombstones.
66/// Turn ids are strictly monotonic and never reused, so a stray turn-scoped
67/// `Cmd` for a cancelled turn can only ever be a post-cancel straggler that
68/// lands within a few turns of the cancel. A small bounded ring is plenty;
69/// older entries age out so the set never grows across a long session.
70const CANCELLED_TOMBSTONE_CAP: usize = 256;
71
72/// Single channel back to the reducer. `EffectRunner` holds the
73/// sender; every spawned task clones this so it can emit `Msg` as
74/// work progresses. Bounded capacity applies natural backpressure —
75/// if the main loop can't keep up, the provider's streaming send
76/// `.await`s and the whole pipeline throttles.
77pub type MsgSender = mpsc::Sender<Msg>;
78
79/// Bounded channel capacity for the effect → reducer stream. 512 is
80/// generous — a single streaming chunk fits comfortably, and the
81/// main loop drains at ~60 Hz so backlog rarely grows. Bigger wastes
82/// RAM; smaller introduces spurious backpressure on bursty tool
83/// output.
84pub const MSG_CHANNEL_CAPACITY: usize = 512;
85
86#[derive(Clone)]
87enum PersistenceJob {
88    Conversation(Box<crate::session::ConversationHistory>),
89    Compaction(Box<PendingCompactionSave>),
90}
91
92#[derive(Clone)]
93struct PendingCompactionSave {
94    archive: crate::domain::CompactionArchive,
95    record: crate::domain::CompactionRecord,
96    conversation: crate::session::ConversationHistory,
97    task_id: Option<String>,
98}
99
100struct PersistedCompaction {
101    id: String,
102    task_id: Option<String>,
103    session_id: String,
104    archive_path: PathBuf,
105}
106
107struct PersistenceState {
108    workdir: PathBuf,
109    manager: Option<crate::session::ConversationManager>,
110    blocked: HashMap<String, VecDeque<PendingCompactionSave>>,
111}
112
113impl PersistenceState {
114    fn new(workdir: PathBuf) -> Self {
115        Self {
116            workdir,
117            manager: None,
118            blocked: HashMap::new(),
119        }
120    }
121
122    fn manager(&mut self) -> anyhow::Result<&crate::session::ConversationManager> {
123        if self.manager.is_none() {
124            self.manager = Some(crate::session::ConversationManager::new(&self.workdir)?);
125        }
126        Ok(self.manager.as_ref().expect("manager initialized"))
127    }
128
129    /// Run one job. Returns every compaction event that persisted durably —
130    /// even when the job as a whole failed — so partially-drained barriers
131    /// still fire their hooks and `SessionSaved`; a dropped event would never
132    /// be re-emitted (its save is already popped).
133    fn process(&mut self, job: PersistenceJob) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
134        match job {
135            PersistenceJob::Conversation(history) => {
136                // Barrier: a still-blocked compaction must persist before any
137                // newer (stripped) conversation snapshot may overwrite the file.
138                let (persisted, retried) = self.retry_blocked(&history.id);
139                if retried.is_err() {
140                    return (persisted, retried);
141                }
142                let saved = self
143                    .manager()
144                    .and_then(|manager| manager.save_conversation(&history).map(|_| ()));
145                (persisted, saved)
146            },
147            PersistenceJob::Compaction(save) => {
148                // Queue first, then drain. The archive is the only durable copy
149                // of the stripped messages, so the save must survive an Err AND
150                // a panic in the write path (pop happens only after success),
151                // and it must land behind any older still-blocked saves (FIFO).
152                let conversation_id = save.archive.conversation_id.clone();
153                self.blocked
154                    .entry(conversation_id.clone())
155                    .or_default()
156                    .push_back(*save);
157                self.retry_blocked(&conversation_id)
158            },
159        }
160    }
161
162    fn retry_blocked(
163        &mut self,
164        conversation_id: &str,
165    ) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
166        let mut persisted = Vec::new();
167        if !self.blocked.contains_key(conversation_id) {
168            return (persisted, Ok(()));
169        }
170        if let Err(error) = self.manager() {
171            return (persisted, Err(error));
172        }
173        // Disjoint field borrows: the manager stays immutably borrowed while
174        // the queue is drained in place — no per-retry clone of the (large)
175        // pending conversation snapshots.
176        let manager = self.manager.as_ref().expect("manager initialized");
177        let queue = self
178            .blocked
179            .get_mut(conversation_id)
180            .expect("checked above");
181        while let Some(save) = queue.front() {
182            match Self::persist_compaction(manager, save) {
183                // Pop only after a successful write: `persist_compaction` runs
184                // inside `spawn_blocking`, and a panic there must not lose the
185                // save (the mutex is poison-tolerant, so the state survives).
186                Ok(event) => {
187                    persisted.push(event);
188                    queue.pop_front();
189                },
190                Err(error) => return (persisted, Err(error)),
191            }
192        }
193        self.blocked.remove(conversation_id);
194        (persisted, Ok(()))
195    }
196
197    fn retry_all_blocked(&mut self) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
198        let ids: Vec<String> = self.blocked.keys().cloned().collect();
199        let mut persisted = Vec::new();
200        let mut first_error = None;
201        for id in ids {
202            // Keep draining the other conversations' barriers; one
203            // conversation's bad disk state must not strand the rest.
204            let (events, result) = self.retry_blocked(&id);
205            persisted.extend(events);
206            if let Err(error) = result {
207                first_error.get_or_insert(error);
208            }
209        }
210        match first_error {
211            None => (persisted, Ok(())),
212            Some(error) => (persisted, Err(error)),
213        }
214    }
215
216    fn persist_compaction(
217        manager: &crate::session::ConversationManager,
218        save: &PendingCompactionSave,
219    ) -> anyhow::Result<PersistedCompaction> {
220        let path = manager.save_compaction_archive(&save.archive)?;
221        manager.save_conversation(&save.conversation)?;
222
223        if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
224            let _ = store.compactions().create(crate::runtime::NewCompaction {
225                id: Some(save.record.id.clone()),
226                task_id: save.task_id.clone(),
227                session_id: Some(save.archive.conversation_id.clone()),
228                source_token_estimate: Some(save.record.before_tokens as i64),
229                summary_token_count: Some(save.record.summary_tokens as i64),
230                preserved_turns: Some(save.record.preserved_turn_count as i64),
231                archive_path: Some(path.display().to_string()),
232                verification_status: Some(save.record.review_status.as_str().to_string()),
233            });
234        }
235
236        Ok(PersistedCompaction {
237            id: save.record.id.clone(),
238            task_id: save.task_id.clone(),
239            session_id: save.archive.conversation_id.clone(),
240            archive_path: path,
241        })
242    }
243}
244
245/// Fire the plugin `compaction` hook for one durably persisted archive.
246async fn fire_compaction_hook(event: &PersistedCompaction) {
247    fire_plugin_hooks(
248        "compaction",
249        serde_json::json!({
250            "id": event.id,
251            "task_id": event.task_id,
252            "session_id": event.session_id,
253            "archive_path": event.archive_path.display().to_string(),
254        }),
255    )
256    .await;
257}
258
259/// The runner. One instance per process, constructed by
260/// `app::run` and consumed when the main loop exits.
261pub struct EffectRunner {
262    msg_tx: MsgSender,
263    /// Per-turn scopes. Populated lazily: the first `Cmd` bearing a
264    /// TurnId creates a scope; `Cmd::CancelScope` tears it down.
265    /// Empty (drained) scopes are reaped by `reap_empty_scopes`, which
266    /// runs at the top of every `dispatch` call so the map stays
267    /// bounded across long sessions (F12).
268    scopes: HashMap<TurnId, TurnScope>,
269    /// F38: bounded tombstone ring of `TurnId`s whose scope has been
270    /// cancelled+dropped. A turn-scoped `Cmd` (`CallModel` / `ExecuteTool` /
271    /// `CompactConversation`) bearing a tombstoned id is dropped in `dispatch`
272    /// instead of resurrecting a fresh, un-cancelled scope through
273    /// `scope_mut`'s `or_insert_with`. Bounded to `CANCELLED_TOMBSTONE_CAP`.
274    cancelled_turns: VecDeque<TurnId>,
275    /// Detached work (saves, persists, MCP lifecycle) lives here.
276    /// This one set never gets cancelled piecemeal — shutdown drains
277    /// it during `EffectRunner::shutdown`.
278    detached: tokio::task::JoinSet<()>,
279    /// FIFO chain for conversation and compaction writes. Keeping persistence
280    /// separate from `detached` prevents an older compaction snapshot from
281    /// racing a newer normal save and winning last-write-wins.
282    persistence_state: Arc<Mutex<PersistenceState>>,
283    persistence_tail: Option<tokio::task::JoinHandle<()>>,
284    /// MCP manager handle is held elsewhere (`crate::mcp` has a
285    /// `OnceLock` for its global manager); we just note workdir so
286    /// handlers can construct absolute paths.
287    workdir: PathBuf,
288    /// Lazy provider registry. `CallModel` resolves through this.
289    /// Tests that don't care about real providers leave this `None`
290    /// and observe the fallback `UpstreamError` Msg; production
291    /// construction via `with_bindings` sets it.
292    providers: Option<Arc<ProviderFactory>>,
293    /// Shared tool registry. See `providers` — same optionality
294    /// rationale for unit tests.
295    tools: Option<Arc<ToolRegistry>>,
296    /// Durable runtime task that owns work launched by this runner.
297    task_id: Option<String>,
298    /// Interactive TUI runners write OSC 2 terminal-title updates.
299    /// Headless `mermaid run` must suppress them so stdout stays
300    /// machine-readable for JSON/markdown/text output modes.
301    terminal_title_enabled: bool,
302    /// Whether this runner's `shutdown` reaps the PROCESS-GLOBAL MCP manager
303    /// (`crate::mcp::manager_ref`). True only for the top-level runner. A
304    /// subagent's child runner shares the global manager, so it must NOT reap
305    /// it — otherwise the first subagent to finish would kill every MCP
306    /// server out from under the parent for the rest of the session.
307    owns_global_mcp: bool,
308    /// Inline-approval broker. `Some` only for interactive TUI runs (set via
309    /// `with_interactive_approvals`); headless + child runners leave it `None`,
310    /// so the gate falls back to the out-of-band DB-approval flow.
311    approval: Option<crate::providers::ApprovalBroker>,
312    /// Inline-question broker for `ask_user_question`. `Some` only for
313    /// interactive TUI runs (set via `with_interactive_questions`); headless +
314    /// child runners leave it `None`, so the tool proceeds without asking.
315    questions: Option<crate::providers::QuestionBroker>,
316    /// Checklist broker for the task tools. Built unconditionally — unlike
317    /// `questions`, task tracking works headless, and a subagent's child
318    /// runner minting its own broker (bound to the CHILD's msg channel) is
319    /// exactly what isolates its checklist from the parent's.
320    tasks: crate::providers::TaskBroker,
321    /// Abort handle for the background config watcher (#45). It's a perpetual
322    /// loop living in `detached`, so `shutdown` aborts it explicitly before
323    /// draining — otherwise the drain would block on it until the timeout.
324    config_watch: Option<tokio::task::AbortHandle>,
325}
326
327impl EffectRunner {
328    /// Create an unused runner. Pair with `msg_rx` from `channel()`.
329    pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
330        let persistence_state = Arc::new(Mutex::new(PersistenceState::new(workdir.clone())));
331        Self {
332            tasks: crate::providers::TaskBroker::new(msg_tx.clone()),
333            msg_tx,
334            scopes: HashMap::new(),
335            cancelled_turns: VecDeque::new(),
336            detached: tokio::task::JoinSet::new(),
337            persistence_state,
338            persistence_tail: None,
339            workdir,
340            providers: None,
341            tools: None,
342            task_id: None,
343            terminal_title_enabled: true,
344            owns_global_mcp: true,
345            approval: None,
346            questions: None,
347            config_watch: None,
348        }
349    }
350
351    /// Enable inline approval prompts (interactive TUI only). The gate then
352    /// pauses gated tools and routes the user's decision through the
353    /// `ApprovalBroker` instead of writing an out-of-band DB approval row.
354    pub fn with_interactive_approvals(mut self) -> Self {
355        self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
356        self
357    }
358
359    /// Enable inline `ask_user_question` prompts (interactive TUI only). The tool
360    /// then parks on the `QuestionBroker` and routes the user's answers back
361    /// through it instead of proceeding without asking.
362    pub fn with_interactive_questions(mut self) -> Self {
363        self.questions = Some(crate::providers::QuestionBroker::new(self.msg_tx.clone()));
364        self
365    }
366
367    /// Start the background config watcher (#45): it polls `MERMAID.md` + memory
368    /// and emits `Msg::InstructionsChanged`/`MemoryChanged` on change, so the
369    /// reducer reads them as injected data instead of refreshing inline. Call
370    /// once at startup. Live-loop only — a replay driver feeds the recorded
371    /// Changed Msgs rather than polling.
372    pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
373        let handle = self.detached.spawn(config_watch::config_watcher(
374            self.msg_tx.clone(),
375            cwd,
376            memory,
377        ));
378        self.config_watch = Some(handle);
379    }
380
381    /// Attach a durable runtime task id so tool runs, approvals,
382    /// checkpoints, compactions, and background processes can be linked.
383    pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
384        self.task_id = task_id;
385        self
386    }
387
388    /// Disable terminal-title writes for non-interactive callers.
389    pub fn without_terminal_title(mut self) -> Self {
390        self.terminal_title_enabled = false;
391        self
392    }
393
394    /// Leave the process-global MCP manager alone on `shutdown`. Child
395    /// (subagent) runners share it with the parent and must not reap it.
396    pub fn without_global_mcp_shutdown(mut self) -> Self {
397        self.owns_global_mcp = false;
398        self
399    }
400
401    /// Attach provider + tool registries. Production wiring uses
402    /// this; unit tests that don't need real dispatch can skip.
403    /// Without bindings, `CallModel` / `ExecuteTool` emit well-
404    /// formed error Msgs so the reducer still transitions cleanly.
405    pub fn with_bindings(
406        mut self,
407        providers: Arc<ProviderFactory>,
408        tools: Arc<ToolRegistry>,
409    ) -> Self {
410        self.providers = Some(providers);
411        self.tools = Some(tools);
412        self
413    }
414
415    /// Pair-constructor: returns both the runner and the receiving
416    /// end of the Msg channel. Preferred for production wiring
417    /// because it keeps the channel capacity constant in one place.
418    pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
419        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
420        (Self::new(tx, workdir), rx)
421    }
422
423    /// Pair constructor that also wires the real provider factory +
424    /// tool registry. Used by `app::run_interactive`.
425    pub fn pair_with_bindings(
426        workdir: PathBuf,
427        config: Config,
428        tools: Arc<ToolRegistry>,
429    ) -> (Self, mpsc::Receiver<Msg>) {
430        let providers = Arc::new(ProviderFactory::new(config));
431        Self::pair_from(workdir, providers, tools)
432    }
433
434    /// Pair constructor that takes a pre-built `ProviderFactory`.
435    /// Used when the caller needs to share a `ProviderFactory` with
436    /// the `SubagentSpawner` so subagents can issue model calls
437    /// through the same cache.
438    pub fn pair_from(
439        workdir: PathBuf,
440        providers: Arc<ProviderFactory>,
441        tools: Arc<ToolRegistry>,
442    ) -> (Self, mpsc::Receiver<Msg>) {
443        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
444        (Self::new(tx, workdir).with_bindings(providers, tools), rx)
445    }
446
447    pub fn pair_from_with_task(
448        workdir: PathBuf,
449        providers: Arc<ProviderFactory>,
450        tools: Arc<ToolRegistry>,
451        task_id: Option<String>,
452    ) -> (Self, mpsc::Receiver<Msg>) {
453        let (runner, rx) = Self::pair_from(workdir, providers, tools);
454        (runner.with_task_id(task_id), rx)
455    }
456
457    /// Construct a runner that shares a pre-derived cancellation
458    /// token for its turn scopes. Used by `SubagentSpawner` so the
459    /// child runner's work aborts as soon as the parent's `ctx.token`
460    /// fires.
461    pub fn new_child(
462        msg_tx: MsgSender,
463        workdir: PathBuf,
464        providers: Arc<ProviderFactory>,
465        tools: Arc<ToolRegistry>,
466    ) -> Self {
467        // A subagent's runner is never the interactive top-level, so it must
468        // NOT emit OSC 2 terminal-title escapes: in a headless `mermaid run`
469        // the parent suppresses them, but an un-suppressed child leaks
470        // `\x1b]2;…\x07` into stdout and corrupts `--format json`/`text` output.
471        // It must also leave the process-global MCP manager running — the
472        // child shares the parent's servers, and reaping them here would kill
473        // MCP for the whole session the moment the first subagent finished.
474        Self::new(msg_tx, workdir)
475            .with_bindings(providers, tools)
476            .without_terminal_title()
477            .without_global_mcp_shutdown()
478    }
479
480    /// Get or create the scope for a turn. Idempotent. The scope is
481    /// retained until `CancelScope` tears it down or it naturally
482    /// drains.
483    fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
484        self.scopes
485            .entry(turn)
486            .or_insert_with(|| TurnScope::new(turn))
487    }
488
489    /// F38: record a cancelled turn in the bounded tombstone ring, evicting the
490    /// oldest id at capacity. Skips duplicates so a re-cancel doesn't churn the
491    /// ring (membership is all `is_tombstoned` checks).
492    fn tombstone_turn(&mut self, turn: TurnId) {
493        if self.cancelled_turns.contains(&turn) {
494            return;
495        }
496        if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
497            self.cancelled_turns.pop_front();
498        }
499        self.cancelled_turns.push_back(turn);
500    }
501
502    /// F38: true iff `turn`'s scope was cancelled (tombstoned). New turn-scoped
503    /// work for such a turn is dropped rather than spinning up a fresh scope.
504    fn is_tombstoned(&self, turn: TurnId) -> bool {
505        self.cancelled_turns.contains(&turn)
506    }
507
508    /// Drop the scope for a turn, signalling cancellation to every
509    /// child first. Safe to call for non-existent turns.
510    ///
511    /// After the scope is cancelled, a detached task moves it off the
512    /// runner, drains its `JoinSet` (so child tasks unwind), then emits
513    /// `Msg::TurnCancelled(turn)` so the reducer can transition
514    /// `Cancelling → Idle`. Without this terminal event the TUI would
515    /// stick in `Cancelling` — the reducer has no other way to learn
516    /// that the abort fully landed.
517    fn drop_scope(&mut self, turn: TurnId) {
518        // F38: tombstone this turn so a stray post-cancel turn-scoped Cmd can't
519        // resurrect an un-cancelled scope for it. Recorded for both the live and
520        // already-reaped branches below — once cancelled, a turn is dead either
521        // way (turn ids are monotonic and never reused).
522        self.tombstone_turn(turn);
523        if let Some(mut scope) = self.scopes.remove(&turn) {
524            scope.cancel();
525            let tx = self.msg_tx.clone();
526            self.detached.spawn(async move {
527                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
528                    .await
529                    .is_err()
530                {
531                    tracing::warn!(
532                        turn = %turn,
533                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
534                        "cancel drain timed out; aborting remaining scoped tasks"
535                    );
536                }
537                let _ = tx.send(Msg::TurnCancelled(turn)).await;
538            });
539        } else {
540            // The scope was already reaped — its `JoinSet` drained to empty
541            // and `reap_empty_scopes` (top of `dispatch`) removed it before
542            // this cancel landed. The reducer is still in `Cancelling` with
543            // no other way to learn the turn ended, so emit the terminal
544            // event anyway. Idempotent: `handle_turn_cancelled` no-ops on
545            // any turn that isn't currently `Cancelling`.
546            let tx = self.msg_tx.clone();
547            self.detached.spawn(async move {
548                let _ = tx.send(Msg::TurnCancelled(turn)).await;
549            });
550        }
551    }
552
553    /// Number of active per-turn scopes. Tests use this to observe
554    /// lifecycle without racing on internal state.
555    pub fn scope_count(&self) -> usize {
556        self.scopes.len()
557    }
558
559    /// F12: remove scope entries whose `JoinSet` is empty — every
560    /// child task has completed, so the scope is just an orphan key
561    /// in the map. Called at the top of `dispatch` so the map stays
562    /// bounded over long sessions. Cheap: one linear walk, no async.
563    ///
564    /// `JoinSet::is_empty` only returns true after completed tasks are
565    /// harvested via `join_next`/`try_join_next`, so we first drain
566    /// any ready completions per scope.
567    fn reap_empty_scopes(&mut self) {
568        self.reap_detached();
569        self.scopes.retain(|_, scope| {
570            scope.drain_completed();
571            !scope.is_empty()
572        });
573    }
574
575    /// Harvest finished detached tasks. Without this the `detached` JoinSet
576    /// grows for the whole session (every fire-and-forget effect lingers as a
577    /// completed-but-unjoined handle), and a panicking detached task vanishes
578    /// without a trace. Non-blocking — only already-finished tasks are taken (#38).
579    fn reap_detached(&mut self) {
580        while let Some(result) = self.detached.try_join_next() {
581            if let Err(e) = result
582                && !e.is_cancelled()
583            {
584                tracing::warn!(error = %e, "effect: detached task panicked");
585            }
586        }
587    }
588
589    /// Route a single `Cmd` into the appropriate spawn + handler.
590    /// Returns immediately; handlers work asynchronously and emit
591    /// `Msg` back through the sender channel.
592    pub fn dispatch(&mut self, cmd: Cmd) {
593        // F12: reap any drained scopes before touching the map. Keeps
594        // `scope_count()` bounded as the session grows.
595        self.reap_empty_scopes();
596        tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
597
598        // F38: refuse to spawn fresh work for a turn we've already cancelled.
599        // Only the scope-spawning variants carry a `scope_turn()`; `CancelScope`
600        // returns `None` here so a re-cancel still reaches `drop_scope` (which
601        // re-emits the terminal `TurnCancelled` the reducer needs). Turn ids are
602        // monotonic and never reused, so a tombstoned id can only be a stray
603        // post-cancel straggler — dropping it stops `scope_mut`'s `or_insert_with`
604        // from resurrecting an un-cancelled scope.
605        if let Some(turn) = cmd.scope_turn()
606            && self.is_tombstoned(turn)
607        {
608            tracing::debug!(
609                cmd = %cmd.summary(),
610                turn = %turn,
611                "effect: dropping turn-scoped cmd for an already-cancelled turn"
612            );
613            return;
614        }
615
616        match cmd {
617            Cmd::CallModel { turn, mut request } => {
618                let tx = self.msg_tx.clone();
619                let providers = self.providers.clone();
620                // Enrich `request.tools` with every user-facing
621                // tool in the bound registry. The reducer has
622                // already populated MCP tools from `state.mcp`;
623                // built-ins come from the runner (which holds the
624                // registry). This keeps `ChatRequest.tools` the
625                // single source of truth for what the model sees.
626                // Formatting turns (`output_schema`) advertise NO tools —
627                // the reducer already sent none; don't re-add built-ins.
628                if let Some(tools) = &self.tools
629                    && request.output_schema.is_none()
630                {
631                    let mut enriched =
632                        filter_suppressed(tools.describe_all(), &request.suppressed_builtin_tools);
633                    // Report the built-in tool-schema token cost so the
634                    // reducer's /context preview can fold it into its MCP-only
635                    // estimate and agree with what the model actually sees.
636                    // Runs AFTER suppression so the estimate matches reality.
637                    let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
638                    // Best-effort and cosmetic (the /context preview). This is the
639                    // synchronous dispatch path so we can't await; if the bounded
640                    // channel is momentarily full under heavy streaming, log the
641                    // drop rather than swallowing it silently — the estimate just
642                    // stays briefly stale (#F43).
643                    if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
644                        tracing::debug!(
645                            error = %e,
646                            "effect: dropped builtin tool-schema token estimate (channel full); \
647                             /context preview may be briefly stale"
648                        );
649                    }
650                    enriched.append(&mut request.tools);
651                    request.tools = enriched;
652                }
653                // Detached + off the blocking pool: never run a plugin hook on
654                // the synchronous dispatch path (it would freeze input/render).
655                self.detached.spawn(fire_plugin_hooks(
656                    "prompt_submit",
657                    serde_json::json!({
658                        "turn_id": turn.0,
659                        "model_id": request.model_id.clone(),
660                        "message_count": request.messages.len(),
661                        "tool_count": request.tools.len(),
662                    }),
663                ));
664                // Task cost attribution: model dispatch reports each request's
665                // completion tokens into the broker's cumulative counter.
666                let task_usage = self.tasks.clone();
667                let scope = self.scope_mut(turn);
668                let token = scope.token();
669                scope.spawn(async move {
670                    use futures::FutureExt;
671                    let fallback_tx = tx.clone();
672                    if std::panic::AssertUnwindSafe(dispatch_call_model(
673                        tx, providers, turn, request, token, task_usage,
674                    ))
675                    .catch_unwind()
676                    .await
677                    .is_err()
678                    {
679                        // The dispatch task panicked. A turn whose model call
680                        // never emits a terminal Msg stays in `Generating`
681                        // forever; emit one so the reducer can leave that state
682                        // instead of wedging (#43).
683                        tracing::error!(turn = %turn, "dispatch_call_model panicked");
684                        let _ = fallback_tx
685                            .send(Msg::UpstreamError {
686                                turn,
687                                error: crate::models::UserFacingError {
688                                    summary: "Internal error".to_string(),
689                                    message: "The model dispatch task panicked unexpectedly."
690                                        .to_string(),
691                                    suggestion: "This is a bug. Please retry; if it persists, \
692                                                 check the logs."
693                                        .to_string(),
694                                    category: crate::models::ErrorCategory::Internal,
695                                    recoverable: true,
696                                },
697                            })
698                            .await;
699                    }
700                });
701            },
702            Cmd::CompactConversation { turn, mut request } => {
703                let tx = self.msg_tx.clone();
704                let providers = self.providers.clone();
705                if let Some(tools) = &self.tools {
706                    let mut enriched = tools.describe_all();
707                    enriched.append(&mut request.chat.tools);
708                    request.chat.tools = enriched;
709                }
710                // Capture the trigger before `request` moves into the task, so a
711                // panic fallback can still name which compaction failed.
712                let trigger = request.trigger;
713                let scope = self.scope_mut(turn);
714                let token = scope.token();
715                scope.spawn(async move {
716                    use futures::FutureExt;
717                    let fallback_tx = tx.clone();
718                    if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
719                        tx, providers, turn, request, token,
720                    ))
721                    .catch_unwind()
722                    .await
723                    .is_err()
724                    {
725                        // The compaction task panicked. Without a terminal
726                        // `CompactionFinished`/`CompactionFailed`, the reducer
727                        // wedges in `Compacting` until Ctrl+C; emit a failure so
728                        // it can recover, mirroring `CallModel`/`ExecuteTool`
729                        // (#43, F37).
730                        tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
731                        let _ = fallback_tx
732                            .send(Msg::CompactionFailed {
733                                turn,
734                                trigger,
735                                message: "the compaction task panicked unexpectedly".to_string(),
736                                kind: crate::domain::StatusKind::Error,
737                            })
738                            .await;
739                    }
740                });
741            },
742            Cmd::ExecuteTool {
743                turn,
744                call_id,
745                source,
746                model_id,
747                safety_mode,
748                plan_file,
749                plan_permissions,
750                context_percent,
751                intent,
752                session_id,
753                message_index,
754                scratchpad,
755            } => {
756                let tx = self.msg_tx.clone();
757                let tools = self.tools.clone();
758                let workdir = self.workdir.clone();
759                // Pass the shared Config from ProviderFactory so
760                // subagents inherit it (F7). Falls back to
761                // Config::default() when providers aren't bound (unit
762                // tests without real wiring).
763                let config = self
764                    .providers
765                    .as_ref()
766                    .map(|p| Arc::new(p.config().clone()))
767                    .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
768                // Auto mode: build an LLM classifier to vet borderline
769                // actions. Only when a provider is bound (real wiring); the
770                // gate fails safe to "escalate" when it's `None`. The vet
771                // uses the configured classifier model, else the session model.
772                // Plan mode also gets one: profile levels set to `auto`
773                // resolve through `PolicyDecision::Classify`, which fails
774                // safe to escalate without a classifier bound.
775                let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
776                    if safety_mode == crate::runtime::SafetyMode::Auto || plan_file.is_some() {
777                        self.providers.as_ref().map(|p| {
778                            let model = config
779                                .safety
780                                .auto_classifier_model
781                                .clone()
782                                .unwrap_or_else(|| model_id.clone());
783                            Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
784                                as Arc<dyn crate::providers::AutoClassifier>
785                        })
786                    } else {
787                        None
788                    };
789                let task_id = self.task_id.clone();
790                let approval = self.approval.clone();
791                let questions = self.questions.clone();
792                let task_broker = self.tasks.clone();
793                let scope = self.scope_mut(turn);
794                let token = scope.token();
795                let background = scope.background_token();
796                let web_bytes = scope.web_bytes();
797                scope.spawn(async move {
798                    use futures::FutureExt;
799                    let fallback_tx = tx.clone();
800                    if std::panic::AssertUnwindSafe(dispatch_execute_tool(
801                        tx,
802                        tools,
803                        workdir,
804                        turn,
805                        call_id,
806                        source,
807                        token,
808                        background,
809                        web_bytes,
810                        config,
811                        model_id,
812                        task_id,
813                        session_id,
814                        message_index,
815                        scratchpad,
816                        safety_mode,
817                        plan_file,
818                        plan_permissions,
819                        context_percent,
820                        intent,
821                        classifier,
822                        approval,
823                        questions,
824                        task_broker,
825                    ))
826                    .catch_unwind()
827                    .await
828                    .is_err()
829                    {
830                        // The tool task panicked. Its turn waits on a
831                        // `ToolFinished` for this `call_id` that will now never
832                        // arrive; emit a terminal error outcome so the turn
833                        // doesn't wedge (#43).
834                        tracing::error!(
835                            turn = %turn,
836                            call_id = call_id.0,
837                            "dispatch_execute_tool panicked"
838                        );
839                        let _ = fallback_tx
840                            .send(Msg::ToolFinished {
841                                turn,
842                                call_id,
843                                outcome: crate::domain::ToolOutcome::error(
844                                    "internal error: the tool execution task panicked".to_string(),
845                                    0.0,
846                                ),
847                            })
848                            .await;
849                    }
850                });
851            },
852            Cmd::ResolveApproval { call_id, decision } => {
853                // Deliver the user's inline decision to the parked tool task.
854                // Not turn-scoped — fire-and-forget to the broker.
855                if let Some(broker) = &self.approval {
856                    broker.resolve(call_id, decision.into());
857                }
858            },
859            Cmd::ResolveQuestion {
860                call_id,
861                resolution,
862            } => {
863                // Deliver the user's answers to the parked ask_user_question
864                // task. Not turn-scoped — fire-and-forget to the broker.
865                if let Some(broker) = &self.questions {
866                    broker.resolve(call_id, resolution);
867                }
868            },
869            Cmd::SyncTaskStore(store) => {
870                // Reducer-initiated truth overwrite (rewind/fork, /clear,
871                // startup resume). Synchronous; the broker does not publish
872                // back — the reducer already holds this store.
873                self.tasks.seed(store);
874            },
875            Cmd::EnsureScratchpad { session_id } => {
876                let tx = self.msg_tx.clone();
877                let workdir = self.workdir.clone();
878                self.detached.spawn(async move {
879                    match crate::session::scratchpad::ensure(&workdir, &session_id) {
880                        Ok(path) => {
881                            let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
882                        },
883                        Err(err) => {
884                            // Non-fatal: the session runs without a scratch
885                            // dir (`Session::scratchpad` stays `None`).
886                            tracing::warn!(error = %err, "failed to create session scratchpad");
887                        },
888                    }
889                    // Best-effort reap of unlocked scratchpads past retention —
890                    // piggybacks on session startup, no separate timer.
891                    if let Err(err) = crate::session::scratchpad::sweep_stale(
892                        crate::session::scratchpad::RETENTION_DAYS,
893                    ) {
894                        tracing::warn!(error = %err, "scratchpad sweep failed");
895                    }
896                });
897            },
898            Cmd::ListScratchpad { path } => {
899                // `/scratchpad` — bounded directory listing back into the
900                // transcript. Blocking filesystem walk, so off the runner.
901                let tx = self.msg_tx.clone();
902                self.detached.spawn(async move {
903                    let text = tokio::task::spawn_blocking(move || {
904                        crate::session::scratchpad::list_text(&path)
905                    })
906                    .await
907                    .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
908                    let _ = tx.send(Msg::RuntimeText(text)).await;
909                });
910            },
911            Cmd::UserTaskEdit(edit) => {
912                // Route the user's /tasks edit through the broker (single
913                // writer) so it serializes with any in-flight tool call. The
914                // broker publishes the resulting snapshot; the outcome line
915                // lands in the transcript as transient status.
916                let broker = self.tasks.clone();
917                let tx = self.msg_tx.clone();
918                self.detached.spawn(async move {
919                    let (line, _snapshot) = broker.user_edit(edit).await;
920                    // The user sees the ack in the transcript; the model
921                    // learns about it on its next request via the notice
922                    // buffer (a checklist the model believes in but the user
923                    // has edited is the worst of both).
924                    let _ = tx
925                        .send(Msg::TaskNotice {
926                            text: format!(
927                                "The user edited the task checklist: {line}. Acknowledge and \
928                                 incorporate this into your plan."
929                            ),
930                        })
931                        .await;
932                    let _ = tx.send(Msg::TransientStatus { text: line }).await;
933                });
934            },
935            Cmd::NotifyTaskCompleted {
936                task,
937                completed,
938                total,
939            } => {
940                // Gated `task_completed` plugin hook: a denying hook VETOES
941                // the completion — the task flips back to in_progress via the
942                // broker (single writer; the publish refreshes the band) and
943                // the reason reaches both the user (transcript) and the model
944                // (notice buffer). Fail-open like every plugin hook: no
945                // enabled hooks / timeout => allow, zero latency added
946                // elsewhere because this runs detached.
947                let payload = serde_json::json!({
948                    "task_id": task.id,
949                    "subject": task.subject,
950                    "description": task.description,
951                    "evidence": task.evidence,
952                    "completed": completed,
953                    "total": total,
954                });
955                let broker = self.tasks.clone();
956                let tx = self.msg_tx.clone();
957                self.detached.spawn(async move {
958                    let gate = run_plugin_hooks_gated("task_completed", payload).await;
959                    let Some((plugin, reason)) = gate.deny else {
960                        return;
961                    };
962                    let reason = crate::utils::redact_secrets(&reason);
963                    let _ = broker
964                        .update(vec![crate::domain::TaskEdit {
965                            id: task.id,
966                            status: Some(crate::domain::TaskStatus::InProgress),
967                            ..crate::domain::TaskEdit::default()
968                        }])
969                        .await;
970                    let _ = tx
971                        .send(Msg::TaskNotice {
972                            text: format!(
973                                "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
974                                 {reason}. The task is back in_progress; address the reason \
975                                 before completing it again.",
976                                task.id, task.subject
977                            ),
978                        })
979                        .await;
980                    let _ = tx
981                        .send(Msg::TransientStatus {
982                            text: format!(
983                                "task #{} completion vetoed by {plugin}: {reason}",
984                                task.id
985                            ),
986                        })
987                        .await;
988                });
989            },
990            Cmd::CancelScope(turn) => {
991                self.drop_scope(turn);
992            },
993            Cmd::BackgroundScope(turn) => {
994                // Fire the scope's background token (don't drop the scope):
995                // detachable tools move their child to a background process and
996                // return a normal outcome, so the turn finishes naturally.
997                self.scope_mut(turn).background();
998            },
999            Cmd::SaveConversation(history) => {
1000                self.queue_persistence(PersistenceJob::Conversation(Box::new(history)));
1001            },
1002            Cmd::SaveCompactionArchive {
1003                archive,
1004                record,
1005                conversation,
1006            } => {
1007                self.queue_persistence(PersistenceJob::Compaction(Box::new(
1008                    PendingCompactionSave {
1009                        archive,
1010                        record,
1011                        conversation,
1012                        task_id: self.task_id.clone(),
1013                    },
1014                )));
1015            },
1016            Cmd::SaveProcess(process) => {
1017                let task_id = self.task_id.clone();
1018                self.detached.spawn(async move {
1019                    let status = match process.status {
1020                        crate::domain::ManagedProcessStatus::Running => {
1021                            crate::runtime::ProcessStatus::Running
1022                        },
1023                        crate::domain::ManagedProcessStatus::Exited => {
1024                            crate::runtime::ProcessStatus::Exited
1025                        },
1026                        crate::domain::ManagedProcessStatus::Unknown => {
1027                            crate::runtime::ProcessStatus::Unknown
1028                        },
1029                    };
1030                    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1031                        let _ = store.processes().upsert(crate::runtime::NewProcess {
1032                            id: Some(process.id),
1033                            task_id,
1034                            pid: process.pid,
1035                            command: process.command,
1036                            cwd: process.cwd,
1037                            log_path: Some(process.log_path),
1038                            detected_url: process.detected_url,
1039                            status,
1040                            health: None,
1041                        });
1042                    }
1043                });
1044            },
1045            Cmd::PersistPlanConfig(plan) => {
1046                self.detached.spawn(async move {
1047                    if let Err(err) = crate::app::persist_plan_config(&plan) {
1048                        tracing::warn!(error = %err, "failed to persist [plan] config");
1049                    }
1050                });
1051            },
1052            Cmd::PersistLastModel(model) => {
1053                self.detached.spawn(async move {
1054                    if let Err(err) = crate::app::persist_last_model(&model) {
1055                        tracing::warn!(error = %err, "failed to persist last-used model");
1056                    }
1057                });
1058            },
1059            Cmd::PersistReasoningFor { model_id, level } => {
1060                self.detached.spawn(async move {
1061                    if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1062                        tracing::warn!(error = %err, "failed to persist reasoning level for model");
1063                    }
1064                });
1065            },
1066            Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1067                self.detached.spawn(async move {
1068                    if let Err(err) =
1069                        crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1070                    {
1071                        tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1072                    }
1073                });
1074            },
1075            Cmd::PersistOllamaOffload(enabled) => {
1076                self.detached.spawn(async move {
1077                    if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1078                        tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1079                    }
1080                });
1081            },
1082            Cmd::PersistUiTheme(theme) => {
1083                self.detached.spawn(async move {
1084                    if let Err(err) = crate::app::persist_ui_theme(theme) {
1085                        tracing::warn!(error = %err, "failed to persist theme");
1086                    }
1087                });
1088            },
1089            Cmd::ComposeInEditor { .. } => {
1090                // Run-loop-intercepted in the interactive TUI (it owns the
1091                // terminal + event stream). Reaching the effect runner means a
1092                // headless driver emitted it — nothing to suspend there.
1093                tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1094            },
1095            Cmd::ListMemory => {
1096                let tx = self.msg_tx.clone();
1097                let workdir = self.workdir.clone();
1098                self.detached.spawn(async move {
1099                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1100                    let text = match crate::app::memory::load(&workdir, &cfg) {
1101                        Some(mem) => mem.index,
1102                        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(),
1103                    };
1104                    let _ = tx.send(Msg::RuntimeText(text)).await;
1105                });
1106            },
1107            Cmd::RememberMemory { text } => {
1108                let tx = self.msg_tx.clone();
1109                let workdir = self.workdir.clone();
1110                self.detached.spawn(async move {
1111                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1112                    let name = memory_title_from_text(&text);
1113                    let status = match crate::app::memory::write_memory(
1114                        &workdir,
1115                        crate::app::memory::MemoryScope::ProjectPrivate,
1116                        &name,
1117                        &text,
1118                        &[],
1119                        &text,
1120                    ) {
1121                        Ok(_) => format!("Remembered: {name}"),
1122                        Err(e) => format!("Couldn't save memory: {e}"),
1123                    };
1124                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1125                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1126                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
1127                });
1128            },
1129            Cmd::ForgetMemory { id } => {
1130                let tx = self.msg_tx.clone();
1131                let workdir = self.workdir.clone();
1132                self.detached.spawn(async move {
1133                    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1134                    let status = match crate::app::memory::delete_memory(&workdir, &id) {
1135                        Ok(Some(_)) => format!("Forgot: {id}"),
1136                        Ok(None) => format!("No memory named '{id}'"),
1137                        Err(e) => format!("Couldn't forget memory: {e}"),
1138                    };
1139                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1140                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1141                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
1142                });
1143            },
1144            Cmd::ConsolidateMemory { model_id } => {
1145                let tx = self.msg_tx.clone();
1146                let workdir = self.workdir.clone();
1147                let providers = self.providers.clone();
1148                self.detached.spawn(async move {
1149                    consolidate_memory(tx, providers, workdir, model_id).await;
1150                });
1151            },
1152            Cmd::LoadConversation(id) => {
1153                let tx = self.msg_tx.clone();
1154                let workdir = self.workdir.clone();
1155                self.detached.spawn(async move {
1156                    match crate::session::ConversationManager::new(&workdir) {
1157                        Ok(mgr) => match mgr.load_conversation(&id) {
1158                            Ok(history) => {
1159                                let _ = tx.send(Msg::ConversationLoaded(history)).await;
1160                            },
1161                            Err(e) => {
1162                                tracing::warn!(id = %id, error = %e, "LoadConversation failed");
1163                            },
1164                        },
1165                        Err(e) => {
1166                            tracing::warn!(error = %e, "ConversationManager init failed");
1167                        },
1168                    }
1169                });
1170            },
1171            Cmd::ListConversations => {
1172                let tx = self.msg_tx.clone();
1173                let workdir = self.workdir.clone();
1174                self.detached.spawn(async move {
1175                    let summaries = match crate::session::ConversationManager::new(&workdir) {
1176                        Ok(mgr) => mgr
1177                            .list_conversation_metas()
1178                            .unwrap_or_default()
1179                            .into_iter()
1180                            .map(|m| crate::domain::ConversationSummary {
1181                                id: m.id,
1182                                title: m.title,
1183                                message_count: m.message_count,
1184                                updated_at: m.updated_at.to_rfc3339(),
1185                            })
1186                            .collect(),
1187                        Err(_) => Vec::new(),
1188                    };
1189                    let _ = tx.send(Msg::ConversationsListed(summaries)).await;
1190                });
1191            },
1192            Cmd::ListProjectFiles => {
1193                let tx = self.msg_tx.clone();
1194                let workdir = self.workdir.clone();
1195                // Filesystem walk — blocking pool, like the other sync I/O.
1196                self.detached.spawn_blocking(move || {
1197                    let files = walk_project_files(&workdir);
1198                    let _ = tx.blocking_send(Msg::ProjectFilesListed(files));
1199                });
1200            },
1201            Cmd::ListRuntimeTasks { limit } => {
1202                let tx = self.msg_tx.clone();
1203                // Synchronous rusqlite read — run on the blocking pool so it
1204                // never stalls an async worker thread (#40).
1205                self.detached.spawn_blocking(move || {
1206                    let tasks = crate::runtime::RuntimeClient::auto()
1207                        .list_tasks(limit)
1208                        .map(|read| read.value)
1209                        .unwrap_or_default();
1210                    let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
1211                });
1212            },
1213            Cmd::LoadRuntimeTask { id } => {
1214                let tx = self.msg_tx.clone();
1215                self.detached.spawn_blocking(move || {
1216                    let (task, events) = crate::runtime::RuntimeClient::auto()
1217                        .task_detail(&id)
1218                        .map(|read| (Some(read.value.task), read.value.events))
1219                        .unwrap_or((None, Vec::new()));
1220                    let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
1221                });
1222            },
1223            Cmd::ListRuntimeProcesses { limit } => {
1224                let tx = self.msg_tx.clone();
1225                self.detached.spawn_blocking(move || {
1226                    let processes = crate::runtime::RuntimeClient::auto()
1227                        .list_processes(limit)
1228                        .map(|read| read.value)
1229                        .unwrap_or_default();
1230                    let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
1231                });
1232            },
1233            Cmd::ShowRuntimeProcessLogs { id } => {
1234                let tx = self.msg_tx.clone();
1235                self.detached.spawn_blocking(move || {
1236                    let text = crate::runtime::RuntimeClient::auto()
1237                        .process_log(&id, None)
1238                        .map(|log| format!("Process log {}\n\n{}", id, log.content))
1239                        .unwrap_or_else(|err| format!("Process log error: {}", err));
1240                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1241                });
1242            },
1243            Cmd::StopRuntimeProcess { id } => {
1244                let tx = self.msg_tx.clone();
1245                self.detached.spawn_blocking(move || {
1246                    let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
1247                        Ok(response) => Msg::TransientStatus {
1248                            text: format!("Stopped process {} (pid {})", id, response.item.pid),
1249                        },
1250                        Err(err) => Msg::TransientStatus {
1251                            text: format!("Process stop failed: {}", err),
1252                        },
1253                    };
1254                    let _ = tx.blocking_send(msg);
1255                });
1256            },
1257            Cmd::KillBackgroundAgent { agent_id } => {
1258                // Synchronous token fire — no task to spawn. Feedback flows
1259                // through the dying child's `Msg::BackgroundAgentFinished`
1260                // (the reducer already validated the id against its registry).
1261                let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1262                if let Some(spawner) = spawner {
1263                    match agent_id {
1264                        Some(id) => {
1265                            spawner.kill_detached(&id);
1266                        },
1267                        None => {
1268                            spawner.kill_all_detached();
1269                        },
1270                    }
1271                }
1272            },
1273            Cmd::RestartRuntimeProcess { id } => {
1274                let tx = self.msg_tx.clone();
1275                self.detached.spawn_blocking(move || {
1276                    let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
1277                        Ok(response) => Msg::TransientStatus {
1278                            text: format!("Restarted process {} (pid {})", id, response.item.pid),
1279                        },
1280                        Err(err) => Msg::TransientStatus {
1281                            text: format!("Process restart failed: {}", err),
1282                        },
1283                    };
1284                    let _ = tx.blocking_send(msg);
1285                });
1286            },
1287            Cmd::OpenRuntimeTarget { target } => {
1288                self.detached.spawn_blocking(move || {
1289                    let resolved = crate::runtime::RuntimeService::open_default()
1290                        .and_then(|service| service.resolve_open_target(&target))
1291                        .unwrap_or(target);
1292                    // #63: the resolved value can be a `detected_url`/`log_path`
1293                    // from a `processes` row — validate before the OS opener,
1294                    // exactly like `open_process`.
1295                    if let Err(err) = crate::runtime::validate_open_target(&resolved) {
1296                        tracing::warn!(error = %err, "refusing to open runtime target");
1297                        return;
1298                    }
1299                    crate::utils::open_file(resolved);
1300                });
1301            },
1302            Cmd::ShowRuntimePorts => {
1303                let tx = self.msg_tx.clone();
1304                self.detached.spawn_blocking(move || {
1305                    let text = crate::runtime::RuntimeClient::auto()
1306                        .ports()
1307                        .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1308                        .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
1309                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1310                });
1311            },
1312            Cmd::ListRuntimeApprovals => {
1313                let tx = self.msg_tx.clone();
1314                self.detached.spawn_blocking(move || {
1315                    let approvals = crate::runtime::RuntimeClient::auto()
1316                        .list_approvals()
1317                        .map(|read| read.value)
1318                        .unwrap_or_default();
1319                    let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
1320                });
1321            },
1322            Cmd::DecideRuntimeApproval { id, decision } => {
1323                let tx = self.msg_tx.clone();
1324                self.detached.spawn_blocking(move || {
1325                    let result = if decision == "approved" {
1326                        crate::runtime::RuntimeClient::auto().approve(&id)
1327                    } else {
1328                        crate::runtime::RuntimeClient::auto().deny(&id)
1329                    };
1330                    let msg = match result {
1331                        Ok(result) => Msg::TransientStatus {
1332                            text: if result.replayed {
1333                                format!("Approval {} {}: {}", id, decision, result.summary)
1334                            } else {
1335                                format!("Approval {} {}", id, decision)
1336                            },
1337                        },
1338                        Err(err) => Msg::TransientStatus {
1339                            text: format!("Approval update failed: {}", err),
1340                        },
1341                    };
1342                    let _ = tx.blocking_send(msg);
1343                });
1344            },
1345            Cmd::ListRuntimeCheckpoints { limit } => {
1346                let tx = self.msg_tx.clone();
1347                self.detached.spawn_blocking(move || {
1348                    let checkpoints = crate::runtime::RuntimeClient::auto()
1349                        .list_checkpoints(limit)
1350                        .map(|read| read.value)
1351                        .unwrap_or_default();
1352                    let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
1353                });
1354            },
1355            Cmd::ListForkCheckpoints {
1356                session_id,
1357                message_index,
1358            } => {
1359                let tx = self.msg_tx.clone();
1360                self.detached.spawn_blocking(move || {
1361                    let checkpoints = crate::runtime::RuntimeStore::open_default()
1362                        .and_then(|store| {
1363                            store
1364                                .checkpoints()
1365                                .list_for_session(&session_id, message_index as i64)
1366                        })
1367                        .unwrap_or_default();
1368                    let _ = tx.blocking_send(Msg::ForkCheckpointsFound(checkpoints));
1369                });
1370            },
1371            Cmd::ListRuntimePlugins => {
1372                let tx = self.msg_tx.clone();
1373                self.detached.spawn_blocking(move || {
1374                    let plugins = crate::runtime::RuntimeClient::auto()
1375                        .list_plugins()
1376                        .map(|read| read.value)
1377                        .unwrap_or_default();
1378                    let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
1379                });
1380            },
1381            Cmd::UpdateRuntimeTaskStatus {
1382                id,
1383                status,
1384                final_report,
1385            } => {
1386                let tx = self.msg_tx.clone();
1387                self.detached.spawn_blocking(move || {
1388                    let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
1389                        store
1390                            .tasks()
1391                            .update_status(&id, status, final_report.as_deref())
1392                    }) {
1393                        Ok(()) => Msg::TransientStatus {
1394                            text: format!("Task {} -> {}", id, status),
1395                        },
1396                        Err(err) => Msg::TransientStatus {
1397                            text: format!("Task update failed: {}", err),
1398                        },
1399                    };
1400                    let _ = tx.blocking_send(msg);
1401                });
1402            },
1403            Cmd::CreateRuntimeCheckpoint { paths } => {
1404                let tx = self.msg_tx.clone();
1405                let workdir = self.workdir.clone();
1406                self.detached.spawn_blocking(move || {
1407                    let pending_action = Some(serde_json::json!({
1408                        "source": "tui",
1409                        "command": "checkpoint",
1410                    }));
1411                    let msg =
1412                        match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
1413                            Ok(manifest) => Msg::TransientStatus {
1414                                text: format!(
1415                                    "Checkpoint {} created for {} path(s)",
1416                                    manifest.id,
1417                                    manifest.files.len()
1418                                ),
1419                            },
1420                            Err(err) => Msg::TransientStatus {
1421                                text: format!("Checkpoint failed: {}", err),
1422                            },
1423                        };
1424                    let _ = tx.blocking_send(msg);
1425                });
1426            },
1427            Cmd::RestoreRuntimeCheckpoint { id } => {
1428                let tx = self.msg_tx.clone();
1429                self.detached.spawn_blocking(move || {
1430                    let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1431                        Ok(result) => Msg::TransientStatus {
1432                            text: format!(
1433                                "Restored checkpoint {} ({} file(s)){}",
1434                                result.checkpoint.id,
1435                                result.checkpoint.files.len(),
1436                                if result.checkpoint.pending_action.is_some() {
1437                                    "; pending action available in checkpoint manifest"
1438                                } else {
1439                                    ""
1440                                }
1441                            ),
1442                        },
1443                        Err(err) => Msg::TransientStatus {
1444                            text: format!("Restore failed: {}", err),
1445                        },
1446                    };
1447                    let _ = tx.blocking_send(msg);
1448                });
1449            },
1450            Cmd::ShowRuntimeModelInfo { model } => {
1451                let tx = self.msg_tx.clone();
1452                self.detached.spawn_blocking(move || {
1453                    let text = runtime_model_info_text(&model);
1454                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1455                });
1456            },
1457            Cmd::InitMcpServers(configs) => {
1458                let tx = self.msg_tx.clone();
1459                self.detached
1460                    .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1461            },
1462            Cmd::StopMcpServer { name } => {
1463                let tx = self.msg_tx.clone();
1464                self.detached.spawn(async move {
1465                    // Actually kill the child before claiming it's stopped —
1466                    // otherwise the UI says "stopped" while the server runs on.
1467                    if let Some(mgr) = crate::mcp::manager_ref::get() {
1468                        mgr.stop_server(&name).await;
1469                    }
1470                    let _ = tx.send(Msg::McpServerStopped { name }).await;
1471                });
1472            },
1473            Cmd::PullOllamaModel { model } => {
1474                let tx = self.msg_tx.clone();
1475                self.detached.spawn(async move {
1476                    dispatch_pull_ollama_model(tx, model).await;
1477                });
1478            },
1479            Cmd::OpenInSystem(path) => {
1480                self.detached.spawn(async move {
1481                    let _ = tokio::task::spawn_blocking(move || {
1482                        crate::utils::open_file(&path);
1483                    })
1484                    .await;
1485                });
1486            },
1487            Cmd::WriteImageToTemp {
1488                path,
1489                bytes,
1490                format: _,
1491            } => {
1492                self.detached.spawn(async move {
1493                    if let Err(e) = tokio::fs::write(&path, &bytes).await {
1494                        tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1495                    }
1496                });
1497            },
1498            Cmd::ReadClipboard => {
1499                let tx = self.msg_tx.clone();
1500                self.detached.spawn(async move {
1501                    dispatch_read_clipboard(tx).await;
1502                });
1503            },
1504            Cmd::ProbeVision { model_id, warn } => {
1505                let tx = self.msg_tx.clone();
1506                let providers = self.providers.clone();
1507                self.detached.spawn(async move {
1508                    dispatch_probe_vision(model_id, warn, providers, tx).await;
1509                });
1510            },
1511            Cmd::CopyToClipboard(text) => {
1512                let tx = self.msg_tx.clone();
1513                self.detached.spawn(async move {
1514                    dispatch_copy_to_clipboard(text, tx).await;
1515                });
1516            },
1517            Cmd::Exit => {
1518                // The main loop observes `state.should_exit` after
1519                // the reducer returns; the runner doesn't need to
1520                // take any special action. Documented here for
1521                // exhaustiveness.
1522            },
1523            Cmd::SetTerminalTitle(title) => {
1524                if !self.terminal_title_enabled {
1525                    return;
1526                }
1527                // Offload the terminal write to the blocking pool: writing to
1528                // stdout can block when the terminal (or a downstream pipe) is
1529                // slow, and an async worker must not block on it (#44). The
1530                // OSC-2 title sequence is out-of-band relative to the renderer's
1531                // frame draws, so it doesn't corrupt them.
1532                self.detached.spawn_blocking(move || {
1533                    use std::io::Write;
1534                    let seq = format!("\x1b]2;{}\x07", title);
1535                    let mut stdout = std::io::stdout();
1536                    let _ = stdout.write_all(seq.as_bytes());
1537                    let _ = stdout.flush();
1538                });
1539            },
1540            Cmd::AlertUser => {
1541                if !self.terminal_title_enabled {
1542                    return;
1543                }
1544                // A single BEL nudges the terminal to alert (dock bounce / tab
1545                // highlight). Offloaded to the blocking pool like the title.
1546                self.detached.spawn_blocking(|| {
1547                    use std::io::Write;
1548                    let mut stdout = std::io::stdout();
1549                    let _ = stdout.write_all(b"\x07");
1550                    let _ = stdout.flush();
1551                });
1552            },
1553        }
1554    }
1555
1556    fn queue_persistence(&mut self, job: PersistenceJob) {
1557        let previous = self.persistence_tail.take();
1558        let state = Arc::clone(&self.persistence_state);
1559        let tx = self.msg_tx.clone();
1560        self.persistence_tail = Some(tokio::spawn(async move {
1561            if let Some(previous) = previous
1562                && let Err(error) = previous.await
1563            {
1564                tracing::warn!(error = %error, "previous persistence job panicked");
1565            }
1566
1567            let result = tokio::task::spawn_blocking(move || {
1568                state
1569                    .lock()
1570                    .unwrap_or_else(|error| error.into_inner())
1571                    .process(job)
1572            })
1573            .await;
1574
1575            match result {
1576                Ok((events, outcome)) => {
1577                    // Events report durable writes even when the job as a
1578                    // whole failed — a partially drained barrier already
1579                    // persisted those archives, and they are never re-emitted.
1580                    if outcome.is_ok() || !events.is_empty() {
1581                        let _ = tx.send(Msg::SessionSaved).await;
1582                    }
1583                    for event in events {
1584                        fire_compaction_hook(&event).await;
1585                    }
1586                    if let Err(error) = outcome {
1587                        tracing::warn!(
1588                            error = %error,
1589                            "persistence job failed; compaction barriers remain queued"
1590                        );
1591                    }
1592                },
1593                Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1594            }
1595        }));
1596    }
1597
1598    /// Async shutdown: cancel every scope, then wait for all spawned
1599    /// work to drain. Bounded by 5 seconds — a hung task past that
1600    /// gets aborted outright by `JoinSet::drop`.
1601    pub async fn shutdown(mut self) {
1602        for (id, scope) in self.scopes.iter() {
1603            tracing::debug!(turn = %id, "shutdown: cancelling scope");
1604            scope.cancel();
1605        }
1606
1607        // The config watcher (#45) is a perpetual loop in `detached`; abort it
1608        // so the drain below doesn't block on it until the bounded timeout.
1609        if let Some(handle) = self.config_watch.take() {
1610            handle.abort();
1611        }
1612
1613        // Drain with a bounded timeout.
1614        let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1615
1616        let owns_global_mcp = self.owns_global_mcp;
1617        let persistence_tail = self.persistence_tail.take();
1618        let persistence_state = Arc::clone(&self.persistence_state);
1619        let drain = async {
1620            if let Some(tail) = persistence_tail
1621                && let Err(error) = tail.await
1622            {
1623                tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1624            }
1625            match tokio::task::spawn_blocking(move || {
1626                persistence_state
1627                    .lock()
1628                    .unwrap_or_else(|error| error.into_inner())
1629                    .retry_all_blocked()
1630            })
1631            .await
1632            {
1633                Ok((events, outcome)) => {
1634                    // A barrier drained at shutdown still owes its hooks —
1635                    // these events are never re-emitted.
1636                    for event in events {
1637                        fire_compaction_hook(&event).await;
1638                    }
1639                    if let Err(error) = outcome {
1640                        tracing::warn!(
1641                            error = %error,
1642                            "shutdown: compaction persistence barrier retry failed"
1643                        );
1644                    }
1645                },
1646                Err(error) => tracing::warn!(
1647                    error = %error,
1648                    "shutdown: compaction persistence barrier panicked"
1649                ),
1650            }
1651            // Only the top-level runner reaps the process-global MCP manager.
1652            // A subagent's child runner shares it; reaping here would kill the
1653            // parent's servers the moment the first subagent finished.
1654            if owns_global_mcp {
1655                // If an MCP init is still in flight, its child processes are
1656                // already spawned but `set_manager` hasn't run yet — `get()`
1657                // below would return `None` and we'd leak those children. Wait
1658                // (bounded) for init to settle so the manager is installed
1659                // before we reap it (#59).
1660                let _ = tokio::time::timeout(
1661                    std::time::Duration::from_secs(2),
1662                    crate::mcp::manager_ref::wait_ready(),
1663                )
1664                .await;
1665                // Gracefully shut down MCP server children (the stdin-EOF →
1666                // terminate → kill ladder in `McpServerManager::shutdown`). The
1667                // manager lives in a `'static OnceLock` that never drops, so
1668                // this explicit call on the exit path is the only thing that
1669                // reaps those child processes. No-op when no servers were
1670                // configured.
1671                if let Some(mgr) = crate::mcp::manager_ref::get() {
1672                    mgr.shutdown().await;
1673                }
1674                // Tear down the auto-managed SearXNG process (zero-config
1675                // web_search). Same ownership rule as MCP: only the top-level
1676                // runner reaps process-global services. No-op if none started.
1677                crate::searxng::shutdown().await;
1678            }
1679            // F42: bound each per-scope drain so one non-cooperative task can't
1680            // eat the whole shutdown budget and starve the remaining scopes'
1681            // drains (the scopes were all cancelled above, so a well-behaved task
1682            // unwinds well within this). On timeout, dropping `scope` aborts its
1683            // still-running `JoinSet` members via `TurnScope::drop`.
1684            for (id, mut scope) in self.scopes.drain() {
1685                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1686                    .await
1687                    .is_err()
1688                {
1689                    tracing::warn!(
1690                        turn = %id,
1691                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1692                        "shutdown: scope drain timed out; aborting its remaining tasks"
1693                    );
1694                }
1695            }
1696            while let Some(result) = self.detached.join_next().await {
1697                if let Err(e) = result
1698                    && !e.is_cancelled()
1699                {
1700                    tracing::warn!(error = %e, "shutdown: detached task panic");
1701                }
1702            }
1703        };
1704
1705        let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1706    }
1707}
1708
1709/// Dispatch a `CallModel` command. Resolves the provider (lazy,
1710/// cached) and streams its events onto the Msg channel. Without a
1711/// bound `ProviderFactory` (unit tests), emits a single
1712/// `UpstreamError` so the reducer ends the turn cleanly.
1713/// Report a completed request's completion tokens into the task broker's
1714/// cumulative counter, so task cost deltas (`tokens_spent`) can be computed
1715/// between in_progress and completed stamps.
1716fn note_stream_usage(
1717    tasks: &crate::providers::TaskBroker,
1718    usage: &Option<crate::models::TokenUsage>,
1719) {
1720    if let Some(usage) = usage {
1721        tasks.add_tokens(usage.completion_tokens as u64);
1722    }
1723}
1724
1725/// Drop the built-in tool definitions the reducer suppressed for this request
1726/// (`ChatRequest::suppressed_builtin_tools` — e.g. the task-checklist writers
1727/// while a plan is being drafted). Pure so it unit-tests without the runner.
1728fn filter_suppressed(
1729    tools: Vec<crate::domain::ToolDefinition>,
1730    suppressed: &[&'static str],
1731) -> Vec<crate::domain::ToolDefinition> {
1732    if suppressed.is_empty() {
1733        return tools;
1734    }
1735    tools
1736        .into_iter()
1737        .filter(|t| !suppressed.contains(&t.name.as_str()))
1738        .collect()
1739}
1740
1741async fn dispatch_call_model(
1742    msg_tx: MsgSender,
1743    providers: Option<Arc<ProviderFactory>>,
1744    turn: TurnId,
1745    mut request: crate::domain::ChatRequest,
1746    token: tokio_util::sync::CancellationToken,
1747    tasks: crate::providers::TaskBroker,
1748) {
1749    use crate::models::UserFacingError;
1750
1751    let Some(factory) = providers else {
1752        let error = UserFacingError {
1753            summary: "not wired".to_string(),
1754            message: "EffectRunner has no ProviderFactory bound".to_string(),
1755            suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1756            category: crate::models::ErrorCategory::Internal,
1757            recoverable: false,
1758        };
1759        let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1760        return;
1761    };
1762
1763    // Lazily resolve the provider for this model.
1764    let provider = match factory.resolve(&request.model_id).await {
1765        Ok(p) => p,
1766        Err(e) => {
1767            let error = classify_error_for_ui(&e);
1768            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1769            return;
1770        },
1771    };
1772    {
1773        // Telemetry write — offload the synchronous DB upserts to the blocking
1774        // pool so they never stall this model-call dispatch path, which runs on
1775        // every turn (#39).
1776        let model_id = request.model_id.clone();
1777        let caps = provider.capabilities().clone();
1778        // Own this telemetry write inside the per-turn task (await it) instead of
1779        // a detached `spawn_blocking` whose handle was dropped — so a panic in the
1780        // upsert surfaces and shutdown isn't racing an untracked DB write (#F41).
1781        // It is a few-ms SQLite upsert before a multi-second model call, so
1782        // awaiting it here does not meaningfully stall the turn (the "never stall
1783        // dispatch" rule is about the synchronous reducer path, not this task).
1784        if let Err(e) =
1785            tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
1786                .await
1787        {
1788            tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
1789        }
1790    }
1791    if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1792        let _ = msg_tx
1793            .send(Msg::TransientStatus {
1794                text: format!(
1795                    "{} does not advertise tool support; Mermaid will send the turn without tools",
1796                    request.model_id
1797                ),
1798            })
1799            .await;
1800        request.tools.clear();
1801    }
1802
1803    // Resolve the *effective* context window. For Ollama this probes the model's
1804    // real window and auto-fits num_ctx to memory (cache-first, off the UI
1805    // thread); for other providers it's the static advertised window. Using the
1806    // effective value here is what un-skips auto-compaction for Ollama (which had
1807    // `NoKnownContextLimit`) and gives the status bar real numbers.
1808    let sizing = provider.resolve_context_window(&request).await;
1809    let max_context_tokens = sizing.effective.or_else(|| {
1810        crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1811    });
1812    // Ride the discovered limits on the request itself so adapters size
1813    // `max_tokens` against the model's REAL window/ceiling (Anthropic
1814    // requires a concrete max_tokens; sizing it from a stale table either
1815    // wastes the ceiling or 400s). Set before the auto-compaction block so
1816    // `CompactionRequest::auto` inherits them for its summary calls.
1817    request.resolved_context_window = sizing.effective.or(sizing.model_max);
1818    request.resolved_max_output = sizing.max_output;
1819    // Report the resolved window to the reducer for the `/context` display +
1820    // truncation quick-fix. Harmless for non-Ollama (source is None → no extra
1821    // detail shown).
1822    let _ = msg_tx
1823        .send(Msg::ProviderContextResolved {
1824            model_id: request.model_id.clone(),
1825            model_max: sizing.model_max,
1826            effective: sizing.effective,
1827            source: sizing.source,
1828            max_output: sizing.max_output,
1829        })
1830        .await;
1831    // No-vision-model fallback: if this turn actually carries images, probe the
1832    // model's vision capability and let the reducer warn if it can't see them.
1833    // This backs up the proactive paste-time probe for the rare case where the
1834    // user pasted and sent before that probe resolved. Cheap — `supports_vision`
1835    // is cache-first, so a repeat probe in the same session is free.
1836    if request
1837        .messages
1838        .iter()
1839        .any(|m| m.images.as_ref().is_some_and(|v| !v.is_empty()))
1840    {
1841        let supports_vision = provider.supports_vision().await;
1842        let _ = msg_tx
1843            .send(Msg::ProviderVisionResolved {
1844                model_id: request.model_id.clone(),
1845                supports_vision,
1846                warn: true,
1847            })
1848            .await;
1849    }
1850    let context_snapshot =
1851        crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1852    let _ = msg_tx
1853        .send(Msg::ContextUsageEstimated {
1854            turn,
1855            snapshot: context_snapshot.clone(),
1856        })
1857        .await;
1858
1859    let policy = CompactionPolicy::default();
1860    let mut compacted_before_stream = false;
1861    if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1862        let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1863        // Best-effort preflight: if there's nothing to compact, proceed
1864        // un-compacted (the provider's own context limit is the real gate).
1865        if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1866            match run_compaction(
1867                Arc::clone(&provider),
1868                turn,
1869                compaction,
1870                prepared,
1871                context_snapshot.clone(),
1872                max_context_tokens,
1873                token.clone(),
1874            )
1875            .await
1876            {
1877                Ok(result) => {
1878                    request.messages = result.replacement_messages.clone();
1879                    compacted_before_stream = true;
1880                    let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1881                },
1882                Err(err) => {
1883                    // Auto-compaction is best-effort. If it can't reduce the
1884                    // context — the estimate is roughest exactly at the limit, so
1885                    // a large preserved tail can read `after >= before` — don't
1886                    // kill the turn. Log it, surface a soft warning, and proceed
1887                    // with the original request; the provider's own context limit
1888                    // is the real gate. (Manual `/compact` keeps its hard error
1889                    // via `run_compaction`'s reduction guard.)
1890                    if token.is_cancelled() {
1891                        return;
1892                    }
1893                    tracing::warn!(
1894                        turn = %turn,
1895                        error = %err,
1896                        "auto-compaction failed; proceeding with the un-compacted request",
1897                    );
1898                    let _ = msg_tx
1899                        .send(Msg::CompactionFailed {
1900                            turn,
1901                            trigger: CompactionTrigger::AutoThreshold,
1902                            message: err.to_string(),
1903                            kind: crate::domain::StatusKind::Warn,
1904                        })
1905                        .await;
1906                },
1907            }
1908        }
1909    }
1910
1911    // Build a StreamContext — provider writes typed events into the
1912    // internal sink; we relay each to the reducer as a Msg.
1913    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1914    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1915
1916    // Drain stream events into Msgs on a sibling task. Ends when the sink
1917    // closes (provider's final `Done` or completion) OR the turn token is
1918    // cancelled — `select!`ing on the token ties this relay to the turn's
1919    // structured cancellation so a cancel drops it within a tick instead of
1920    // waiting on the next event. (A separate task is required: the relay must
1921    // run concurrently with `provider.chat` for streaming backpressure.)
1922    let relay_tx = msg_tx.clone();
1923    let relay_token = token.clone();
1924    let relay_tasks = tasks.clone();
1925    let relay = spawn_guarded(async move {
1926        loop {
1927            let event = tokio::select! {
1928                biased;
1929                _ = relay_token.cancelled() => {
1930                    // #F40: a cancel landing right after the provider finished must
1931                    // not discard the terminal Done it already enqueued. Drain the
1932                    // buffered events and relay only a terminal Done — so the
1933                    // just-completed turn's usage is still recorded — while NOT
1934                    // painting buffered intermediate text (the turn is cancelled).
1935                    // `try_recv` drains the buffer without awaiting more.
1936                    while let Ok(buffered) = stream_rx.try_recv() {
1937                        if let StreamEvent::Done {
1938                            usage,
1939                            provider_continuation,
1940                            stop_reason,
1941                        } = buffered
1942                        {
1943                            note_stream_usage(&relay_tasks, &usage);
1944                            let _ = relay_tx
1945                                .send(Msg::StreamDone {
1946                                    turn,
1947                                    usage,
1948                                    provider_continuation,
1949                                    stop_reason,
1950                                })
1951                                .await;
1952                        }
1953                    }
1954                    break;
1955                },
1956                ev = stream_rx.recv() => match ev {
1957                    Some(ev) => ev,
1958                    None => break,
1959                },
1960            };
1961            let msg = match event {
1962                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1963                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1964                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1965                // Plumbing notice ("Starting the local Ollama server…") —
1966                // a turn-independent system line, not response content.
1967                StreamEvent::Status(text) => Msg::TransientStatus { text },
1968                StreamEvent::Done {
1969                    usage,
1970                    provider_continuation,
1971                    stop_reason,
1972                } => {
1973                    note_stream_usage(&relay_tasks, &usage);
1974                    Msg::StreamDone {
1975                        turn,
1976                        usage,
1977                        provider_continuation,
1978                        stop_reason,
1979                    }
1980                },
1981            };
1982            if relay_tx.send(msg).await.is_err() {
1983                break;
1984            }
1985        }
1986    });
1987
1988    // Run the actual provider. On error, the relay will have
1989    // already emitted partial events; we follow with a single
1990    // UpstreamError to terminate the turn cleanly.
1991    //
1992    // `ModelError::Cancelled` is swallowed — the terminal
1993    // `Msg::TurnCancelled` is emitted from `drop_scope` after the
1994    // turn's `TurnScope` drains. Emitting `UpstreamError` here would
1995    // commit a "cancelled" message the user didn't ask to see.
1996    let mut completed_ok = false;
1997    match provider.chat(request.clone(), ctx).await {
1998        Ok(_final_response) => {
1999            // Success — the final `Done` flowed through the sink.
2000            completed_ok = true;
2001        },
2002        Err(crate::models::ModelError::Cancelled) => {
2003            // Silent: `drop_scope` will emit `Msg::TurnCancelled`.
2004        },
2005        Err(e) => {
2006            let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
2007            if retry_context_limit {
2008                let latest_snapshot =
2009                    crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
2010                let compaction =
2011                    CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
2012                // Only retry if there's something to compact; otherwise fall
2013                // through to surface the original context-limit error.
2014                if let Ok(prepared) =
2015                    crate::domain::prepare_compaction(&compaction, max_context_tokens)
2016                {
2017                    match run_compaction(
2018                        Arc::clone(&provider),
2019                        turn,
2020                        compaction,
2021                        prepared,
2022                        latest_snapshot,
2023                        max_context_tokens,
2024                        token.clone(),
2025                    )
2026                    .await
2027                    {
2028                        Ok(result) => {
2029                            let mut retry_request = request;
2030                            retry_request.messages = result.replacement_messages.clone();
2031                            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2032                            join_logged(relay.take(), "stream_relay").await;
2033                            dispatch_provider_stream(
2034                                msg_tx,
2035                                provider,
2036                                turn,
2037                                retry_request,
2038                                token,
2039                                tasks,
2040                            )
2041                            .await;
2042                            return;
2043                        },
2044                        Err(compact_err) => {
2045                            let _ = msg_tx
2046                                .send(Msg::CompactionFailed {
2047                                    turn,
2048                                    trigger: CompactionTrigger::ContextLimitRetry,
2049                                    message: compact_err.to_string(),
2050                                    kind: crate::domain::StatusKind::Error,
2051                                })
2052                                .await;
2053                        },
2054                    }
2055                }
2056            }
2057            let error = classify_error_for_ui(&e);
2058            run_provider_error_hook(&request.model_id, &error).await;
2059            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
2060        },
2061    }
2062
2063    join_logged(relay.take(), "stream_relay").await;
2064
2065    // Post-turn (success only): verify the model actually fit VRAM. Skipped when
2066    // the user allowed RAM offload (no warning possible) and a no-op for
2067    // non-Ollama providers (verify_placement returns None). Off the critical path
2068    // — StreamDone is already enqueued, so any warning renders after the answer.
2069    if completed_ok
2070        && request.ollama_allow_ram_offload != Some(true)
2071        && let Some(p) = provider.verify_placement(sizing.effective).await
2072    {
2073        tracing::debug!(
2074            size_vram_bytes = p.size_vram_bytes,
2075            total_bytes = p.total_bytes,
2076            offloaded = p.size_vram_bytes < p.total_bytes,
2077            suggested_num_ctx = ?p.suggested_num_ctx,
2078            "Ollama placement"
2079        );
2080        let _ = msg_tx
2081            .send(Msg::OllamaPlacementResolved {
2082                model_id: request.model_id.clone(),
2083                size_vram_bytes: p.size_vram_bytes,
2084                total_bytes: p.total_bytes,
2085                suggested_num_ctx: p.suggested_num_ctx,
2086            })
2087            .await;
2088    }
2089}
2090
2091/// Drop-based per-turn model-call timer: emits a structured `tracing` event with
2092/// the elapsed wall time when the stream dispatch returns (success, error, or
2093/// cancel). Impure-shell only — lands in the log / TRACE bundle.
2094struct TurnTimer {
2095    turn: TurnId,
2096    model_id: String,
2097    started: std::time::Instant,
2098}
2099
2100impl Drop for TurnTimer {
2101    fn drop(&mut self) {
2102        tracing::debug!(
2103            turn = %self.turn,
2104            model = %self.model_id,
2105            elapsed_ms = self.started.elapsed().as_millis() as u64,
2106            "model turn complete"
2107        );
2108    }
2109}
2110
2111async fn dispatch_provider_stream(
2112    msg_tx: MsgSender,
2113    provider: Arc<dyn ModelProvider>,
2114    turn: TurnId,
2115    request: crate::domain::ChatRequest,
2116    token: tokio_util::sync::CancellationToken,
2117    tasks: crate::providers::TaskBroker,
2118) {
2119    let _turn_timer = TurnTimer {
2120        turn,
2121        model_id: request.model_id.clone(),
2122        started: std::time::Instant::now(),
2123    };
2124    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
2125    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
2126    let relay_tx = msg_tx.clone();
2127    let relay_token = token.clone();
2128    let relay_tasks = tasks.clone();
2129    let relay = spawn_guarded(async move {
2130        loop {
2131            let event = tokio::select! {
2132                biased;
2133                _ = relay_token.cancelled() => {
2134                    // #F40: a cancel landing right after the provider finished must
2135                    // not discard the terminal Done it already enqueued. Drain the
2136                    // buffered events and relay only a terminal Done — so the
2137                    // just-completed turn's usage is still recorded — while NOT
2138                    // painting buffered intermediate text (the turn is cancelled).
2139                    // `try_recv` drains the buffer without awaiting more.
2140                    while let Ok(buffered) = stream_rx.try_recv() {
2141                        if let StreamEvent::Done {
2142                            usage,
2143                            provider_continuation,
2144                            stop_reason,
2145                        } = buffered
2146                        {
2147                            note_stream_usage(&relay_tasks, &usage);
2148                            let _ = relay_tx
2149                                .send(Msg::StreamDone {
2150                                    turn,
2151                                    usage,
2152                                    provider_continuation,
2153                                    stop_reason,
2154                                })
2155                                .await;
2156                        }
2157                    }
2158                    break;
2159                },
2160                ev = stream_rx.recv() => match ev {
2161                    Some(ev) => ev,
2162                    None => break,
2163                },
2164            };
2165            let msg = match event {
2166                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
2167                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
2168                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
2169                // Plumbing notice — turn-independent system line.
2170                StreamEvent::Status(text) => Msg::TransientStatus { text },
2171                StreamEvent::Done {
2172                    usage,
2173                    provider_continuation,
2174                    stop_reason,
2175                } => {
2176                    note_stream_usage(&relay_tasks, &usage);
2177                    Msg::StreamDone {
2178                        turn,
2179                        usage,
2180                        provider_continuation,
2181                        stop_reason,
2182                    }
2183                },
2184            };
2185            if relay_tx.send(msg).await.is_err() {
2186                break;
2187            }
2188        }
2189    });
2190
2191    let model_id = request.model_id.clone();
2192    match provider.chat(request, ctx).await {
2193        Ok(_) | Err(ModelError::Cancelled) => {},
2194        Err(e) => {
2195            let error = classify_error_for_ui(&e);
2196            run_provider_error_hook(&model_id, &error).await;
2197            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
2198        },
2199    }
2200
2201    join_logged(relay.take(), "stream_relay").await;
2202}
2203
2204/// Run plugin hooks OFF the async executor. `run_plugin_hooks` is synchronous —
2205/// it spawns hook children and bounded-waits on them — so calling it inline
2206/// would block a tokio worker, or (on the `dispatch` path) the whole event loop.
2207/// `spawn_blocking` moves it to the blocking pool. Hooks are fire-and-forget
2208/// observers, so the result is dropped.
2209async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
2210    let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
2211        .await;
2212}
2213
2214/// Run hooks for an event whose responses GATE the action, returning the
2215/// aggregated verdict. Infrastructure failures (store/spawn errors, a panicked
2216/// blocking task) yield an empty gate — fail open; explicit hook denials
2217/// always deny.
2218async fn run_plugin_hooks_gated(
2219    event: &'static str,
2220    payload: serde_json::Value,
2221) -> crate::runtime::HookGate {
2222    tokio::task::spawn_blocking(move || {
2223        crate::runtime::run_plugin_hooks(event, &payload)
2224            .map(crate::runtime::aggregate_hook_responses)
2225            .unwrap_or_default()
2226    })
2227    .await
2228    .unwrap_or_default()
2229}
2230
2231async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
2232    fire_plugin_hooks(
2233        "provider_error",
2234        serde_json::json!({
2235            "model_id": model_id,
2236            "summary": &error.summary,
2237            "message": &error.message,
2238            "category": format!("{:?}", error.category),
2239            "recoverable": error.recoverable,
2240        }),
2241    )
2242    .await;
2243}
2244
2245/// Derive a short title for a `/remember` memory from free-text input: the
2246/// first non-empty line, capped to ~8 words / 60 chars. `write_memory`
2247/// slugifies it into the filename.
2248fn memory_title_from_text(text: &str) -> String {
2249    let first = text
2250        .lines()
2251        .find(|l| !l.trim().is_empty())
2252        .unwrap_or("memory")
2253        .trim();
2254    let title: String = first
2255        .split_whitespace()
2256        .take(8)
2257        .collect::<Vec<_>>()
2258        .join(" ")
2259        .chars()
2260        .take(60)
2261        .collect();
2262    if title.trim().is_empty() {
2263        "memory".to_string()
2264    } else {
2265        title
2266    }
2267}
2268
2269const CONSOLIDATE_SYSTEM_PROMPT: &str = "You maintain a coding agent's durable memory: a set of atomic facts. Your only job is to find facts that are EXACT DUPLICATES or CLEARLY OBSOLETE/SUPERSEDED by another fact, and list their ids for pruning. Never prune facts that are merely related or similar but carry distinct information. Never rewrite or merge facts. When in doubt, keep. Reply with ONLY a JSON object: {\"prune\": [\"id1\", \"id2\"], \"reason\": \"one short sentence\"}. If nothing should be pruned, return an empty prune list.";
2270
2271#[derive(Debug)]
2272struct PrunePlan {
2273    prune: Vec<String>,
2274    reason: String,
2275}
2276
2277/// Extract a `{prune:[...], reason:""}` plan from a model response, tolerating
2278/// prose or code fences around the JSON object.
2279fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
2280    let start = text.find('{')?;
2281    let end = text.rfind('}')?;
2282    if end < start {
2283        return None;
2284    }
2285    let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
2286    let prune = json
2287        .get("prune")?
2288        .as_array()?
2289        .iter()
2290        .filter_map(|v| v.as_str().map(str::to_string))
2291        .collect();
2292    let reason = json
2293        .get("reason")
2294        .and_then(|v| v.as_str())
2295        .unwrap_or("")
2296        .to_string();
2297    Some(PrunePlan { prune, reason })
2298}
2299
2300/// `/consolidate-memory`: a one-shot model pass that names duplicate/obsolete
2301/// facts to prune (never rewrites — that's the anti-drift rule). The pruned
2302/// files are snapshotted into a checkpoint first, so the prune is reversible.
2303async fn consolidate_memory(
2304    tx: MsgSender,
2305    providers: Option<Arc<ProviderFactory>>,
2306    workdir: std::path::PathBuf,
2307    model_id: String,
2308) {
2309    let items = crate::app::memory::entries_with_bodies(&workdir);
2310    if items.len() < 2 {
2311        let _ = tx
2312            .send(Msg::RuntimeText(format!(
2313                "Nothing to consolidate — {} memor{} saved.",
2314                items.len(),
2315                if items.len() == 1 { "y" } else { "ies" }
2316            )))
2317            .await;
2318        return;
2319    }
2320    let Some(factory) = providers else {
2321        let _ = tx
2322            .send(Msg::RuntimeText(
2323                "Memory consolidation needs a model provider, which isn't bound in this session."
2324                    .to_string(),
2325            ))
2326            .await;
2327        return;
2328    };
2329
2330    let mut listing = String::new();
2331    for (entry, body) in &items {
2332        let id = entry
2333            .path
2334            .file_stem()
2335            .and_then(|s| s.to_str())
2336            .unwrap_or(entry.name.as_str());
2337        listing.push_str(&format!(
2338            "- id: {id}\n  scope: {}\n  description: {}\n  body: {}\n",
2339            entry.scope.as_str(),
2340            entry.description,
2341            body.replace('\n', " ").trim(),
2342        ));
2343    }
2344    let user = format!(
2345        "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
2346        items.len(),
2347        listing
2348    );
2349    let request = crate::domain::ChatRequest {
2350        model_id: model_id.clone(),
2351        messages: vec![crate::models::ChatMessage::user(user)],
2352        system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
2353        instructions: None,
2354        reasoning: crate::models::ReasoningLevel::None,
2355        temperature: 0.0,
2356        max_tokens: 1024,
2357        tools: Vec::new(),
2358        ollama_num_ctx: None,
2359        ollama_allow_ram_offload: None,
2360        resolved_context_window: None,
2361        resolved_max_output: None,
2362        output_schema: None,
2363        suppress_auto_compact: false,
2364        suppressed_builtin_tools: Vec::new(),
2365    };
2366
2367    let provider = match factory.resolve(&model_id).await {
2368        Ok(p) => p,
2369        Err(e) => {
2370            let _ = tx
2371                .send(Msg::RuntimeText(format!(
2372                    "Memory consolidation failed: {e}"
2373                )))
2374                .await;
2375            return;
2376        },
2377    };
2378    let token = tokio_util::sync::CancellationToken::new();
2379    let text =
2380        match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
2381            Ok((t, _)) => t,
2382            Err(e) => {
2383                let _ = tx
2384                    .send(Msg::RuntimeText(format!(
2385                        "Memory consolidation failed: {e}"
2386                    )))
2387                    .await;
2388                return;
2389            },
2390        };
2391
2392    let Some(plan) = parse_prune_plan(&text) else {
2393        let _ = tx
2394            .send(Msg::RuntimeText(
2395                "Memory consolidation: couldn't parse the model's plan; nothing changed."
2396                    .to_string(),
2397            ))
2398            .await;
2399        return;
2400    };
2401    if plan.prune.is_empty() {
2402        let reason = if plan.reason.is_empty() {
2403            String::new()
2404        } else {
2405            format!(" {}", plan.reason)
2406        };
2407        let _ = tx
2408            .send(Msg::RuntimeText(format!(
2409                "Memory consolidation: nothing to prune.{reason}"
2410            )))
2411            .await;
2412        return;
2413    }
2414
2415    // Snapshot the to-be-pruned files first so the prune is reversible. The
2416    // delete below is irreversible, so a failed checkpoint must NOT proceed —
2417    // otherwise the report would advertise "Recoverable from the latest
2418    // checkpoint" for a prune with no checkpoint behind it (#F69). Abort instead;
2419    // nothing has been deleted yet, so no memory is lost.
2420    let paths: Vec<std::path::PathBuf> = plan
2421        .prune
2422        .iter()
2423        .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
2424        .collect();
2425    if !paths.is_empty()
2426        && let Err(e) = crate::runtime::create_checkpoint(
2427            &workdir,
2428            &paths,
2429            Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
2430        )
2431    {
2432        let _ = tx
2433            .send(Msg::RuntimeText(format!(
2434                "Memory consolidation aborted: couldn't checkpoint the {} file{} marked for pruning, so nothing was deleted (no memory lost). Error: {e}",
2435                paths.len(),
2436                if paths.len() == 1 { "" } else { "s" },
2437            )))
2438            .await;
2439        return;
2440    }
2441
2442    let mut pruned = Vec::new();
2443    for id in &plan.prune {
2444        if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
2445            pruned.push(id.clone());
2446        }
2447    }
2448
2449    let cfg = crate::app::load_project_scoped_config(&workdir).memory;
2450    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
2451    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
2452
2453    let report = if pruned.is_empty() {
2454        "Memory consolidation: the model named facts to prune, but none matched existing memories."
2455            .to_string()
2456    } else {
2457        format!(
2458            "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
2459            pruned.len(),
2460            if pruned.len() == 1 { "" } else { "s" },
2461            pruned.join(", "),
2462            if plan.reason.is_empty() {
2463                String::new()
2464            } else {
2465                format!(" Reason: {}.", plan.reason)
2466            },
2467        )
2468    };
2469    let _ = tx.send(Msg::RuntimeText(report)).await;
2470}
2471
2472async fn dispatch_compact_conversation(
2473    msg_tx: MsgSender,
2474    providers: Option<Arc<ProviderFactory>>,
2475    turn: TurnId,
2476    mut request: CompactionRequest,
2477    token: tokio_util::sync::CancellationToken,
2478) {
2479    let Some(factory) = providers else {
2480        let _ = msg_tx
2481            .send(Msg::CompactionFailed {
2482                turn,
2483                trigger: request.trigger,
2484                message: "EffectRunner has no ProviderFactory bound".to_string(),
2485                kind: crate::domain::StatusKind::Error,
2486            })
2487            .await;
2488        return;
2489    };
2490
2491    let provider = match factory.resolve(&request.chat.model_id).await {
2492        Ok(provider) => provider,
2493        Err(err) => {
2494            let _ = msg_tx
2495                .send(Msg::CompactionFailed {
2496                    turn,
2497                    trigger: request.trigger,
2498                    message: err.to_string(),
2499                    kind: crate::domain::StatusKind::Error,
2500                })
2501                .await;
2502            return;
2503        },
2504    };
2505
2506    // Resolve the window live (cache-first, so a manual /compact right after
2507    // a turn is a pure cache read). Static capabilities are `None` for
2508    // providers that discover limits at turn time (Anthropic/Gemini) — using
2509    // them here would regress manual /compact to "unknown window".
2510    let sizing = provider.resolve_context_window(&request.chat).await;
2511    request.chat.resolved_context_window = sizing.effective.or(sizing.model_max);
2512    request.chat.resolved_max_output = sizing.max_output;
2513    let max_context_tokens = request.chat.resolved_context_window.or_else(|| {
2514        crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
2515    });
2516    let before_snapshot =
2517        crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
2518
2519    let trigger = request.trigger;
2520    // A benign precondition (e.g. too little history to summarize) is a no-op, not
2521    // a failure — surface it as `Info` so the reducer shows a calm note instead of
2522    // a "Compaction failed: Invalid request" error. Real failures (model errors,
2523    // an empty/non-reducing summary) still flow through `run_compaction` as errors.
2524    let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
2525        Ok(prepared) => prepared,
2526        Err(skip) => {
2527            let _ = msg_tx
2528                .send(Msg::CompactionFailed {
2529                    turn,
2530                    trigger,
2531                    message: skip.to_string(),
2532                    kind: crate::domain::StatusKind::Info,
2533                })
2534                .await;
2535            return;
2536        },
2537    };
2538    match run_compaction(
2539        provider,
2540        turn,
2541        request,
2542        prepared,
2543        before_snapshot,
2544        max_context_tokens,
2545        token,
2546    )
2547    .await
2548    {
2549        Ok(result) => {
2550            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2551        },
2552        Err(err) => {
2553            let _ = msg_tx
2554                .send(Msg::CompactionFailed {
2555                    turn,
2556                    trigger,
2557                    message: err.to_string(),
2558                    kind: crate::domain::StatusKind::Error,
2559                })
2560                .await;
2561        },
2562    }
2563}
2564
2565async fn run_compaction(
2566    provider: Arc<dyn ModelProvider>,
2567    turn: TurnId,
2568    request: CompactionRequest,
2569    prepared: crate::domain::PreparedCompaction,
2570    before_snapshot: crate::domain::ContextUsageSnapshot,
2571    max_context_tokens: Option<usize>,
2572    token: tokio_util::sync::CancellationToken,
2573) -> Result<CompactionResult, ModelError> {
2574    let started = Instant::now();
2575
2576    let summary_request = crate::domain::build_summary_request(
2577        &request.chat,
2578        &prepared,
2579        request.instructions.as_deref(),
2580        request.policy,
2581    );
2582    ensure_compaction_request_fits(&summary_request, max_context_tokens)?;
2583    let (draft, draft_usage) =
2584        collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
2585            .await?;
2586    let draft_summary = crate::domain::normalize_summary(&draft);
2587    let draft_validation = crate::domain::validate_summary_structure(&draft_summary);
2588
2589    let verify_request = crate::domain::build_verification_request(
2590        &request.chat,
2591        &prepared,
2592        &draft_summary,
2593        request.instructions.as_deref(),
2594        request.policy,
2595    );
2596    let review_fits = compaction_request_fits(&verify_request, max_context_tokens);
2597    let (final_summary, verify_usage, review_status, review_error) = if review_fits {
2598        match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
2599            Ok((verified_text, verify_usage)) => {
2600                let verified_summary = crate::domain::normalize_summary(&verified_text);
2601                match crate::domain::validate_summary_structure(&verified_summary) {
2602                    Ok(()) => (
2603                        verified_summary,
2604                        verify_usage,
2605                        crate::domain::CompactionReviewStatus::Reviewed,
2606                        None,
2607                    ),
2608                    Err(error) => match draft_validation {
2609                        Ok(()) => (
2610                            draft_summary,
2611                            verify_usage,
2612                            crate::domain::CompactionReviewStatus::DraftValidated,
2613                            Some(format!("review returned an invalid checkpoint: {error}")),
2614                        ),
2615                        Err(draft_error) => {
2616                            return Err(ModelError::InvalidRequest(format!(
2617                                "compaction produced no structurally valid checkpoint (draft: {draft_error}; review: {error})"
2618                            )));
2619                        },
2620                    },
2621                }
2622            },
2623            Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
2624            Err(err) => match draft_validation {
2625                Ok(()) => (
2626                    draft_summary,
2627                    None,
2628                    crate::domain::CompactionReviewStatus::DraftValidated,
2629                    Some(format!("review failed: {err}")),
2630                ),
2631                Err(draft_error) => {
2632                    return Err(ModelError::InvalidRequest(format!(
2633                        "compaction draft was invalid and review failed (draft: {draft_error}; review: {err})"
2634                    )));
2635                },
2636            },
2637        }
2638    } else {
2639        match draft_validation {
2640            Ok(()) => (
2641                draft_summary,
2642                None,
2643                crate::domain::CompactionReviewStatus::DraftValidated,
2644                Some(
2645                    "review skipped because the complete request would exceed the context window"
2646                        .to_string(),
2647                ),
2648            ),
2649            Err(error) => {
2650                return Err(ModelError::InvalidRequest(format!(
2651                    "compaction draft was invalid and the review request did not fit: {error}"
2652                )));
2653            },
2654        }
2655    };
2656
2657    let id = format!(
2658        "compact_{}",
2659        chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
2660    );
2661    let mut record = crate::domain::CompactionRecord {
2662        id,
2663        trigger: request.trigger,
2664        created_at: chrono::Local::now(),
2665        before_tokens: before_snapshot.used_tokens,
2666        after_tokens: 0,
2667        archived_message_count: prepared.archived_messages.len(),
2668        preserved_message_count: prepared.preserved_messages.len(),
2669        preserved_turn_count: prepared
2670            .preserved_messages
2671            .iter()
2672            .filter(|message| message.role == crate::models::MessageRole::User)
2673            .count(),
2674        summary_tokens: final_summary.len().div_ceil(4),
2675        duration_secs: started.elapsed().as_secs_f64(),
2676        review_status,
2677        review_error,
2678        focus: request.instructions.clone(),
2679        archive_path: None,
2680    };
2681
2682    let mut replacement =
2683        crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2684    let mut compacted_request = request.chat.clone();
2685    compacted_request.messages = replacement.clone();
2686    let mut after_snapshot =
2687        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2688    record.after_tokens = after_snapshot.used_tokens;
2689    record.duration_secs = started.elapsed().as_secs_f64();
2690    replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2691    compacted_request.messages = replacement.clone();
2692    after_snapshot =
2693        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2694    record.after_tokens = after_snapshot.used_tokens;
2695
2696    if after_snapshot.used_tokens >= before_snapshot.used_tokens {
2697        return Err(ModelError::InvalidRequest(format!(
2698            "compaction did not reduce context ({} -> {} tokens)",
2699            before_snapshot.used_tokens, after_snapshot.used_tokens
2700        )));
2701    }
2702
2703    if crate::domain::context_exceeds_hard_limit(
2704        &after_snapshot,
2705        &compacted_request,
2706        request.policy,
2707    ) {
2708        return Err(ModelError::InvalidRequest(format!(
2709            "compacted context still exceeds response reserve ({} tokens used)",
2710            after_snapshot.used_tokens
2711        )));
2712    }
2713
2714    Ok(CompactionResult {
2715        record,
2716        replacement_messages: replacement,
2717        archived_messages: prepared.archived_messages,
2718        before_snapshot,
2719        after_snapshot,
2720        usage: crate::domain::combine_usage(draft_usage, verify_usage),
2721        source_boundaries: request
2722            .chat
2723            .messages
2724            .iter()
2725            .map(crate::domain::CompactionBoundary::from_message)
2726            .collect(),
2727    })
2728}
2729
2730fn compaction_request_fits(
2731    request: &crate::domain::ChatRequest,
2732    max_context_tokens: Option<usize>,
2733) -> bool {
2734    let Some(max_tokens) = max_context_tokens else {
2735        return true;
2736    };
2737    let used = crate::domain::estimate_context_usage_for_request(request, Some(max_tokens));
2738    used.used_tokens.saturating_add(request.max_tokens) <= max_tokens
2739}
2740
2741fn ensure_compaction_request_fits(
2742    request: &crate::domain::ChatRequest,
2743    max_context_tokens: Option<usize>,
2744) -> Result<(), ModelError> {
2745    if compaction_request_fits(request, max_context_tokens) {
2746        Ok(())
2747    } else {
2748        Err(ModelError::InvalidRequest(
2749            "complete compaction request exceeds the model context window".to_string(),
2750        ))
2751    }
2752}
2753
2754async fn collect_compaction_text(
2755    provider: Arc<dyn ModelProvider>,
2756    turn: TurnId,
2757    request: crate::domain::ChatRequest,
2758    token: tokio_util::sync::CancellationToken,
2759) -> Result<(String, Option<TokenUsage>), ModelError> {
2760    // Shared with the Auto-mode safety classifier — see
2761    // `crate::providers::model::collect_text`.
2762    crate::providers::model::collect_text(provider, turn, request, token).await
2763}
2764
2765fn record_provider_capabilities(
2766    model_id: &str,
2767    caps: &crate::providers::capabilities::Capabilities,
2768) {
2769    let (provider, model) = split_model_id(model_id);
2770    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2771        for (key, value) in [
2772            ("tools_support", caps.supports_tools.to_string()),
2773            ("vision_support", caps.supports_vision.to_string()),
2774            (
2775                "context_limit",
2776                caps.max_context_tokens
2777                    .map(|v| v.to_string())
2778                    .unwrap_or_else(|| "unknown".to_string()),
2779            ),
2780            (
2781                "reasoning_parameter_shape",
2782                format!("{:?}", caps.supports_reasoning),
2783            ),
2784            (
2785                "streaming_usage_available",
2786                "provider_dependent".to_string(),
2787            ),
2788            ("token_usage_field_shape", "normalized".to_string()),
2789        ] {
2790            let _ = store
2791                .provider_probes()
2792                .upsert(crate::runtime::NewProviderProbe {
2793                    provider: provider.clone(),
2794                    model_id: model.clone(),
2795                    capability_key: key.to_string(),
2796                    capability_value: value,
2797                    confidence: "verified".to_string(),
2798                    error: None,
2799                });
2800        }
2801    }
2802}
2803
2804fn split_model_id(model_id: &str) -> (String, String) {
2805    match model_id.split_once('/') {
2806        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2807            (provider.to_ascii_lowercase(), model.to_string())
2808        },
2809        _ => ("ollama".to_string(), model_id.to_string()),
2810    }
2811}
2812
2813/// Hard cap on paths returned by [`walk_project_files`]. Well past any
2814/// project the picker is useful on; keeps a runaway monorepo walk bounded.
2815const MAX_PROJECT_FILES: usize = 20_000;
2816
2817/// Enumerate the project for the @-mention picker: gitignore-aware
2818/// (ripgrep's walker — .gitignore/.ignore/global excludes), hidden entries
2819/// and `.git` skipped, symlinks not followed. Returns RELATIVE UTF-8 paths
2820/// sorted lexicographically, directories with a trailing `/`, capped at
2821/// [`MAX_PROJECT_FILES`]. Non-UTF-8 paths are skipped — the mention is
2822/// spliced into the text prompt, so it must be valid text.
2823fn walk_project_files(root: &std::path::Path) -> Vec<String> {
2824    let mut files = Vec::new();
2825    for entry in ignore::WalkBuilder::new(root)
2826        .hidden(true)
2827        .follow_links(false)
2828        .build()
2829        .flatten()
2830    {
2831        if files.len() >= MAX_PROJECT_FILES {
2832            break;
2833        }
2834        let path = entry.path();
2835        if path == root {
2836            continue;
2837        }
2838        let Ok(rel) = path.strip_prefix(root) else {
2839            continue;
2840        };
2841        let Some(mut rel) = rel.to_str().map(str::to_string) else {
2842            continue;
2843        };
2844        // Normalize Windows separators so a mention is stable text.
2845        if std::path::MAIN_SEPARATOR != '/' {
2846            rel = rel.replace(std::path::MAIN_SEPARATOR, "/");
2847        }
2848        if entry.file_type().is_some_and(|t| t.is_dir()) {
2849            rel.push('/');
2850        }
2851        files.push(rel);
2852    }
2853    files.sort();
2854    files
2855}
2856
2857fn is_context_limit_error(error: &ModelError) -> bool {
2858    let text = error.to_string().to_lowercase();
2859    text.contains("context")
2860        && (text.contains("too large")
2861            || text.contains("exceed")
2862            || text.contains("maximum")
2863            || text.contains("token"))
2864}
2865
2866/// Dispatch an `ExecuteTool` command.
2867#[allow(clippy::too_many_arguments)]
2868async fn dispatch_execute_tool(
2869    msg_tx: MsgSender,
2870    tools: Option<Arc<ToolRegistry>>,
2871    workdir: PathBuf,
2872    turn: TurnId,
2873    call_id: crate::domain::ToolCallId,
2874    source: crate::models::tool_call::ToolCall,
2875    token: tokio_util::sync::CancellationToken,
2876    background: tokio_util::sync::CancellationToken,
2877    web_bytes: Arc<std::sync::atomic::AtomicUsize>,
2878    config: Arc<crate::app::Config>,
2879    model_id: String,
2880    task_id: Option<String>,
2881    session_id: String,
2882    message_index: usize,
2883    scratchpad: Option<PathBuf>,
2884    safety_mode: crate::runtime::SafetyMode,
2885    plan_file: Option<PathBuf>,
2886    plan_permissions: crate::app::PlanPermissions,
2887    context_percent: Option<u8>,
2888    intent: Option<String>,
2889    classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2890    approval: Option<crate::providers::ApprovalBroker>,
2891    questions: Option<crate::providers::QuestionBroker>,
2892    tasks: crate::providers::TaskBroker,
2893) {
2894    let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2895
2896    let Some(registry) = tools else {
2897        let _ = msg_tx
2898            .send(Msg::ToolFinished {
2899                turn,
2900                call_id,
2901                outcome: crate::domain::ToolOutcome::error(
2902                    "EffectRunner has no ToolRegistry bound",
2903                    0.0,
2904                ),
2905            })
2906            .await;
2907        return;
2908    };
2909
2910    // Route MCP-prefixed calls to the mcp proxy, which takes
2911    // {server_name, tool_name, arguments}. The raw model call has
2912    // those embedded in the function name and arguments respectively.
2913    let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2914        let rest = &source.function.name[5..];
2915        if let Some((server, tool)) = rest.split_once("__") {
2916            (
2917                "mcp_proxy",
2918                serde_json::json!({
2919                    "server_name": server,
2920                    "tool_name": tool,
2921                    "arguments": source.function.arguments.clone(),
2922                }),
2923            )
2924        } else {
2925            let _ = msg_tx
2926                .send(Msg::ToolFinished {
2927                    turn,
2928                    call_id,
2929                    outcome: crate::domain::ToolOutcome::error(
2930                        format!("invalid MCP tool name: {}", source.function.name),
2931                        0.0,
2932                    ),
2933                })
2934                .await;
2935            return;
2936        }
2937    } else {
2938        (
2939            source.function.name.as_str(),
2940            source.function.arguments.clone(),
2941        )
2942    };
2943    let tool_run_id =
2944        start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2945
2946    let Some(tool) = registry.get(tool_key) else {
2947        let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2948        finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2949        let _ = msg_tx
2950            .send(Msg::ToolFinished {
2951                turn,
2952                call_id,
2953                outcome,
2954            })
2955            .await;
2956        return;
2957    };
2958
2959    // Bridge the tool's progress channel to `Msg::ToolProgress`.
2960    // A sibling task drains progress events while the tool runs.
2961    // The channel closes when `progress_tx` drops (when `ctx`
2962    // drops at the end of `tool.execute`), which terminates the
2963    // relay loop cleanly.
2964    let (progress_tx, mut progress_rx) = mpsc::channel(16);
2965    let relay_tx = msg_tx.clone();
2966    let relay_token = token.clone();
2967    let progress_relay = spawn_guarded(async move {
2968        loop {
2969            let event = tokio::select! {
2970                biased;
2971                _ = relay_token.cancelled() => break,
2972                ev = progress_rx.recv() => match ev {
2973                    Some(ev) => ev,
2974                    None => break,
2975                },
2976            };
2977            if relay_tx
2978                .send(Msg::ToolProgress {
2979                    turn,
2980                    call_id,
2981                    event,
2982                })
2983                .await
2984                .is_err()
2985            {
2986                break;
2987            }
2988        }
2989    });
2990
2991    let mut ctx = ExecContext::new(
2992        token,
2993        progress_tx,
2994        call_id,
2995        turn,
2996        workdir,
2997        config,
2998        model_id,
2999        task_id,
3000        Some(session_id),
3001        Some(message_index as i64),
3002        safety_mode,
3003        intent,
3004        classifier,
3005        approval,
3006        questions,
3007        Some(tasks.clone()),
3008    );
3009    ctx.background = background;
3010    ctx.web_bytes = web_bytes;
3011    ctx.plan_file = plan_file;
3012    ctx.plan_permissions = plan_permissions;
3013    ctx.context_percent = context_percent;
3014    // Detached work (backgrounded subagents) reports back through the main
3015    // msg channel after this turn's progress relay is gone.
3016    ctx.notify = Some(msg_tx.clone());
3017    // Per-session scratch dir, when the session has one materialized.
3018    ctx.scratchpad = scratchpad;
3019    // `before_tool_use` is the one DECISION event: an enabled plugin hook may
3020    // deny the call, rewrite its arguments, or inject context for the next
3021    // model request. Every other event stays fire-and-forget.
3022    let before_payload = serde_json::json!({
3023        "turn_id": turn.0,
3024        "call_id": call_id.0,
3025        "tool": tool_key,
3026        "arguments": args,
3027    });
3028    let gate = run_plugin_hooks_gated("before_tool_use", before_payload).await;
3029    if !gate.context.is_empty() {
3030        // Injected context flows into transcripts/model input — scrub
3031        // credential-shaped content on the way in.
3032        let texts = gate
3033            .context
3034            .iter()
3035            .map(|t| crate::utils::redact_secrets(t))
3036            .collect();
3037        let _ = msg_tx.send(Msg::HookContext { turn, texts }).await;
3038    }
3039    if let Some((plugin, reason)) = gate.deny {
3040        // Mirror the unknown-tool arm: synthesize an error outcome and unwind.
3041        // Dropping `ctx` closes the progress channel so the relay terminates
3042        // before the join below.
3043        drop(ctx);
3044        let reason = crate::utils::redact_secrets(&reason);
3045        let outcome = crate::domain::ToolOutcome::error(
3046            format!("Denied by plugin hook ({plugin}): {reason}"),
3047            0.0,
3048        );
3049        finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
3050        join_logged(progress_relay.take(), "tool_progress_relay").await;
3051        let _ = msg_tx
3052            .send(Msg::ToolFinished {
3053                turn,
3054                call_id,
3055                outcome,
3056            })
3057            .await;
3058        return;
3059    }
3060    // A rewritten input is deliberately NOT redacted (it becomes executable
3061    // args — corrupting them would be worse), and it cannot launder a blocked
3062    // action: the policy gate runs inside `tool.execute` and vets the
3063    // rewritten call exactly like an original one.
3064    let args = gate.updated_input.unwrap_or(args);
3065    let outcome = tool.execute(args, ctx).await;
3066    // Evidence trail: attribute this call to the in-progress checklist task
3067    // (no-op when none). The task tools themselves are skipped — a checklist
3068    // edit is not evidence of work on the task. `display_info_for` gives the
3069    // same human target the transcript row shows (path, command head, query).
3070    if !source.function.name.starts_with("task_") {
3071        let (action, target) = crate::domain::display_info_for(&crate::domain::PendingToolCall {
3072            call_id,
3073            source: source.clone(),
3074        });
3075        tasks
3076            .record_evidence(crate::domain::EvidenceEntry {
3077                tool: action,
3078                target,
3079                status: tool_status_label(outcome.status).to_string(),
3080            })
3081            .await;
3082    }
3083    let after_payload = serde_json::json!({
3084        "turn_id": turn.0,
3085        "call_id": call_id.0,
3086        "tool": tool_key,
3087        "status": tool_status_label(outcome.status),
3088        "summary": &outcome.summary,
3089    });
3090    fire_plugin_hooks("after_tool_use", after_payload).await;
3091    finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
3092    join_logged(progress_relay.take(), "tool_progress_relay").await;
3093    let _ = msg_tx
3094        .send(Msg::ToolFinished {
3095            turn,
3096            call_id,
3097            outcome,
3098        })
3099        .await;
3100}
3101
3102async fn start_runtime_tool_run(
3103    task_id: Option<&str>,
3104    turn: TurnId,
3105    call_id: crate::domain::ToolCallId,
3106    tool_name: &str,
3107    args: &serde_json::Value,
3108) -> Option<String> {
3109    // Synchronous rusqlite write on the hot tool-execution path — offload it to
3110    // the blocking pool. The id is needed by `finish`, so we await the result
3111    // (unlike `finish`, which is fire-and-forget) (#39).
3112    let task_id = task_id.map(str::to_string);
3113    let tool_name = tool_name.to_string();
3114    let args_json = redacted_json_string(args);
3115    tokio::task::spawn_blocking(move || {
3116        crate::runtime::RuntimeStore::open_default()
3117            .and_then(|store| {
3118                store.tool_runs().start(crate::runtime::NewToolRun {
3119                    id: None,
3120                    task_id,
3121                    turn_id: Some(turn.0.to_string()),
3122                    call_id: Some(call_id.0.to_string()),
3123                    tool_name,
3124                    args_json,
3125                })
3126            })
3127            .map(|record| record.id)
3128            .ok()
3129    })
3130    .await
3131    .ok()
3132    .flatten()
3133}
3134
3135fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
3136    let Some(tool_run_id) = tool_run_id else {
3137        return;
3138    };
3139    let tool_run_id = tool_run_id.to_string();
3140    let status = tool_status_label(outcome.status).to_string();
3141    let output_json = redacted_json_string(&serde_json::json!({
3142        "status": tool_status_label(outcome.status),
3143        "summary": &outcome.summary,
3144        "model_content": &outcome.model_content,
3145        "error": &outcome.error,
3146        "metadata": &outcome.metadata,
3147        "artifacts": &outcome.artifacts,
3148        "duration_secs": outcome.duration_secs,
3149    }));
3150    // Fire-and-forget telemetry write on the blocking pool — don't stall the
3151    // tool-finish path waiting on rusqlite (#39).
3152    tokio::task::spawn_blocking(move || {
3153        if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
3154            let _ = store
3155                .tool_runs()
3156                .finish(&tool_run_id, &status, output_json.as_deref());
3157        }
3158    });
3159}
3160
3161/// Serialize a durable runtime payload only after applying the same mandatory
3162/// credential redaction used by recordings and conversation archives. Keep
3163/// executable values unmodified in memory; this helper is exclusively for
3164/// persistence sinks.
3165fn redacted_json_string(value: &serde_json::Value) -> Option<String> {
3166    let mut redacted = value.clone();
3167    crate::utils::redact_json(&mut redacted);
3168    serde_json::to_string(&redacted).ok()
3169}
3170
3171fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
3172    match status {
3173        crate::domain::ToolStatus::Success => "success",
3174        crate::domain::ToolStatus::Error => "error",
3175        crate::domain::ToolStatus::Cancelled => "cancelled",
3176    }
3177}
3178
3179fn runtime_model_info_text(model: &str) -> String {
3180    let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
3181    let mut lines = vec![
3182        format!("Model info: {}", model),
3183        format!("- provider: {}", snapshot.provider),
3184        format!("- model: {}", snapshot.model),
3185        format!("- supports tools: {}", snapshot.supports_tools),
3186        format!("- supports vision: {}", snapshot.supports_vision),
3187        format!("- reasoning: {}", snapshot.reasoning),
3188        format!(
3189            "- context limit: {}",
3190            snapshot
3191                .max_context_tokens
3192                .map(|value: usize| value.to_string())
3193                .unwrap_or_else(|| "unknown".to_string())
3194        ),
3195    ];
3196    if let Ok(store) = crate::runtime::RuntimeStore::open_default()
3197        && let Ok(probes) = store
3198            .provider_probes()
3199            .list(Some(&snapshot.provider), Some(&snapshot.model))
3200        && !probes.is_empty()
3201    {
3202        lines.push(String::new());
3203        lines.push("Cached provider reality records:".to_string());
3204        for probe in probes {
3205            lines.push(format!(
3206                "- {} = {} ({})",
3207                probe.capability_key, probe.capability_value, probe.confidence
3208            ));
3209        }
3210    }
3211    lines.join("\n")
3212}
3213
3214/// Spawn `ollama pull <model>` and stream its stdout lines as
3215/// `Msg::ModelPullProgress` status updates. Emits a final
3216/// `Msg::ModelPullFinished` on successful exit; on failure, emits a
3217/// single `ModelPullProgress` with the error text.
3218async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
3219    use tokio::io::{AsyncBufReadExt, BufReader};
3220    use tokio::process::Command;
3221
3222    let mut cmd = Command::new("ollama");
3223    cmd.arg("pull")
3224        .arg(&model)
3225        .stdin(std::process::Stdio::null())
3226        .stdout(std::process::Stdio::piped())
3227        .stderr(std::process::Stdio::piped())
3228        .kill_on_drop(true);
3229
3230    let mut child = match cmd.spawn() {
3231        Ok(c) => c,
3232        Err(e) => {
3233            let _ = tx
3234                .send(Msg::ModelPullProgress(format!(
3235                    "ollama pull failed to start: {}",
3236                    e
3237                )))
3238                .await;
3239            return;
3240        },
3241    };
3242
3243    // Capture the reader's handle instead of orphaning it: the child's stdout
3244    // closes when it exits, so this task finishes right after `child.wait`
3245    // below — we join it there so a panic is logged, not silently lost (#60).
3246    let reader_handle = child.stdout.take().map(|stdout| {
3247        let tx_inner = tx.clone();
3248        tokio::spawn(async move {
3249            let mut reader = BufReader::new(stdout).lines();
3250            while let Ok(Some(line)) = reader.next_line().await {
3251                let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
3252            }
3253        })
3254    });
3255
3256    match child.wait().await {
3257        Ok(status) if status.success() => {
3258            let _ = tx.send(Msg::ModelPullFinished { model }).await;
3259        },
3260        Ok(status) => {
3261            let _ = tx
3262                .send(Msg::ModelPullProgress(format!(
3263                    "ollama pull exited with status {}",
3264                    status.code().unwrap_or(-1)
3265                )))
3266                .await;
3267        },
3268        Err(e) => {
3269            let _ = tx
3270                .send(Msg::ModelPullProgress(format!(
3271                    "ollama pull wait error: {}",
3272                    e
3273                )))
3274                .await;
3275        },
3276    }
3277
3278    // The child has exited; its stdout is closed, so the reader is finishing.
3279    // Join it (logging a panic) so it isn't left orphaned (#60).
3280    if let Some(handle) = reader_handle {
3281        join_logged(handle, "ollama_pull_reader").await;
3282    }
3283}
3284
3285/// Start every configured MCP server CONCURRENTLY, each bounded by
3286/// `MCP_STARTUP_TIMEOUT`, emitting one `Msg::McpServerReady`/`McpServerErrored`
3287/// per server AS IT RESOLVES — a slow server never delays the rest. The
3288/// (initially empty) manager is installed BEFORE the tasks spawn so shutdown
3289/// always finds it; init is "complete" once every server has resolved
3290/// (`McpToolProxy::wait_ready` semantics unchanged — a first-message
3291/// `mcp__` call waits, bounded, for the full fleet). A zero-tool server that
3292/// started successfully is still Ready with an empty tool list.
3293async fn dispatch_init_mcp_servers(
3294    configs: std::collections::HashMap<String, crate::app::McpServerConfig>,
3295    tx: tokio::sync::mpsc::Sender<Msg>,
3296) {
3297    if configs.is_empty() {
3298        return;
3299    }
3300    crate::mcp::manager_ref::mark_init_started();
3301    let manager = std::sync::Arc::new(crate::mcp::McpServerManager::new(&configs));
3302    crate::mcp::manager_ref::set_manager(manager.clone());
3303    let mut join = tokio::task::JoinSet::new();
3304    for (name, config) in configs {
3305        let manager = manager.clone();
3306        let tx = tx.clone();
3307        join.spawn(async move {
3308            let msg = match manager.start_server(&name, &config).await {
3309                Ok(tools) => Msg::McpServerReady { name, tools },
3310                Err(e) => Msg::McpServerErrored {
3311                    name,
3312                    reason: e.to_string(),
3313                },
3314            };
3315            let _ = tx.send(msg).await;
3316        });
3317    }
3318    while join.join_next().await.is_some() {}
3319    crate::mcp::manager_ref::mark_init_complete();
3320}
3321
3322/// Read the system clipboard on a blocking thread and emit a `Msg`
3323/// back into the main loop. Image content wins when present; falls
3324/// back to text; empty or error surface as `Msg::TransientStatus` so
3325/// the user gets visible feedback (a silent no-op on Ctrl+V would be
3326/// confusing, especially on macOS where `osascript` can take ~300ms).
3327///
3328/// `tokio::task::spawn_blocking` is the right primitive: `clipboard::
3329/// has_image` / `read_image_bytes` / `read_text` shell out to xclip /
3330/// wl-paste / pngpaste / PowerShell, all of which block synchronously —
3331/// bounded, since every clipboard subprocess runs under a kill-on-timeout
3332/// deadline, so a hung helper returns an error here instead of pinning
3333/// this blocking thread forever.
3334async fn dispatch_read_clipboard(tx: MsgSender) {
3335    use crate::domain::ClipboardRead;
3336
3337    enum Outcome {
3338        Image { bytes: Vec<u8>, format: String },
3339        Text(String),
3340        Empty,
3341        Error(String),
3342    }
3343
3344    let outcome = tokio::task::spawn_blocking(|| {
3345        if crate::clipboard::has_image() {
3346            match crate::clipboard::read_image_bytes() {
3347                Ok((bytes, format)) => Outcome::Image { bytes, format },
3348                Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
3349            }
3350        } else {
3351            match crate::clipboard::read_text() {
3352                Ok(t) if !t.is_empty() => Outcome::Text(t),
3353                Ok(_) => Outcome::Empty,
3354                Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
3355            }
3356        }
3357    })
3358    .await
3359    .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
3360
3361    // Route ALL four outcomes through `Msg::ClipboardRead` (not `Msg::Paste` /
3362    // `Msg::TransientStatus`): the reducer decrements `clipboard_reads_pending`
3363    // on exactly these messages, so an empty/failed read must still land here to
3364    // release a submit that was held waiting on it.
3365    let msg = match outcome {
3366        Outcome::Image { bytes, format } => {
3367            Msg::ClipboardRead(ClipboardRead::Image { bytes, format })
3368        },
3369        Outcome::Text(text) => Msg::ClipboardRead(ClipboardRead::Text(text)),
3370        Outcome::Empty => Msg::ClipboardRead(ClipboardRead::Empty),
3371        Outcome::Error(text) => Msg::ClipboardRead(ClipboardRead::Error(text)),
3372    };
3373    let _ = tx.send(msg).await;
3374}
3375
3376/// Probe whether `model_id` can see images and report it via
3377/// `Msg::ProviderVisionResolved`. Best-effort: an unresolvable provider or a
3378/// provider that doesn't probe (non-Ollama) reports `None` ("unknown"), which
3379/// the reducer treats as "don't warn". `warn` rides through unchanged so the
3380/// reducer knows whether an image is actually in play.
3381async fn dispatch_probe_vision(
3382    model_id: String,
3383    warn: bool,
3384    providers: Option<Arc<ProviderFactory>>,
3385    tx: MsgSender,
3386) {
3387    let supports_vision = match providers {
3388        Some(factory) => match factory.resolve(&model_id).await {
3389            Ok(provider) => provider.supports_vision().await,
3390            Err(_) => None,
3391        },
3392        None => None,
3393    };
3394    let _ = tx
3395        .send(Msg::ProviderVisionResolved {
3396            model_id,
3397            supports_vision,
3398            warn,
3399        })
3400        .await;
3401}
3402
3403/// Write text to the system clipboard on a blocking thread (the platform
3404/// tools shell out and block), then report the result via a transient status.
3405async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
3406    let char_count = text.chars().count();
3407    let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
3408        .await
3409        .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
3410
3411    let msg = match result {
3412        Ok(()) => Msg::TransientStatus {
3413            text: format!("Copied {char_count} chars to clipboard"),
3414        },
3415        Err(e) => Msg::TransientStatus {
3416            text: format!("Copy failed: {e}"),
3417        },
3418    };
3419    let _ = tx.send(msg).await;
3420}
3421
3422fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
3423    use crate::models::{ErrorCategory, ModelError, UserFacingError};
3424    match e {
3425        ModelError::Backend(b) => UserFacingError {
3426            summary: "Backend error".to_string(),
3427            message: b.to_string(),
3428            suggestion: "Check the provider endpoint / API key.".to_string(),
3429            category: ErrorCategory::Connection,
3430            recoverable: true,
3431        },
3432        ModelError::Authentication(msg) => UserFacingError {
3433            summary: "Auth error".to_string(),
3434            message: msg.clone(),
3435            suggestion: "Set the env var the provider expects.".to_string(),
3436            category: ErrorCategory::Auth,
3437            recoverable: false,
3438        },
3439        ModelError::RateLimit {
3440            retry_after,
3441            message,
3442        } => UserFacingError {
3443            summary: "Rate limited".to_string(),
3444            // The provider's own reason distinguishes "slow down" from
3445            // "daily quota exhausted" — show it when the 429 body had one.
3446            message: message.clone().unwrap_or_else(|| {
3447                "The provider rejected the request with 429 (too many requests).".to_string()
3448            }),
3449            suggestion: match retry_after {
3450                Some(secs) => format!("The provider asked to retry after {secs}s."),
3451                None => "Retry shortly; if it persists, check your plan's quota.".to_string(),
3452            },
3453            category: ErrorCategory::Temporary,
3454            recoverable: true,
3455        },
3456        ModelError::StreamError(msg) => UserFacingError {
3457            summary: "Stream error".to_string(),
3458            message: msg.clone(),
3459            suggestion: "Retry the request.".to_string(),
3460            category: ErrorCategory::Connection,
3461            recoverable: true,
3462        },
3463        other => UserFacingError {
3464            summary: "Model error".to_string(),
3465            message: other.to_string(),
3466            suggestion: String::new(),
3467            category: ErrorCategory::Internal,
3468            recoverable: false,
3469        },
3470    }
3471}
3472
3473#[cfg(test)]
3474mod tests {
3475    use super::*;
3476    use crate::domain::ToolCallId;
3477    use std::time::Duration;
3478
3479    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
3480        EffectRunner::pair(PathBuf::from("/tmp"))
3481    }
3482
3483    /// The reducer's `suppressed_builtin_tools` contract: named tools drop
3484    /// out of the advertised set, everything else passes through in order.
3485    #[test]
3486    fn filter_suppressed_drops_only_the_named_tools() {
3487        let def = |name: &str| crate::domain::ToolDefinition {
3488            name: name.to_string(),
3489            description: String::new(),
3490            input_schema: serde_json::json!({}),
3491        };
3492        let tools = vec![def("task_create"), def("task_list"), def("task_update")];
3493        let kept = filter_suppressed(tools.clone(), &["task_create", "task_update"]);
3494        assert_eq!(
3495            kept.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
3496            vec!["task_list"]
3497        );
3498        let kept = filter_suppressed(tools, &[]);
3499        assert_eq!(kept.len(), 3, "empty suppression list is a no-op");
3500    }
3501
3502    #[test]
3503    fn runtime_tool_payloads_are_redacted_before_serialization() {
3504        let payload = serde_json::json!({
3505            "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
3506            "authorization": "opaque-secret-value",
3507            "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
3508        });
3509        let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
3510        assert!(
3511            !serialized.contains("hunter2"),
3512            "URL password leaked: {serialized}"
3513        );
3514        assert!(
3515            !serialized.contains("opaque-signature"),
3516            "signed URL leaked: {serialized}"
3517        );
3518        assert!(
3519            !serialized.contains("private"),
3520            "URL fragment leaked: {serialized}"
3521        );
3522        assert!(
3523            !serialized.contains("opaque-secret-value"),
3524            "credential-named field leaked: {serialized}"
3525        );
3526        assert!(
3527            !serialized.contains("abcdef123456ghijkl"),
3528            "bearer token leaked: {serialized}"
3529        );
3530        assert!(
3531            !serialized.contains("sk-abcdefghijklmnop1234"),
3532            "secret-shaped fetched content leaked: {serialized}"
3533        );
3534        assert!(serialized.contains("[REDACTED]"));
3535    }
3536
3537    #[test]
3538    fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
3539        let root = std::env::temp_dir().join(format!(
3540            "mermaid-walk-{}-{:?}",
3541            std::process::id(),
3542            std::thread::current().id()
3543        ));
3544        let _ = std::fs::remove_dir_all(&root);
3545        std::fs::create_dir_all(root.join("src")).unwrap();
3546        std::fs::create_dir_all(root.join("target")).unwrap();
3547        std::fs::create_dir_all(root.join(".git")).unwrap();
3548        std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
3549        std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
3550        std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
3551        std::fs::write(root.join("README.md"), "readme").unwrap();
3552        std::fs::write(root.join(".hidden"), "hidden").unwrap();
3553
3554        let files = walk_project_files(&root);
3555        assert_eq!(
3556            files,
3557            vec![
3558                "README.md".to_string(),
3559                "src/".to_string(),
3560                "src/main.rs".to_string(),
3561            ],
3562            "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
3563        );
3564        let _ = std::fs::remove_dir_all(&root);
3565    }
3566
3567    #[test]
3568    fn new_child_suppresses_terminal_title() {
3569        // A subagent's child runner must not emit OSC 2 terminal titles —
3570        // otherwise they leak into a headless parent's stdout and corrupt
3571        // `--format json`/`text` output (caught during live headless testing).
3572        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
3573        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
3574        let tools = Arc::new(ToolRegistry::new());
3575        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
3576        assert!(
3577            !child.terminal_title_enabled,
3578            "subagent child runner must suppress terminal-title escapes"
3579        );
3580    }
3581
3582    #[test]
3583    fn new_child_does_not_own_global_mcp_shutdown() {
3584        // The MCP manager is process-global and shared with the parent. A
3585        // child runner's shutdown (which runs after EVERY subagent) must not
3586        // reap it — that would kill the parent's MCP servers for the rest of
3587        // the session. Only the top-level runner owns the reap.
3588        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
3589        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
3590        let tools = Arc::new(ToolRegistry::new());
3591        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
3592        assert!(
3593            !child.owns_global_mcp,
3594            "child runner must not reap the shared global MCP manager"
3595        );
3596        let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
3597        assert!(
3598            top.owns_global_mcp,
3599            "top-level runner still owns the global MCP reap"
3600        );
3601    }
3602
3603    #[test]
3604    fn parse_prune_plan_extracts_json_amid_prose() {
3605        let plan = parse_prune_plan(
3606            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
3607        )
3608        .expect("should parse");
3609        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
3610        assert_eq!(plan.reason, "dupes");
3611    }
3612
3613    #[test]
3614    fn parse_prune_plan_handles_empty_and_garbage() {
3615        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
3616            .expect("empty plan parses");
3617        assert!(empty.prune.is_empty());
3618        assert!(parse_prune_plan("no json here").is_none());
3619    }
3620
3621    #[test]
3622    fn memory_title_from_text_is_short_and_nonempty() {
3623        assert_eq!(
3624            memory_title_from_text("prefer ripgrep over grep"),
3625            "prefer ripgrep over grep"
3626        );
3627        assert_eq!(memory_title_from_text("   "), "memory");
3628        let long = memory_title_from_text("one two three four five six seven eight nine ten");
3629        assert!(long.split_whitespace().count() <= 8);
3630    }
3631
3632    #[tokio::test]
3633    async fn dispatch_exit_is_noop_on_runner_state() {
3634        let (mut r, _rx) = runner();
3635        r.dispatch(Cmd::Exit);
3636        assert_eq!(r.scope_count(), 0);
3637    }
3638
3639    #[tokio::test]
3640    async fn dispatch_save_emits_session_saved() {
3641        let (mut r, mut rx) = runner();
3642        r.dispatch(Cmd::SaveConversation(
3643            crate::session::ConversationHistory::new(
3644                "/p".to_string(),
3645                "m".to_string(),
3646                chrono::Local::now(),
3647            ),
3648        ));
3649        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3650            .await
3651            .expect("sender emits")
3652            .expect("channel alive");
3653        assert!(matches!(msg, Msg::SessionSaved));
3654    }
3655
3656    #[cfg(unix)]
3657    #[tokio::test]
3658    async fn init_mcp_servers_emits_incremental_errored_msgs() {
3659        // Two servers that both fail fast (nonexistent binaries): each
3660        // resolves independently and emits its own Errored msg; init
3661        // completes after both. Also exercises the empty-manager install.
3662        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
3663        let mut configs = std::collections::HashMap::new();
3664        for name in ["one", "two"] {
3665            configs.insert(
3666                name.to_string(),
3667                crate::app::McpServerConfig {
3668                    command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
3669                    ..Default::default()
3670                },
3671            );
3672        }
3673        dispatch_init_mcp_servers(configs, tx).await;
3674        let mut errored = Vec::new();
3675        while let Ok(msg) = rx.try_recv() {
3676            match msg {
3677                Msg::McpServerErrored { name, .. } => errored.push(name),
3678                other => panic!("unexpected msg: {other:?}"),
3679            }
3680        }
3681        errored.sort();
3682        assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
3683        assert!(crate::mcp::manager_ref::is_ready());
3684    }
3685
3686    #[tokio::test]
3687    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
3688        let (mut r, mut rx) = runner();
3689        let turn = TurnId(77);
3690        {
3691            let scope = r.scope_mut(turn);
3692            scope.spawn(async {
3693                std::future::pending::<()>().await;
3694            });
3695        }
3696        assert_eq!(r.scope_count(), 1);
3697
3698        let start = std::time::Instant::now();
3699        r.dispatch(Cmd::CancelScope(turn));
3700        assert_eq!(r.scope_count(), 0);
3701        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
3702            .await
3703            .expect("bounded cancel should emit terminal message")
3704            .expect("channel alive");
3705        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
3706        assert!(
3707            start.elapsed() < Duration::from_millis(500),
3708            "cancel terminal message took {:?}",
3709            start.elapsed()
3710        );
3711    }
3712
3713    #[tokio::test]
3714    async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
3715        // Regression (Axis 1 #9): if a turn's tasks complete and
3716        // `reap_empty_scopes` removes the now-empty scope before the user's
3717        // cancel lands, `drop_scope` used to be a silent no-op and the reducer
3718        // stuck forever in `Cancelling`. The terminal `TurnCancelled` must fire
3719        // even when the scope is already gone.
3720        let (mut r, mut rx) = runner();
3721        let turn = TurnId(88);
3722        {
3723            let scope = r.scope_mut(turn);
3724            scope.spawn(async {}); // completes immediately
3725        }
3726        assert_eq!(r.scope_count(), 1);
3727
3728        // Let the task finish, then any dispatch reaps the now-empty scope.
3729        tokio::time::sleep(Duration::from_millis(20)).await;
3730        r.dispatch(Cmd::Exit);
3731        assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
3732
3733        // The scope is gone, but the reducer is still `Cancelling`: cancel must
3734        // still produce a terminal message.
3735        r.dispatch(Cmd::CancelScope(turn));
3736        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
3737            .await
3738            .expect("cancel on a reaped scope must still emit a terminal message")
3739            .expect("channel alive");
3740        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
3741    }
3742
3743    #[tokio::test]
3744    async fn dispatch_call_model_creates_scope() {
3745        let (mut r, _rx) = runner();
3746        let turn = TurnId(7);
3747        let request = crate::domain::ChatRequest {
3748            model_id: "test/m".to_string(),
3749            messages: vec![],
3750            system_prompt: String::new(),
3751            instructions: None,
3752            reasoning: crate::models::ReasoningLevel::Medium,
3753            temperature: 0.7,
3754            max_tokens: 4096,
3755            tools: vec![],
3756
3757            ollama_num_ctx: None,
3758            ollama_allow_ram_offload: None,
3759            resolved_context_window: None,
3760            resolved_max_output: None,
3761            output_schema: None,
3762            suppress_auto_compact: false,
3763            suppressed_builtin_tools: Vec::new(),
3764        };
3765        r.dispatch(Cmd::CallModel { turn, request });
3766        assert_eq!(r.scope_count(), 1);
3767    }
3768
3769    /// F12: after a spawned task completes (here via the
3770    /// no-ProviderFactory error path), the next `dispatch` call reaps
3771    /// the empty scope instead of leaving an orphan entry in the map.
3772    #[tokio::test]
3773    async fn empty_scopes_are_reaped_on_next_dispatch() {
3774        let (mut r, mut rx) = runner();
3775        let turn = TurnId(42);
3776        let request = crate::domain::ChatRequest {
3777            model_id: "test/m".to_string(),
3778            messages: vec![],
3779            system_prompt: String::new(),
3780            instructions: None,
3781            reasoning: crate::models::ReasoningLevel::Medium,
3782            temperature: 0.7,
3783            max_tokens: 4096,
3784            tools: vec![],
3785
3786            ollama_num_ctx: None,
3787            ollama_allow_ram_offload: None,
3788            resolved_context_window: None,
3789            resolved_max_output: None,
3790            output_schema: None,
3791            suppress_auto_compact: false,
3792            suppressed_builtin_tools: Vec::new(),
3793        };
3794        r.dispatch(Cmd::CallModel { turn, request });
3795        assert_eq!(r.scope_count(), 1);
3796
3797        // Runner has no provider bindings → dispatch_call_model hits
3798        // the "not wired" error path and emits UpstreamError, then the
3799        // spawned task returns. Drain that message so we know the task
3800        // ran to completion.
3801        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3802            .await
3803            .expect("upstream error arrived")
3804            .expect("channel alive");
3805        assert!(matches!(msg, Msg::UpstreamError { .. }));
3806
3807        // Give the JoinSet a tick to notice the task finished.
3808        tokio::task::yield_now().await;
3809
3810        // Any subsequent dispatch reaps the now-empty scope.
3811        r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
3812        assert_eq!(
3813            r.scope_count(),
3814            0,
3815            "completed scope must be reaped on next dispatch"
3816        );
3817    }
3818
3819    #[tokio::test]
3820    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
3821        let (mut r, mut rx) = runner();
3822        let turn = TurnId(7);
3823        let call_id = ToolCallId(1);
3824        let source = crate::models::tool_call::ToolCall {
3825            id: Some("c1".to_string()),
3826            function: crate::models::tool_call::FunctionCall {
3827                name: "read_file".to_string(),
3828                arguments: serde_json::json!({"path": "x"}),
3829            },
3830        };
3831        r.dispatch(Cmd::ExecuteTool {
3832            turn,
3833            call_id,
3834            source,
3835            model_id: "ollama/test".to_string(),
3836            safety_mode: crate::runtime::SafetyMode::Ask,
3837            plan_file: None,
3838            plan_permissions: crate::app::PlanPermissions::default(),
3839            context_percent: None,
3840            intent: None,
3841            session_id: "sess-test".to_string(),
3842            message_index: 0,
3843            scratchpad: None,
3844        });
3845        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3846            .await
3847            .expect("some msg")
3848            .expect("channel alive");
3849        assert!(matches!(
3850            first,
3851            Msg::ToolStarted {
3852                turn: t,
3853                call_id: c,
3854            } if t == turn && c == call_id
3855        ));
3856    }
3857
3858    #[tokio::test]
3859    async fn cancel_scope_before_execute_tool_drops_pending_work() {
3860        let (mut r, _rx) = runner();
3861        let turn = TurnId(9);
3862        r.dispatch(Cmd::CallModel {
3863            turn,
3864            request: crate::domain::ChatRequest {
3865                model_id: "m".to_string(),
3866                messages: vec![],
3867                system_prompt: String::new(),
3868                instructions: None,
3869                reasoning: crate::models::ReasoningLevel::Medium,
3870                temperature: 0.7,
3871                max_tokens: 4096,
3872                tools: vec![],
3873
3874                ollama_num_ctx: None,
3875                ollama_allow_ram_offload: None,
3876                resolved_context_window: None,
3877                resolved_max_output: None,
3878                output_schema: None,
3879                suppress_auto_compact: false,
3880                suppressed_builtin_tools: Vec::new(),
3881            },
3882        });
3883        assert_eq!(r.scope_count(), 1);
3884
3885        r.dispatch(Cmd::CancelScope(turn));
3886        assert_eq!(r.scope_count(), 0);
3887    }
3888
3889    #[tokio::test]
3890    async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
3891        // F38: once a turn's scope has been cancelled (dropped + tombstoned), a
3892        // stray turn-scoped Cmd bearing the same TurnId must be dropped — not
3893        // used to spin up a fresh, un-cancelled scope via `scope_mut`'s
3894        // `or_insert_with`. Turn ids are monotonic and never reused, so such a
3895        // Cmd can only be a post-cancel straggler.
3896        let (mut r, _rx) = runner();
3897        let req = || crate::domain::ChatRequest {
3898            model_id: "test/m".to_string(),
3899            messages: vec![],
3900            system_prompt: String::new(),
3901            instructions: None,
3902            reasoning: crate::models::ReasoningLevel::Medium,
3903            temperature: 0.7,
3904            max_tokens: 4096,
3905            tools: vec![],
3906            ollama_num_ctx: None,
3907            ollama_allow_ram_offload: None,
3908            resolved_context_window: None,
3909            resolved_max_output: None,
3910            output_schema: None,
3911            suppress_auto_compact: false,
3912            suppressed_builtin_tools: Vec::new(),
3913        };
3914        let turn = TurnId(123);
3915
3916        r.dispatch(Cmd::CallModel {
3917            turn,
3918            request: req(),
3919        });
3920        assert_eq!(r.scope_count(), 1);
3921
3922        // Cancel: drops the scope and tombstones the turn.
3923        r.dispatch(Cmd::CancelScope(turn));
3924        assert_eq!(r.scope_count(), 0);
3925
3926        // A late scoped Cmd for the now-tombstoned turn must be dropped.
3927        r.dispatch(Cmd::CallModel {
3928            turn,
3929            request: req(),
3930        });
3931        assert_eq!(
3932            r.scope_count(),
3933            0,
3934            "a cancelled turn must not be resurrected by a late scoped Cmd"
3935        );
3936
3937        // A fresh, higher turn id is unaffected by the tombstone.
3938        r.dispatch(Cmd::CallModel {
3939            turn: TurnId(124),
3940            request: req(),
3941        });
3942        assert_eq!(
3943            r.scope_count(),
3944            1,
3945            "a fresh turn must still create its scope normally"
3946        );
3947    }
3948
3949    #[tokio::test]
3950    async fn shutdown_drains_pending_saves() {
3951        let (mut r, _rx) = runner();
3952        for _ in 0..5 {
3953            r.dispatch(Cmd::SaveConversation(
3954                crate::session::ConversationHistory::new(
3955                    "/p".to_string(),
3956                    "m".to_string(),
3957                    chrono::Local::now(),
3958                ),
3959            ));
3960        }
3961        // Shutdown waits for all five to complete (should be instant).
3962        let start = std::time::Instant::now();
3963        r.shutdown().await;
3964        assert!(start.elapsed() < Duration::from_secs(2));
3965    }
3966
3967    fn persistence_fixture(
3968        root: &std::path::Path,
3969        archive_id: &str,
3970    ) -> (crate::session::ConversationHistory, PendingCompactionSave) {
3971        let now = chrono::Local::now();
3972        let mut full = crate::session::ConversationHistory::new(
3973            root.display().to_string(),
3974            "test/model".to_string(),
3975            now,
3976        );
3977        full.add_messages(&[crate::models::ChatMessage::user("raw history")], now);
3978        let mut compacted = full.clone();
3979        compacted.replace_messages(
3980            vec![crate::models::ChatMessage::user("compacted checkpoint")],
3981            now,
3982        );
3983        let archive = crate::domain::CompactionArchive {
3984            id: archive_id.to_string(),
3985            conversation_id: full.id.clone(),
3986            created_at: now,
3987            messages: full.messages().to_vec(),
3988        };
3989        let record = crate::domain::CompactionRecord {
3990            id: archive_id.to_string(),
3991            trigger: crate::domain::CompactionTrigger::Manual,
3992            created_at: now,
3993            before_tokens: 100,
3994            after_tokens: 20,
3995            archived_message_count: 1,
3996            preserved_message_count: 1,
3997            preserved_turn_count: 1,
3998            summary_tokens: 10,
3999            duration_secs: 0.1,
4000            review_status: crate::domain::CompactionReviewStatus::Reviewed,
4001            review_error: None,
4002            focus: None,
4003            archive_path: None,
4004        };
4005        (
4006            full,
4007            PendingCompactionSave {
4008                archive,
4009                record,
4010                conversation: compacted,
4011                task_id: None,
4012            },
4013        )
4014    }
4015
4016    #[test]
4017    fn persistence_orders_compaction_before_newer_conversation_save() {
4018        let root = std::env::temp_dir().join(format!(
4019            "mermaid-persistence-order-{}-{:?}",
4020            std::process::id(),
4021            std::thread::current().id()
4022        ));
4023        let _ = std::fs::remove_dir_all(&root);
4024        let (full, compaction) = persistence_fixture(&root, "compact_ordered");
4025        let manager = crate::session::ConversationManager::new(&root).unwrap();
4026        manager.save_conversation(&full).unwrap();
4027
4028        let mut state = PersistenceState::new(root.clone());
4029        let (events, outcome) =
4030            state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
4031        outcome.unwrap();
4032        assert_eq!(events.len(), 1);
4033        let mut newer = compaction.conversation;
4034        newer.add_messages(
4035            &[crate::models::ChatMessage::assistant("new assistant reply")],
4036            chrono::Local::now(),
4037        );
4038        let (_, outcome) = state.process(PersistenceJob::Conversation(Box::new(newer)));
4039        outcome.unwrap();
4040
4041        let loaded = crate::session::ConversationManager::new(&root)
4042            .unwrap()
4043            .load_conversation(&full.id)
4044            .unwrap();
4045        assert!(
4046            loaded
4047                .messages()
4048                .iter()
4049                .any(|message| message.content == "new assistant reply")
4050        );
4051        let _ = std::fs::remove_dir_all(root);
4052    }
4053
4054    #[test]
4055    fn failed_archive_blocks_later_stripped_conversation_save() {
4056        let root = std::env::temp_dir().join(format!(
4057            "mermaid-persistence-barrier-{}-{:?}",
4058            std::process::id(),
4059            std::thread::current().id()
4060        ));
4061        let _ = std::fs::remove_dir_all(&root);
4062        let (full, compaction) = persistence_fixture(&root, "../invalid");
4063        let manager = crate::session::ConversationManager::new(&root).unwrap();
4064        manager.save_conversation(&full).unwrap();
4065
4066        let mut state = PersistenceState::new(root.clone());
4067        assert!(
4068            state
4069                .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
4070                .1
4071                .is_err()
4072        );
4073        assert!(
4074            state
4075                .process(PersistenceJob::Conversation(Box::new(
4076                    compaction.conversation,
4077                )))
4078                .1
4079                .is_err()
4080        );
4081        assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
4082
4083        let loaded = crate::session::ConversationManager::new(&root)
4084            .unwrap()
4085            .load_conversation(&full.id)
4086            .unwrap();
4087        assert_eq!(loaded.messages()[0].content, "raw history");
4088        let _ = std::fs::remove_dir_all(root);
4089    }
4090
4091    #[test]
4092    fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
4093        let root = std::env::temp_dir().join(format!(
4094            "mermaid-persistence-queue-{}-{:?}",
4095            std::process::id(),
4096            std::thread::current().id()
4097        ));
4098        let _ = std::fs::remove_dir_all(&root);
4099        let (full, first) = persistence_fixture(&root, "../invalid");
4100        let mut second = first.clone();
4101        second.archive.id = "compact_second".to_string();
4102        second.record.id = "compact_second".to_string();
4103
4104        let mut state = PersistenceState::new(root.clone());
4105        assert!(
4106            state
4107                .process(PersistenceJob::Compaction(Box::new(first)))
4108                .1
4109                .is_err()
4110        );
4111        // The older barrier still fails; the new save must queue behind it —
4112        // its archive is the only durable copy of the stripped messages.
4113        assert!(
4114            state
4115                .process(PersistenceJob::Compaction(Box::new(second)))
4116                .1
4117                .is_err()
4118        );
4119        let queued = state.blocked.get(&full.id).expect("barrier queue");
4120        assert_eq!(queued.len(), 2);
4121        assert_eq!(queued[0].archive.id, "../invalid");
4122        assert_eq!(queued[1].archive.id, "compact_second");
4123        let _ = std::fs::remove_dir_all(root);
4124    }
4125
4126    #[test]
4127    fn retry_all_blocked_attempts_every_conversation() {
4128        let root = std::env::temp_dir().join(format!(
4129            "mermaid-persistence-drain-{}-{:?}",
4130            std::process::id(),
4131            std::thread::current().id()
4132        ));
4133        let _ = std::fs::remove_dir_all(&root);
4134        let (bad_full, bad) = persistence_fixture(&root, "../invalid");
4135        let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
4136        // Conversation ids are millisecond timestamps; two fixtures minted in
4137        // the same instant would collide into one barrier queue. Force the
4138        // second conversation onto a distinct (still format-valid) id.
4139        good_full.id = "20990101_000000_001".to_string();
4140        good.archive.conversation_id = good_full.id.clone();
4141        good.conversation.id = good_full.id.clone();
4142
4143        let mut state = PersistenceState::new(root.clone());
4144        state
4145            .blocked
4146            .entry(bad_full.id.clone())
4147            .or_default()
4148            .push_back(bad);
4149        state
4150            .blocked
4151            .entry(good_full.id.clone())
4152            .or_default()
4153            .push_back(good);
4154
4155        // One conversation's bad disk state must not strand the other's
4156        // barrier at shutdown: the error surfaces, but the good save lands —
4157        // and its durably persisted event is reported alongside the error.
4158        let (events, outcome) = state.retry_all_blocked();
4159        assert!(outcome.is_err());
4160        assert_eq!(events.len(), 1);
4161        assert_eq!(events[0].id, "compact_good");
4162        assert!(!state.blocked.contains_key(&good_full.id));
4163        assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
4164        let loaded = crate::session::ConversationManager::new(&root)
4165            .unwrap()
4166            .load_conversation(&good_full.id)
4167            .unwrap();
4168        assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
4169        let _ = std::fs::remove_dir_all(root);
4170    }
4171
4172    #[test]
4173    fn partially_drained_barrier_reports_its_persisted_events() {
4174        let root = std::env::temp_dir().join(format!(
4175            "mermaid-persistence-partial-{}-{:?}",
4176            std::process::id(),
4177            std::thread::current().id()
4178        ));
4179        let _ = std::fs::remove_dir_all(&root);
4180        let (full, good) = persistence_fixture(&root, "compact_good");
4181        let mut bad = good.clone();
4182        bad.archive.id = "../invalid".to_string();
4183        bad.record.id = "../invalid".to_string();
4184
4185        let mut state = PersistenceState::new(root.clone());
4186        let queue = state.blocked.entry(full.id.clone()).or_default();
4187        queue.push_back(good);
4188        queue.push_back(bad);
4189
4190        // The good save at the head of the queue persists durably before the
4191        // bad one fails. Its event must surface with the error — it is popped
4192        // and would otherwise never fire SessionSaved or the compaction hook.
4193        let (events, outcome) = state.retry_blocked(&full.id);
4194        assert!(outcome.is_err());
4195        assert_eq!(events.len(), 1);
4196        assert_eq!(events[0].id, "compact_good");
4197        assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
4198        let _ = std::fs::remove_dir_all(root);
4199    }
4200}