Skip to main content

mermaid_cli/effect/
mod.rs

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