Skip to main content

mermaid_cli/effect/
mod.rs

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