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