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::time::Instant;
43
44use tokio::sync::mpsc;
45
46use crate::app::{Config, MemoryConfig};
47use crate::domain::{
48    Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
49};
50use crate::models::{ModelError, TokenUsage};
51use crate::providers::ctx::{ExecContext, StreamContext};
52use crate::providers::model::ModelProvider;
53use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
54
55pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
56pub use turn_scope::TurnScope;
57
58#[cfg(not(test))]
59const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
60#[cfg(test)]
61const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
62
63/// F38: how many recently-cancelled `TurnId`s to remember as tombstones.
64/// Turn ids are strictly monotonic and never reused, so a stray turn-scoped
65/// `Cmd` for a cancelled turn can only ever be a post-cancel straggler that
66/// lands within a few turns of the cancel. A small bounded ring is plenty;
67/// older entries age out so the set never grows across a long session.
68const CANCELLED_TOMBSTONE_CAP: usize = 256;
69
70/// Single channel back to the reducer. `EffectRunner` holds the
71/// sender; every spawned task clones this so it can emit `Msg` as
72/// work progresses. Bounded capacity applies natural backpressure —
73/// if the main loop can't keep up, the provider's streaming send
74/// `.await`s and the whole pipeline throttles.
75pub type MsgSender = mpsc::Sender<Msg>;
76
77/// Bounded channel capacity for the effect → reducer stream. 512 is
78/// generous — a single streaming chunk fits comfortably, and the
79/// main loop drains at ~60 Hz so backlog rarely grows. Bigger wastes
80/// RAM; smaller introduces spurious backpressure on bursty tool
81/// output.
82pub const MSG_CHANNEL_CAPACITY: usize = 512;
83
84/// The runner. One instance per process, constructed by
85/// `app::run` and consumed when the main loop exits.
86pub struct EffectRunner {
87    msg_tx: MsgSender,
88    /// Per-turn scopes. Populated lazily: the first `Cmd` bearing a
89    /// TurnId creates a scope; `Cmd::CancelScope` tears it down.
90    /// Empty (drained) scopes are reaped by `reap_empty_scopes`, which
91    /// runs at the top of every `dispatch` call so the map stays
92    /// bounded across long sessions (F12).
93    scopes: HashMap<TurnId, TurnScope>,
94    /// F38: bounded tombstone ring of `TurnId`s whose scope has been
95    /// cancelled+dropped. A turn-scoped `Cmd` (`CallModel` / `ExecuteTool` /
96    /// `CompactConversation`) bearing a tombstoned id is dropped in `dispatch`
97    /// instead of resurrecting a fresh, un-cancelled scope through
98    /// `scope_mut`'s `or_insert_with`. Bounded to `CANCELLED_TOMBSTONE_CAP`.
99    cancelled_turns: VecDeque<TurnId>,
100    /// Detached work (saves, persists, MCP lifecycle) lives here.
101    /// This one set never gets cancelled piecemeal — shutdown drains
102    /// it during `EffectRunner::shutdown`.
103    detached: tokio::task::JoinSet<()>,
104    /// MCP manager handle is held elsewhere (`crate::mcp` has a
105    /// `OnceLock` for its global manager); we just note workdir so
106    /// handlers can construct absolute paths.
107    workdir: PathBuf,
108    /// Lazy provider registry. `CallModel` resolves through this.
109    /// Tests that don't care about real providers leave this `None`
110    /// and observe the fallback `UpstreamError` Msg; production
111    /// construction via `with_bindings` sets it.
112    providers: Option<Arc<ProviderFactory>>,
113    /// Shared tool registry. See `providers` — same optionality
114    /// rationale for unit tests.
115    tools: Option<Arc<ToolRegistry>>,
116    /// Durable runtime task that owns work launched by this runner.
117    task_id: Option<String>,
118    /// Interactive TUI runners write OSC 2 terminal-title updates.
119    /// Headless `mermaid run` must suppress them so stdout stays
120    /// machine-readable for JSON/markdown/text output modes.
121    terminal_title_enabled: bool,
122    /// Whether this runner's `shutdown` reaps the PROCESS-GLOBAL MCP manager
123    /// (`crate::mcp::manager_ref`). True only for the top-level runner. A
124    /// subagent's child runner shares the global manager, so it must NOT reap
125    /// it — otherwise the first subagent to finish would kill every MCP
126    /// server out from under the parent for the rest of the session.
127    owns_global_mcp: bool,
128    /// Inline-approval broker. `Some` only for interactive TUI runs (set via
129    /// `with_interactive_approvals`); headless + child runners leave it `None`,
130    /// so the gate falls back to the out-of-band DB-approval flow.
131    approval: Option<crate::providers::ApprovalBroker>,
132    /// Abort handle for the background config watcher (#45). It's a perpetual
133    /// loop living in `detached`, so `shutdown` aborts it explicitly before
134    /// draining — otherwise the drain would block on it until the timeout.
135    config_watch: Option<tokio::task::AbortHandle>,
136}
137
138impl EffectRunner {
139    /// Create an unused runner. Pair with `msg_rx` from `channel()`.
140    pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
141        Self {
142            msg_tx,
143            scopes: HashMap::new(),
144            cancelled_turns: VecDeque::new(),
145            detached: tokio::task::JoinSet::new(),
146            workdir,
147            providers: None,
148            tools: None,
149            task_id: None,
150            terminal_title_enabled: true,
151            owns_global_mcp: true,
152            approval: None,
153            config_watch: None,
154        }
155    }
156
157    /// Enable inline approval prompts (interactive TUI only). The gate then
158    /// pauses gated tools and routes the user's decision through the
159    /// `ApprovalBroker` instead of writing an out-of-band DB approval row.
160    pub fn with_interactive_approvals(mut self) -> Self {
161        self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
162        self
163    }
164
165    /// Start the background config watcher (#45): it polls `MERMAID.md` + memory
166    /// and emits `Msg::InstructionsChanged`/`MemoryChanged` on change, so the
167    /// reducer reads them as injected data instead of refreshing inline. Call
168    /// once at startup. Live-loop only — a replay driver feeds the recorded
169    /// Changed Msgs rather than polling.
170    pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
171        let handle = self.detached.spawn(config_watch::config_watcher(
172            self.msg_tx.clone(),
173            cwd,
174            memory,
175        ));
176        self.config_watch = Some(handle);
177    }
178
179    /// Attach a durable runtime task id so tool runs, approvals,
180    /// checkpoints, compactions, and background processes can be linked.
181    pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
182        self.task_id = task_id;
183        self
184    }
185
186    /// Disable terminal-title writes for non-interactive callers.
187    pub fn without_terminal_title(mut self) -> Self {
188        self.terminal_title_enabled = false;
189        self
190    }
191
192    /// Leave the process-global MCP manager alone on `shutdown`. Child
193    /// (subagent) runners share it with the parent and must not reap it.
194    pub fn without_global_mcp_shutdown(mut self) -> Self {
195        self.owns_global_mcp = false;
196        self
197    }
198
199    /// Attach provider + tool registries. Production wiring uses
200    /// this; unit tests that don't need real dispatch can skip.
201    /// Without bindings, `CallModel` / `ExecuteTool` emit well-
202    /// formed error Msgs so the reducer still transitions cleanly.
203    pub fn with_bindings(
204        mut self,
205        providers: Arc<ProviderFactory>,
206        tools: Arc<ToolRegistry>,
207    ) -> Self {
208        self.providers = Some(providers);
209        self.tools = Some(tools);
210        self
211    }
212
213    /// Pair-constructor: returns both the runner and the receiving
214    /// end of the Msg channel. Preferred for production wiring
215    /// because it keeps the channel capacity constant in one place.
216    pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
217        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
218        (Self::new(tx, workdir), rx)
219    }
220
221    /// Pair constructor that also wires the real provider factory +
222    /// tool registry. Used by `app::run_interactive`.
223    pub fn pair_with_bindings(
224        workdir: PathBuf,
225        config: Config,
226        tools: Arc<ToolRegistry>,
227    ) -> (Self, mpsc::Receiver<Msg>) {
228        let providers = Arc::new(ProviderFactory::new(config));
229        Self::pair_from(workdir, providers, tools)
230    }
231
232    /// Pair constructor that takes a pre-built `ProviderFactory`.
233    /// Used when the caller needs to share a `ProviderFactory` with
234    /// the `SubagentSpawner` so subagents can issue model calls
235    /// through the same cache.
236    pub fn pair_from(
237        workdir: PathBuf,
238        providers: Arc<ProviderFactory>,
239        tools: Arc<ToolRegistry>,
240    ) -> (Self, mpsc::Receiver<Msg>) {
241        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
242        (Self::new(tx, workdir).with_bindings(providers, tools), rx)
243    }
244
245    pub fn pair_from_with_task(
246        workdir: PathBuf,
247        providers: Arc<ProviderFactory>,
248        tools: Arc<ToolRegistry>,
249        task_id: Option<String>,
250    ) -> (Self, mpsc::Receiver<Msg>) {
251        let (runner, rx) = Self::pair_from(workdir, providers, tools);
252        (runner.with_task_id(task_id), rx)
253    }
254
255    /// Construct a runner that shares a pre-derived cancellation
256    /// token for its turn scopes. Used by `SubagentSpawner` so the
257    /// child runner's work aborts as soon as the parent's `ctx.token`
258    /// fires.
259    pub fn new_child(
260        msg_tx: MsgSender,
261        workdir: PathBuf,
262        providers: Arc<ProviderFactory>,
263        tools: Arc<ToolRegistry>,
264    ) -> Self {
265        // A subagent's runner is never the interactive top-level, so it must
266        // NOT emit OSC 2 terminal-title escapes: in a headless `mermaid run`
267        // the parent suppresses them, but an un-suppressed child leaks
268        // `\x1b]2;…\x07` into stdout and corrupts `--format json`/`text` output.
269        // It must also leave the process-global MCP manager running — the
270        // child shares the parent's servers, and reaping them here would kill
271        // MCP for the whole session the moment the first subagent finished.
272        Self::new(msg_tx, workdir)
273            .with_bindings(providers, tools)
274            .without_terminal_title()
275            .without_global_mcp_shutdown()
276    }
277
278    /// Get or create the scope for a turn. Idempotent. The scope is
279    /// retained until `CancelScope` tears it down or it naturally
280    /// drains.
281    fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
282        self.scopes
283            .entry(turn)
284            .or_insert_with(|| TurnScope::new(turn))
285    }
286
287    /// F38: record a cancelled turn in the bounded tombstone ring, evicting the
288    /// oldest id at capacity. Skips duplicates so a re-cancel doesn't churn the
289    /// ring (membership is all `is_tombstoned` checks).
290    fn tombstone_turn(&mut self, turn: TurnId) {
291        if self.cancelled_turns.contains(&turn) {
292            return;
293        }
294        if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
295            self.cancelled_turns.pop_front();
296        }
297        self.cancelled_turns.push_back(turn);
298    }
299
300    /// F38: true iff `turn`'s scope was cancelled (tombstoned). New turn-scoped
301    /// work for such a turn is dropped rather than spinning up a fresh scope.
302    fn is_tombstoned(&self, turn: TurnId) -> bool {
303        self.cancelled_turns.contains(&turn)
304    }
305
306    /// Drop the scope for a turn, signalling cancellation to every
307    /// child first. Safe to call for non-existent turns.
308    ///
309    /// After the scope is cancelled, a detached task moves it off the
310    /// runner, drains its `JoinSet` (so child tasks unwind), then emits
311    /// `Msg::TurnCancelled(turn)` so the reducer can transition
312    /// `Cancelling → Idle`. Without this terminal event the TUI would
313    /// stick in `Cancelling` — the reducer has no other way to learn
314    /// that the abort fully landed.
315    fn drop_scope(&mut self, turn: TurnId) {
316        // F38: tombstone this turn so a stray post-cancel turn-scoped Cmd can't
317        // resurrect an un-cancelled scope for it. Recorded for both the live and
318        // already-reaped branches below — once cancelled, a turn is dead either
319        // way (turn ids are monotonic and never reused).
320        self.tombstone_turn(turn);
321        if let Some(mut scope) = self.scopes.remove(&turn) {
322            scope.cancel();
323            let tx = self.msg_tx.clone();
324            self.detached.spawn(async move {
325                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
326                    .await
327                    .is_err()
328                {
329                    tracing::warn!(
330                        turn = %turn,
331                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
332                        "cancel drain timed out; aborting remaining scoped tasks"
333                    );
334                }
335                let _ = tx.send(Msg::TurnCancelled(turn)).await;
336            });
337        } else {
338            // The scope was already reaped — its `JoinSet` drained to empty
339            // and `reap_empty_scopes` (top of `dispatch`) removed it before
340            // this cancel landed. The reducer is still in `Cancelling` with
341            // no other way to learn the turn ended, so emit the terminal
342            // event anyway. Idempotent: `handle_turn_cancelled` no-ops on
343            // any turn that isn't currently `Cancelling`.
344            let tx = self.msg_tx.clone();
345            self.detached.spawn(async move {
346                let _ = tx.send(Msg::TurnCancelled(turn)).await;
347            });
348        }
349    }
350
351    /// Number of active per-turn scopes. Tests use this to observe
352    /// lifecycle without racing on internal state.
353    pub fn scope_count(&self) -> usize {
354        self.scopes.len()
355    }
356
357    /// F12: remove scope entries whose `JoinSet` is empty — every
358    /// child task has completed, so the scope is just an orphan key
359    /// in the map. Called at the top of `dispatch` so the map stays
360    /// bounded over long sessions. Cheap: one linear walk, no async.
361    ///
362    /// `JoinSet::is_empty` only returns true after completed tasks are
363    /// harvested via `join_next`/`try_join_next`, so we first drain
364    /// any ready completions per scope.
365    fn reap_empty_scopes(&mut self) {
366        self.reap_detached();
367        self.scopes.retain(|_, scope| {
368            scope.drain_completed();
369            !scope.is_empty()
370        });
371    }
372
373    /// Harvest finished detached tasks. Without this the `detached` JoinSet
374    /// grows for the whole session (every fire-and-forget effect lingers as a
375    /// completed-but-unjoined handle), and a panicking detached task vanishes
376    /// without a trace. Non-blocking — only already-finished tasks are taken (#38).
377    fn reap_detached(&mut self) {
378        while let Some(result) = self.detached.try_join_next() {
379            if let Err(e) = result
380                && !e.is_cancelled()
381            {
382                tracing::warn!(error = %e, "effect: detached task panicked");
383            }
384        }
385    }
386
387    /// Route a single `Cmd` into the appropriate spawn + handler.
388    /// Returns immediately; handlers work asynchronously and emit
389    /// `Msg` back through the sender channel.
390    pub fn dispatch(&mut self, cmd: Cmd) {
391        // F12: reap any drained scopes before touching the map. Keeps
392        // `scope_count()` bounded as the session grows.
393        self.reap_empty_scopes();
394        tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
395
396        // F38: refuse to spawn fresh work for a turn we've already cancelled.
397        // Only the scope-spawning variants carry a `scope_turn()`; `CancelScope`
398        // returns `None` here so a re-cancel still reaches `drop_scope` (which
399        // re-emits the terminal `TurnCancelled` the reducer needs). Turn ids are
400        // monotonic and never reused, so a tombstoned id can only be a stray
401        // post-cancel straggler — dropping it stops `scope_mut`'s `or_insert_with`
402        // from resurrecting an un-cancelled scope.
403        if let Some(turn) = cmd.scope_turn()
404            && self.is_tombstoned(turn)
405        {
406            tracing::debug!(
407                cmd = %cmd.summary(),
408                turn = %turn,
409                "effect: dropping turn-scoped cmd for an already-cancelled turn"
410            );
411            return;
412        }
413
414        match cmd {
415            Cmd::CallModel { turn, mut request } => {
416                let tx = self.msg_tx.clone();
417                let providers = self.providers.clone();
418                // Enrich `request.tools` with every user-facing
419                // tool in the bound registry. The reducer has
420                // already populated MCP tools from `state.mcp`;
421                // built-ins come from the runner (which holds the
422                // registry). This keeps `ChatRequest.tools` the
423                // single source of truth for what the model sees.
424                if let Some(tools) = &self.tools {
425                    let mut enriched = tools.describe_all();
426                    // Report the built-in tool-schema token cost so the
427                    // reducer's /context preview can fold it into its MCP-only
428                    // estimate and agree with what the model actually sees.
429                    let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
430                    // Best-effort and cosmetic (the /context preview). This is the
431                    // synchronous dispatch path so we can't await; if the bounded
432                    // channel is momentarily full under heavy streaming, log the
433                    // drop rather than swallowing it silently — the estimate just
434                    // stays briefly stale (#F43).
435                    if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
436                        tracing::debug!(
437                            error = %e,
438                            "effect: dropped builtin tool-schema token estimate (channel full); \
439                             /context preview may be briefly stale"
440                        );
441                    }
442                    enriched.append(&mut request.tools);
443                    request.tools = enriched;
444                }
445                // Detached + off the blocking pool: never run a plugin hook on
446                // the synchronous dispatch path (it would freeze input/render).
447                self.detached.spawn(fire_plugin_hooks(
448                    "prompt_submit",
449                    serde_json::json!({
450                        "turn_id": turn.0,
451                        "model_id": request.model_id.clone(),
452                        "message_count": request.messages.len(),
453                        "tool_count": request.tools.len(),
454                    }),
455                ));
456                let scope = self.scope_mut(turn);
457                let token = scope.token();
458                scope.spawn(async move {
459                    use futures::FutureExt;
460                    let fallback_tx = tx.clone();
461                    if std::panic::AssertUnwindSafe(dispatch_call_model(
462                        tx, providers, turn, request, token,
463                    ))
464                    .catch_unwind()
465                    .await
466                    .is_err()
467                    {
468                        // The dispatch task panicked. A turn whose model call
469                        // never emits a terminal Msg stays in `Generating`
470                        // forever; emit one so the reducer can leave that state
471                        // instead of wedging (#43).
472                        tracing::error!(turn = %turn, "dispatch_call_model panicked");
473                        let _ = fallback_tx
474                            .send(Msg::UpstreamError {
475                                turn,
476                                error: crate::models::UserFacingError {
477                                    summary: "Internal error".to_string(),
478                                    message: "The model dispatch task panicked unexpectedly."
479                                        .to_string(),
480                                    suggestion: "This is a bug. Please retry; if it persists, \
481                                                 check the logs."
482                                        .to_string(),
483                                    category: crate::models::ErrorCategory::Internal,
484                                    recoverable: true,
485                                },
486                            })
487                            .await;
488                    }
489                });
490            },
491            Cmd::CompactConversation { turn, mut request } => {
492                let tx = self.msg_tx.clone();
493                let providers = self.providers.clone();
494                if let Some(tools) = &self.tools {
495                    let mut enriched = tools.describe_all();
496                    enriched.append(&mut request.chat.tools);
497                    request.chat.tools = enriched;
498                }
499                // Capture the trigger before `request` moves into the task, so a
500                // panic fallback can still name which compaction failed.
501                let trigger = request.trigger;
502                let scope = self.scope_mut(turn);
503                let token = scope.token();
504                scope.spawn(async move {
505                    use futures::FutureExt;
506                    let fallback_tx = tx.clone();
507                    if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
508                        tx, providers, turn, request, token,
509                    ))
510                    .catch_unwind()
511                    .await
512                    .is_err()
513                    {
514                        // The compaction task panicked. Without a terminal
515                        // `CompactionFinished`/`CompactionFailed`, the reducer
516                        // wedges in `Compacting` until Ctrl+C; emit a failure so
517                        // it can recover, mirroring `CallModel`/`ExecuteTool`
518                        // (#43, F37).
519                        tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
520                        let _ = fallback_tx
521                            .send(Msg::CompactionFailed {
522                                turn,
523                                trigger,
524                                message: "the compaction task panicked unexpectedly".to_string(),
525                                kind: crate::domain::StatusKind::Error,
526                            })
527                            .await;
528                    }
529                });
530            },
531            Cmd::ExecuteTool {
532                turn,
533                call_id,
534                source,
535                model_id,
536                safety_mode,
537                intent,
538            } => {
539                let tx = self.msg_tx.clone();
540                let tools = self.tools.clone();
541                let workdir = self.workdir.clone();
542                // Pass the shared Config from ProviderFactory so
543                // subagents inherit it (F7). Falls back to
544                // Config::default() when providers aren't bound (unit
545                // tests without real wiring).
546                let config = self
547                    .providers
548                    .as_ref()
549                    .map(|p| Arc::new(p.config().clone()))
550                    .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
551                // Auto mode: build an LLM classifier to vet borderline
552                // actions. Only when a provider is bound (real wiring); the
553                // gate fails safe to "escalate" when it's `None`. The vet
554                // uses the configured classifier model, else the session model.
555                let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
556                    if safety_mode == crate::runtime::SafetyMode::Auto {
557                        self.providers.as_ref().map(|p| {
558                            let model = config
559                                .safety
560                                .auto_classifier_model
561                                .clone()
562                                .unwrap_or_else(|| model_id.clone());
563                            Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
564                                as Arc<dyn crate::providers::AutoClassifier>
565                        })
566                    } else {
567                        None
568                    };
569                let task_id = self.task_id.clone();
570                let approval = self.approval.clone();
571                let scope = self.scope_mut(turn);
572                let token = scope.token();
573                let background = scope.background_token();
574                scope.spawn(async move {
575                    use futures::FutureExt;
576                    let fallback_tx = tx.clone();
577                    if std::panic::AssertUnwindSafe(dispatch_execute_tool(
578                        tx,
579                        tools,
580                        workdir,
581                        turn,
582                        call_id,
583                        source,
584                        token,
585                        background,
586                        config,
587                        model_id,
588                        task_id,
589                        safety_mode,
590                        intent,
591                        classifier,
592                        approval,
593                    ))
594                    .catch_unwind()
595                    .await
596                    .is_err()
597                    {
598                        // The tool task panicked. Its turn waits on a
599                        // `ToolFinished` for this `call_id` that will now never
600                        // arrive; emit a terminal error outcome so the turn
601                        // doesn't wedge (#43).
602                        tracing::error!(
603                            turn = %turn,
604                            call_id = call_id.0,
605                            "dispatch_execute_tool panicked"
606                        );
607                        let _ = fallback_tx
608                            .send(Msg::ToolFinished {
609                                turn,
610                                call_id,
611                                outcome: crate::domain::ToolOutcome::error(
612                                    "internal error: the tool execution task panicked".to_string(),
613                                    0.0,
614                                ),
615                            })
616                            .await;
617                    }
618                });
619            },
620            Cmd::ResolveApproval { call_id, decision } => {
621                // Deliver the user's inline decision to the parked tool task.
622                // Not turn-scoped — fire-and-forget to the broker.
623                if let Some(broker) = &self.approval {
624                    broker.resolve(call_id, decision.into());
625                }
626            },
627            Cmd::CancelScope(turn) => {
628                self.drop_scope(turn);
629            },
630            Cmd::BackgroundScope(turn) => {
631                // Fire the scope's background token (don't drop the scope):
632                // detachable tools move their child to a background process and
633                // return a normal outcome, so the turn finishes naturally.
634                self.scope_mut(turn).background();
635            },
636            Cmd::SaveConversation(history) => {
637                let tx = self.msg_tx.clone();
638                let workdir = self.workdir.clone();
639                self.detached.spawn(async move {
640                    if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
641                        && manager.save_conversation(&history).is_ok()
642                    {
643                        let _ = tx.send(Msg::SessionSaved).await;
644                    } else {
645                        tracing::warn!("SaveConversation: failed to write to disk");
646                    }
647                });
648            },
649            Cmd::SaveCompactionArchive {
650                archive,
651                record,
652                conversation,
653            } => {
654                let tx = self.msg_tx.clone();
655                let workdir = self.workdir.clone();
656                let task_id = self.task_id.clone();
657                self.detached.spawn(async move {
658                    let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
659                        return;
660                    };
661                    // Archive FIRST — it is the only durable copy of the
662                    // dropped messages. Only overwrite the (stripped)
663                    // conversation once the archive has persisted, so a
664                    // failed archive can never lose messages.
665                    match manager.save_compaction_archive(&archive) {
666                        Ok(path) => {
667                            if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
668                                let compaction_id = record.id.clone();
669                                let conversation_id = archive.conversation_id.clone();
670                                let _ =
671                                    store.compactions().create(crate::runtime::NewCompaction {
672                                        id: Some(record.id),
673                                        task_id: task_id.clone(),
674                                        session_id: Some(archive.conversation_id),
675                                        source_token_estimate: Some(record.before_tokens as i64),
676                                        summary_token_count: Some(record.summary_tokens as i64),
677                                        preserved_turns: Some(record.preserved_message_count as i64),
678                                        archive_path: Some(path.display().to_string()),
679                                        verification_status: Some("verified".to_string()),
680                                    });
681                                fire_plugin_hooks(
682                                    "compaction",
683                                    serde_json::json!({
684                                        "id": compaction_id,
685                                        "task_id": task_id.clone(),
686                                        "session_id": conversation_id,
687                                        "archive_path": path.display().to_string(),
688                                    }),
689                                )
690                                .await;
691                            }
692                            if manager.save_conversation(&conversation).is_ok() {
693                                let _ = tx.send(Msg::SessionSaved).await;
694                            } else {
695                                tracing::warn!(
696                                    "SaveCompactionArchive: archive persisted but conversation save failed"
697                                );
698                            }
699                        },
700                        Err(err) => {
701                            tracing::warn!(
702                                error = %err,
703                                "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
704                            );
705                        },
706                    }
707                });
708            },
709            Cmd::SaveProcess(process) => {
710                let task_id = self.task_id.clone();
711                self.detached.spawn(async move {
712                    let status = match process.status {
713                        crate::domain::ManagedProcessStatus::Running => {
714                            crate::runtime::ProcessStatus::Running
715                        },
716                        crate::domain::ManagedProcessStatus::Exited => {
717                            crate::runtime::ProcessStatus::Exited
718                        },
719                        crate::domain::ManagedProcessStatus::Unknown => {
720                            crate::runtime::ProcessStatus::Unknown
721                        },
722                    };
723                    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
724                        let _ = store.processes().upsert(crate::runtime::NewProcess {
725                            id: Some(process.id),
726                            task_id,
727                            pid: process.pid,
728                            command: process.command,
729                            cwd: process.cwd,
730                            log_path: Some(process.log_path),
731                            detected_url: process.detected_url,
732                            status,
733                            health: None,
734                        });
735                    }
736                });
737            },
738            Cmd::PersistLastModel(model) => {
739                self.detached.spawn(async move {
740                    let _ = crate::app::persist_last_model(&model);
741                });
742            },
743            Cmd::PersistReasoningFor { model_id, level } => {
744                self.detached.spawn(async move {
745                    let _ = crate::app::persist_reasoning_for_model(&model_id, level);
746                });
747            },
748            Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
749                self.detached.spawn(async move {
750                    let _ = crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx);
751                });
752            },
753            Cmd::PersistOllamaOffload(enabled) => {
754                self.detached.spawn(async move {
755                    let _ = crate::app::persist_ollama_allow_ram_offload(enabled);
756                });
757            },
758            Cmd::ListMemory => {
759                let tx = self.msg_tx.clone();
760                let workdir = self.workdir.clone();
761                self.detached.spawn(async move {
762                    let cfg = crate::app::load_config().unwrap_or_default().memory;
763                    let text = match crate::app::memory::load(&workdir, &cfg) {
764                        Some(mem) => mem.index,
765                        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(),
766                    };
767                    let _ = tx.send(Msg::RuntimeText(text)).await;
768                });
769            },
770            Cmd::RememberMemory { text } => {
771                let tx = self.msg_tx.clone();
772                let workdir = self.workdir.clone();
773                self.detached.spawn(async move {
774                    let cfg = crate::app::load_config().unwrap_or_default().memory;
775                    let name = memory_title_from_text(&text);
776                    let status = match crate::app::memory::write_memory(
777                        &workdir,
778                        crate::app::memory::MemoryScope::ProjectPrivate,
779                        &name,
780                        &text,
781                        &[],
782                        &text,
783                    ) {
784                        Ok(_) => format!("Remembered: {name}"),
785                        Err(e) => format!("Couldn't save memory: {e}"),
786                    };
787                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
788                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
789                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
790                });
791            },
792            Cmd::ForgetMemory { id } => {
793                let tx = self.msg_tx.clone();
794                let workdir = self.workdir.clone();
795                self.detached.spawn(async move {
796                    let cfg = crate::app::load_config().unwrap_or_default().memory;
797                    let status = match crate::app::memory::delete_memory(&workdir, &id) {
798                        Ok(Some(_)) => format!("Forgot: {id}"),
799                        Ok(None) => format!("No memory named '{id}'"),
800                        Err(e) => format!("Couldn't forget memory: {e}"),
801                    };
802                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
803                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
804                    let _ = tx.send(Msg::TransientStatus { text: status }).await;
805                });
806            },
807            Cmd::ConsolidateMemory { model_id } => {
808                let tx = self.msg_tx.clone();
809                let workdir = self.workdir.clone();
810                let providers = self.providers.clone();
811                self.detached.spawn(async move {
812                    consolidate_memory(tx, providers, workdir, model_id).await;
813                });
814            },
815            Cmd::LoadConversation(id) => {
816                let tx = self.msg_tx.clone();
817                let workdir = self.workdir.clone();
818                self.detached.spawn(async move {
819                    match crate::session::ConversationManager::new(&workdir) {
820                        Ok(mgr) => match mgr.load_conversation(&id) {
821                            Ok(history) => {
822                                let _ = tx.send(Msg::ConversationLoaded(history)).await;
823                            },
824                            Err(e) => {
825                                tracing::warn!(id = %id, error = %e, "LoadConversation failed");
826                            },
827                        },
828                        Err(e) => {
829                            tracing::warn!(error = %e, "ConversationManager init failed");
830                        },
831                    }
832                });
833            },
834            Cmd::ListConversations => {
835                let tx = self.msg_tx.clone();
836                let workdir = self.workdir.clone();
837                self.detached.spawn(async move {
838                    let summaries = match crate::session::ConversationManager::new(&workdir) {
839                        Ok(mgr) => mgr
840                            .list_conversations()
841                            .unwrap_or_default()
842                            .into_iter()
843                            .map(|h| crate::domain::ConversationSummary {
844                                id: h.id.clone(),
845                                title: h.title.clone(),
846                                message_count: h.messages.len(),
847                                updated_at: h.updated_at.to_rfc3339(),
848                            })
849                            .collect(),
850                        Err(_) => Vec::new(),
851                    };
852                    let _ = tx.send(Msg::ConversationsListed(summaries)).await;
853                });
854            },
855            Cmd::ListRuntimeTasks { limit } => {
856                let tx = self.msg_tx.clone();
857                // Synchronous rusqlite read — run on the blocking pool so it
858                // never stalls an async worker thread (#40).
859                self.detached.spawn_blocking(move || {
860                    let tasks = crate::runtime::RuntimeClient::auto()
861                        .list_tasks(limit)
862                        .map(|read| read.value)
863                        .unwrap_or_default();
864                    let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
865                });
866            },
867            Cmd::LoadRuntimeTask { id } => {
868                let tx = self.msg_tx.clone();
869                self.detached.spawn_blocking(move || {
870                    let (task, events) = crate::runtime::RuntimeClient::auto()
871                        .task_detail(&id)
872                        .map(|read| (Some(read.value.task), read.value.events))
873                        .unwrap_or((None, Vec::new()));
874                    let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
875                });
876            },
877            Cmd::ListRuntimeProcesses { limit } => {
878                let tx = self.msg_tx.clone();
879                self.detached.spawn_blocking(move || {
880                    let processes = crate::runtime::RuntimeClient::auto()
881                        .list_processes(limit)
882                        .map(|read| read.value)
883                        .unwrap_or_default();
884                    let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
885                });
886            },
887            Cmd::ShowRuntimeProcessLogs { id } => {
888                let tx = self.msg_tx.clone();
889                self.detached.spawn_blocking(move || {
890                    let text = crate::runtime::RuntimeClient::auto()
891                        .process_log(&id, None)
892                        .map(|log| format!("Process log {}\n\n{}", id, log.content))
893                        .unwrap_or_else(|err| format!("Process log error: {}", err));
894                    let _ = tx.blocking_send(Msg::RuntimeText(text));
895                });
896            },
897            Cmd::StopRuntimeProcess { id } => {
898                let tx = self.msg_tx.clone();
899                self.detached.spawn_blocking(move || {
900                    let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
901                        Ok(response) => Msg::TransientStatus {
902                            text: format!("Stopped process {} (pid {})", id, response.item.pid),
903                        },
904                        Err(err) => Msg::TransientStatus {
905                            text: format!("Process stop failed: {}", err),
906                        },
907                    };
908                    let _ = tx.blocking_send(msg);
909                });
910            },
911            Cmd::RestartRuntimeProcess { id } => {
912                let tx = self.msg_tx.clone();
913                self.detached.spawn_blocking(move || {
914                    let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
915                        Ok(response) => Msg::TransientStatus {
916                            text: format!("Restarted process {} (pid {})", id, response.item.pid),
917                        },
918                        Err(err) => Msg::TransientStatus {
919                            text: format!("Process restart failed: {}", err),
920                        },
921                    };
922                    let _ = tx.blocking_send(msg);
923                });
924            },
925            Cmd::OpenRuntimeTarget { target } => {
926                self.detached.spawn_blocking(move || {
927                    let resolved = crate::runtime::RuntimeService::open_default()
928                        .and_then(|service| service.resolve_open_target(&target))
929                        .unwrap_or(target);
930                    // #63: the resolved value can be a `detected_url`/`log_path`
931                    // from a `processes` row — validate before the OS opener,
932                    // exactly like `open_process`.
933                    if let Err(err) = crate::runtime::validate_open_target(&resolved) {
934                        tracing::warn!(error = %err, "refusing to open runtime target");
935                        return;
936                    }
937                    crate::utils::open_file(resolved);
938                });
939            },
940            Cmd::ShowRuntimePorts => {
941                let tx = self.msg_tx.clone();
942                self.detached.spawn_blocking(move || {
943                    let text = crate::runtime::RuntimeClient::auto()
944                        .ports()
945                        .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
946                        .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
947                    let _ = tx.blocking_send(Msg::RuntimeText(text));
948                });
949            },
950            Cmd::ListRuntimeApprovals => {
951                let tx = self.msg_tx.clone();
952                self.detached.spawn_blocking(move || {
953                    let approvals = crate::runtime::RuntimeClient::auto()
954                        .list_approvals()
955                        .map(|read| read.value)
956                        .unwrap_or_default();
957                    let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
958                });
959            },
960            Cmd::DecideRuntimeApproval { id, decision } => {
961                let tx = self.msg_tx.clone();
962                self.detached.spawn_blocking(move || {
963                    let result = if decision == "approved" {
964                        crate::runtime::RuntimeClient::auto().approve(&id)
965                    } else {
966                        crate::runtime::RuntimeClient::auto().deny(&id)
967                    };
968                    let msg = match result {
969                        Ok(result) => Msg::TransientStatus {
970                            text: if result.replayed {
971                                format!("Approval {} {}: {}", id, decision, result.summary)
972                            } else {
973                                format!("Approval {} {}", id, decision)
974                            },
975                        },
976                        Err(err) => Msg::TransientStatus {
977                            text: format!("Approval update failed: {}", err),
978                        },
979                    };
980                    let _ = tx.blocking_send(msg);
981                });
982            },
983            Cmd::ListRuntimeCheckpoints { limit } => {
984                let tx = self.msg_tx.clone();
985                self.detached.spawn_blocking(move || {
986                    let checkpoints = crate::runtime::RuntimeClient::auto()
987                        .list_checkpoints(limit)
988                        .map(|read| read.value)
989                        .unwrap_or_default();
990                    let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
991                });
992            },
993            Cmd::ListRuntimePlugins => {
994                let tx = self.msg_tx.clone();
995                self.detached.spawn_blocking(move || {
996                    let plugins = crate::runtime::RuntimeClient::auto()
997                        .list_plugins()
998                        .map(|read| read.value)
999                        .unwrap_or_default();
1000                    let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
1001                });
1002            },
1003            Cmd::UpdateRuntimeTaskStatus {
1004                id,
1005                status,
1006                final_report,
1007            } => {
1008                let tx = self.msg_tx.clone();
1009                self.detached.spawn_blocking(move || {
1010                    let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
1011                        store
1012                            .tasks()
1013                            .update_status(&id, status, final_report.as_deref())
1014                    }) {
1015                        Ok(()) => Msg::TransientStatus {
1016                            text: format!("Task {} -> {}", id, status),
1017                        },
1018                        Err(err) => Msg::TransientStatus {
1019                            text: format!("Task update failed: {}", err),
1020                        },
1021                    };
1022                    let _ = tx.blocking_send(msg);
1023                });
1024            },
1025            Cmd::CreateRuntimeCheckpoint { paths } => {
1026                let tx = self.msg_tx.clone();
1027                let workdir = self.workdir.clone();
1028                self.detached.spawn_blocking(move || {
1029                    let pending_action = Some(serde_json::json!({
1030                        "source": "tui",
1031                        "command": "checkpoint",
1032                    }));
1033                    let msg =
1034                        match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
1035                            Ok(manifest) => Msg::TransientStatus {
1036                                text: format!(
1037                                    "Checkpoint {} created for {} path(s)",
1038                                    manifest.id,
1039                                    manifest.files.len()
1040                                ),
1041                            },
1042                            Err(err) => Msg::TransientStatus {
1043                                text: format!("Checkpoint failed: {}", err),
1044                            },
1045                        };
1046                    let _ = tx.blocking_send(msg);
1047                });
1048            },
1049            Cmd::RestoreRuntimeCheckpoint { id } => {
1050                let tx = self.msg_tx.clone();
1051                self.detached.spawn_blocking(move || {
1052                    let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1053                        Ok(result) => Msg::TransientStatus {
1054                            text: format!(
1055                                "Restored checkpoint {} ({} file(s)){}",
1056                                result.checkpoint.id,
1057                                result.checkpoint.files.len(),
1058                                if result.checkpoint.pending_action.is_some() {
1059                                    "; pending action available in checkpoint manifest"
1060                                } else {
1061                                    ""
1062                                }
1063                            ),
1064                        },
1065                        Err(err) => Msg::TransientStatus {
1066                            text: format!("Restore failed: {}", err),
1067                        },
1068                    };
1069                    let _ = tx.blocking_send(msg);
1070                });
1071            },
1072            Cmd::ShowRuntimeModelInfo { model } => {
1073                let tx = self.msg_tx.clone();
1074                self.detached.spawn_blocking(move || {
1075                    let text = runtime_model_info_text(&model);
1076                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1077                });
1078            },
1079            Cmd::InitMcpServers(configs) => {
1080                let tx = self.msg_tx.clone();
1081                self.detached.spawn(async move {
1082                    if configs.is_empty() {
1083                        return;
1084                    }
1085                    crate::mcp::manager_ref::mark_init_started();
1086                    let manager =
1087                        std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
1088                    // Emit a Ready or Errored per server based on
1089                    // what came up. Zero-tool servers are still ready
1090                    // if the manager has an active client for them.
1091                    for (name, _cfg) in configs.iter() {
1092                        let server_tools: Vec<crate::domain::McpToolSpec> = manager
1093                            .get_all_tools()
1094                            .iter()
1095                            .filter(|(server, _)| server == name)
1096                            .map(|(_, def)| crate::domain::McpToolSpec {
1097                                name: def.name.clone(),
1098                                description: def.description.clone(),
1099                                input_schema: def.input_schema.clone(),
1100                            })
1101                            .collect();
1102                        let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
1103                        let _ = tx.send(msg).await;
1104                    }
1105                    crate::mcp::manager_ref::set_manager(manager);
1106                    crate::mcp::manager_ref::mark_init_complete();
1107                });
1108            },
1109            Cmd::StopMcpServer { name } => {
1110                let tx = self.msg_tx.clone();
1111                self.detached.spawn(async move {
1112                    // Actually kill the child before claiming it's stopped —
1113                    // otherwise the UI says "stopped" while the server runs on.
1114                    if let Some(mgr) = crate::mcp::manager_ref::get() {
1115                        mgr.stop_server(&name).await;
1116                    }
1117                    let _ = tx.send(Msg::McpServerStopped { name }).await;
1118                });
1119            },
1120            Cmd::PullOllamaModel { model } => {
1121                let tx = self.msg_tx.clone();
1122                self.detached.spawn(async move {
1123                    dispatch_pull_ollama_model(tx, model).await;
1124                });
1125            },
1126            Cmd::OpenInSystem(path) => {
1127                self.detached.spawn(async move {
1128                    let _ = tokio::task::spawn_blocking(move || {
1129                        crate::utils::open_file(&path);
1130                    })
1131                    .await;
1132                });
1133            },
1134            Cmd::WriteImageToTemp {
1135                path,
1136                bytes,
1137                format: _,
1138            } => {
1139                self.detached.spawn(async move {
1140                    if let Err(e) = tokio::fs::write(&path, &bytes).await {
1141                        tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1142                    }
1143                });
1144            },
1145            Cmd::ReadClipboard => {
1146                let tx = self.msg_tx.clone();
1147                self.detached.spawn(async move {
1148                    dispatch_read_clipboard(tx).await;
1149                });
1150            },
1151            Cmd::CopyToClipboard(text) => {
1152                let tx = self.msg_tx.clone();
1153                self.detached.spawn(async move {
1154                    dispatch_copy_to_clipboard(text, tx).await;
1155                });
1156            },
1157            Cmd::Exit => {
1158                // The main loop observes `state.should_exit` after
1159                // the reducer returns; the runner doesn't need to
1160                // take any special action. Documented here for
1161                // exhaustiveness.
1162            },
1163            Cmd::SetTerminalTitle(title) => {
1164                if !self.terminal_title_enabled {
1165                    return;
1166                }
1167                // Offload the terminal write to the blocking pool: writing to
1168                // stdout can block when the terminal (or a downstream pipe) is
1169                // slow, and an async worker must not block on it (#44). The
1170                // OSC-2 title sequence is out-of-band relative to the renderer's
1171                // frame draws, so it doesn't corrupt them.
1172                self.detached.spawn_blocking(move || {
1173                    use std::io::Write;
1174                    let seq = format!("\x1b]2;{}\x07", title);
1175                    let mut stdout = std::io::stdout();
1176                    let _ = stdout.write_all(seq.as_bytes());
1177                    let _ = stdout.flush();
1178                });
1179            },
1180        }
1181    }
1182
1183    /// Async shutdown: cancel every scope, then wait for all spawned
1184    /// work to drain. Bounded by 5 seconds — a hung task past that
1185    /// gets aborted outright by `JoinSet::drop`.
1186    pub async fn shutdown(mut self) {
1187        for (id, scope) in self.scopes.iter() {
1188            tracing::debug!(turn = %id, "shutdown: cancelling scope");
1189            scope.cancel();
1190        }
1191
1192        // The config watcher (#45) is a perpetual loop in `detached`; abort it
1193        // so the drain below doesn't block on it until the bounded timeout.
1194        if let Some(handle) = self.config_watch.take() {
1195            handle.abort();
1196        }
1197
1198        // Drain with a bounded timeout.
1199        let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1200
1201        let owns_global_mcp = self.owns_global_mcp;
1202        let drain = async {
1203            // Only the top-level runner reaps the process-global MCP manager.
1204            // A subagent's child runner shares it; reaping here would kill the
1205            // parent's servers the moment the first subagent finished.
1206            if owns_global_mcp {
1207                // If an MCP init is still in flight, its child processes are
1208                // already spawned but `set_manager` hasn't run yet — `get()`
1209                // below would return `None` and we'd leak those children. Wait
1210                // (bounded) for init to settle so the manager is installed
1211                // before we reap it (#59).
1212                let _ = tokio::time::timeout(
1213                    std::time::Duration::from_secs(2),
1214                    crate::mcp::manager_ref::wait_ready(),
1215                )
1216                .await;
1217                // Gracefully shut down MCP server children (the stdin-EOF →
1218                // terminate → kill ladder in `McpServerManager::shutdown`). The
1219                // manager lives in a `'static OnceLock` that never drops, so
1220                // this explicit call on the exit path is the only thing that
1221                // reaps those child processes. No-op when no servers were
1222                // configured.
1223                if let Some(mgr) = crate::mcp::manager_ref::get() {
1224                    mgr.shutdown().await;
1225                }
1226                // Tear down the auto-managed SearXNG container (zero-config
1227                // web_search). Same ownership rule as MCP: only the top-level
1228                // runner reaps process-global services. No-op if none started.
1229                crate::searxng::shutdown().await;
1230            }
1231            // F42: bound each per-scope drain so one non-cooperative task can't
1232            // eat the whole shutdown budget and starve the remaining scopes'
1233            // drains (the scopes were all cancelled above, so a well-behaved task
1234            // unwinds well within this). On timeout, dropping `scope` aborts its
1235            // still-running `JoinSet` members via `TurnScope::drop`.
1236            for (id, mut scope) in self.scopes.drain() {
1237                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1238                    .await
1239                    .is_err()
1240                {
1241                    tracing::warn!(
1242                        turn = %id,
1243                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1244                        "shutdown: scope drain timed out; aborting its remaining tasks"
1245                    );
1246                }
1247            }
1248            while let Some(result) = self.detached.join_next().await {
1249                if let Err(e) = result
1250                    && !e.is_cancelled()
1251                {
1252                    tracing::warn!(error = %e, "shutdown: detached task panic");
1253                }
1254            }
1255        };
1256
1257        let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1258    }
1259}
1260
1261/// Dispatch a `CallModel` command. Resolves the provider (lazy,
1262/// cached) and streams its events onto the Msg channel. Without a
1263/// bound `ProviderFactory` (unit tests), emits a single
1264/// `UpstreamError` so the reducer ends the turn cleanly.
1265/// Await a sibling relay task's handle, logging a panic (but not a normal
1266/// post-cancellation abort). These relays run alongside the provider/tool call
1267/// for streaming backpressure; awaiting their handle keeps a stray panic from
1268/// vanishing the way a bare `let _ = handle.await` would (#58, #60).
1269async fn join_logged(handle: tokio::task::JoinHandle<()>, what: &str) {
1270    if let Err(e) = handle.await
1271        && !e.is_cancelled()
1272    {
1273        tracing::warn!(error = %e, task = what, "effect: sibling relay task panicked");
1274    }
1275}
1276
1277/// Owns a sibling relay task so it cannot outlive its parent future as a detached
1278/// task: if the parent is dropped before it `take()`s the handle for
1279/// `join_logged`, the guard aborts the task on drop (#F39). On the normal path the
1280/// handle is taken and awaited, so the drop is a no-op. The relay is already
1281/// bounded by the turn `CancellationToken`; this makes the "every turn task is
1282/// owned" invariant hold structurally rather than only behaviorally.
1283struct AbortOnDrop(Option<tokio::task::JoinHandle<()>>);
1284
1285impl AbortOnDrop {
1286    fn take(mut self) -> tokio::task::JoinHandle<()> {
1287        self.0.take().expect("AbortOnDrop::take called once")
1288    }
1289}
1290
1291impl Drop for AbortOnDrop {
1292    fn drop(&mut self) {
1293        if let Some(handle) = &self.0 {
1294            handle.abort();
1295        }
1296    }
1297}
1298
1299/// `tokio::spawn` a relay, wrapped in an [`AbortOnDrop`] so the parent owns it.
1300fn spawn_guarded<F>(fut: F) -> AbortOnDrop
1301where
1302    F: std::future::Future<Output = ()> + Send + 'static,
1303{
1304    AbortOnDrop(Some(tokio::spawn(fut)))
1305}
1306
1307async fn dispatch_call_model(
1308    msg_tx: MsgSender,
1309    providers: Option<Arc<ProviderFactory>>,
1310    turn: TurnId,
1311    mut request: crate::domain::ChatRequest,
1312    token: tokio_util::sync::CancellationToken,
1313) {
1314    use crate::models::UserFacingError;
1315
1316    let Some(factory) = providers else {
1317        let error = UserFacingError {
1318            summary: "not wired".to_string(),
1319            message: "EffectRunner has no ProviderFactory bound".to_string(),
1320            suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1321            category: crate::models::ErrorCategory::Internal,
1322            recoverable: false,
1323        };
1324        let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1325        return;
1326    };
1327
1328    // Lazily resolve the provider for this model.
1329    let provider = match factory.resolve(&request.model_id).await {
1330        Ok(p) => p,
1331        Err(e) => {
1332            let error = classify_error_for_ui(&e);
1333            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1334            return;
1335        },
1336    };
1337    {
1338        // Telemetry write — offload the synchronous DB upserts to the blocking
1339        // pool so they never stall this model-call dispatch path, which runs on
1340        // every turn (#39).
1341        let model_id = request.model_id.clone();
1342        let caps = provider.capabilities().clone();
1343        // Own this telemetry write inside the per-turn task (await it) instead of
1344        // a detached `spawn_blocking` whose handle was dropped — so a panic in the
1345        // upsert surfaces and shutdown isn't racing an untracked DB write (#F41).
1346        // It is a few-ms SQLite upsert before a multi-second model call, so
1347        // awaiting it here does not meaningfully stall the turn (the "never stall
1348        // dispatch" rule is about the synchronous reducer path, not this task).
1349        if let Err(e) =
1350            tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
1351                .await
1352        {
1353            tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
1354        }
1355    }
1356    if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1357        let _ = msg_tx
1358            .send(Msg::TransientStatus {
1359                text: format!(
1360                    "{} does not advertise tool support; Mermaid will send the turn without tools",
1361                    request.model_id
1362                ),
1363            })
1364            .await;
1365        request.tools.clear();
1366    }
1367
1368    // Resolve the *effective* context window. For Ollama this probes the model's
1369    // real window and auto-fits num_ctx to memory (cache-first, off the UI
1370    // thread); for other providers it's the static advertised window. Using the
1371    // effective value here is what un-skips auto-compaction for Ollama (which had
1372    // `NoKnownContextLimit`) and gives the status bar real numbers.
1373    let sizing = provider.resolve_context_window(&request).await;
1374    let max_context_tokens = sizing.effective.or_else(|| {
1375        crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1376    });
1377    // Report the resolved window to the reducer for the `/context` display +
1378    // truncation quick-fix. Harmless for non-Ollama (source is None → no extra
1379    // detail shown).
1380    let _ = msg_tx
1381        .send(Msg::ProviderContextResolved {
1382            model_id: request.model_id.clone(),
1383            model_max: sizing.model_max,
1384            effective: sizing.effective,
1385            source: sizing.source,
1386        })
1387        .await;
1388    let context_snapshot =
1389        crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1390    let _ = msg_tx
1391        .send(Msg::ContextUsageEstimated {
1392            turn,
1393            snapshot: context_snapshot.clone(),
1394        })
1395        .await;
1396
1397    let policy = CompactionPolicy::default();
1398    let mut compacted_before_stream = false;
1399    if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1400        let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1401        // Best-effort preflight: if there's nothing to compact, proceed
1402        // un-compacted (the provider's own context limit is the real gate).
1403        if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1404            match run_compaction(
1405                Arc::clone(&provider),
1406                turn,
1407                compaction,
1408                prepared,
1409                context_snapshot.clone(),
1410                max_context_tokens,
1411                token.clone(),
1412            )
1413            .await
1414            {
1415                Ok(result) => {
1416                    request.messages = result.replacement_messages.clone();
1417                    compacted_before_stream = true;
1418                    let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1419                },
1420                Err(err) => {
1421                    // Auto-compaction is best-effort. If it can't reduce the
1422                    // context — the estimate is roughest exactly at the limit, so
1423                    // a large preserved tail can read `after >= before` — don't
1424                    // kill the turn. Log it, surface a soft warning, and proceed
1425                    // with the original request; the provider's own context limit
1426                    // is the real gate. (Manual `/compact` keeps its hard error
1427                    // via `run_compaction`'s reduction guard.)
1428                    if token.is_cancelled() {
1429                        return;
1430                    }
1431                    tracing::warn!(
1432                        turn = %turn,
1433                        error = %err,
1434                        "auto-compaction failed; proceeding with the un-compacted request",
1435                    );
1436                    let _ = msg_tx
1437                        .send(Msg::CompactionFailed {
1438                            turn,
1439                            trigger: CompactionTrigger::AutoThreshold,
1440                            message: err.to_string(),
1441                            kind: crate::domain::StatusKind::Warn,
1442                        })
1443                        .await;
1444                },
1445            }
1446        }
1447    }
1448
1449    // Build a StreamContext — provider writes typed events into the
1450    // internal sink; we relay each to the reducer as a Msg.
1451    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1452    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1453
1454    // Drain stream events into Msgs on a sibling task. Ends when the sink
1455    // closes (provider's final `Done` or completion) OR the turn token is
1456    // cancelled — `select!`ing on the token ties this relay to the turn's
1457    // structured cancellation so a cancel drops it within a tick instead of
1458    // waiting on the next event. (A separate task is required: the relay must
1459    // run concurrently with `provider.chat` for streaming backpressure.)
1460    let relay_tx = msg_tx.clone();
1461    let relay_token = token.clone();
1462    let relay = spawn_guarded(async move {
1463        loop {
1464            let event = tokio::select! {
1465                biased;
1466                _ = relay_token.cancelled() => {
1467                    // #F40: a cancel landing right after the provider finished must
1468                    // not discard the terminal Done it already enqueued. Drain the
1469                    // buffered events and relay only a terminal Done — so the
1470                    // just-completed turn's usage is still recorded — while NOT
1471                    // painting buffered intermediate text (the turn is cancelled).
1472                    // `try_recv` drains the buffer without awaiting more.
1473                    while let Ok(buffered) = stream_rx.try_recv() {
1474                        if let StreamEvent::Done {
1475                            usage,
1476                            thinking_signature,
1477                            stop_reason,
1478                        } = buffered
1479                        {
1480                            let _ = relay_tx
1481                                .send(Msg::StreamDone {
1482                                    turn,
1483                                    usage,
1484                                    thinking_signature,
1485                                    stop_reason,
1486                                })
1487                                .await;
1488                        }
1489                    }
1490                    break;
1491                },
1492                ev = stream_rx.recv() => match ev {
1493                    Some(ev) => ev,
1494                    None => break,
1495                },
1496            };
1497            let msg = match event {
1498                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1499                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1500                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1501                StreamEvent::ThinkingSignature(_) => continue, // folded into Done below
1502                StreamEvent::Done {
1503                    usage,
1504                    thinking_signature,
1505                    stop_reason,
1506                } => Msg::StreamDone {
1507                    turn,
1508                    usage,
1509                    thinking_signature,
1510                    stop_reason,
1511                },
1512            };
1513            if relay_tx.send(msg).await.is_err() {
1514                break;
1515            }
1516        }
1517    });
1518
1519    // Run the actual provider. On error, the relay will have
1520    // already emitted partial events; we follow with a single
1521    // UpstreamError to terminate the turn cleanly.
1522    //
1523    // `ModelError::Cancelled` is swallowed — the terminal
1524    // `Msg::TurnCancelled` is emitted from `drop_scope` after the
1525    // turn's `TurnScope` drains. Emitting `UpstreamError` here would
1526    // commit a "cancelled" message the user didn't ask to see.
1527    let mut completed_ok = false;
1528    match provider.chat(request.clone(), ctx).await {
1529        Ok(_final_response) => {
1530            // Success — the final `Done` flowed through the sink.
1531            completed_ok = true;
1532        },
1533        Err(crate::models::ModelError::Cancelled) => {
1534            // Silent: `drop_scope` will emit `Msg::TurnCancelled`.
1535        },
1536        Err(e) => {
1537            let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1538            if retry_context_limit {
1539                let latest_snapshot =
1540                    crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1541                let compaction =
1542                    CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1543                // Only retry if there's something to compact; otherwise fall
1544                // through to surface the original context-limit error.
1545                if let Ok(prepared) =
1546                    crate::domain::prepare_compaction(&compaction, max_context_tokens)
1547                {
1548                    match run_compaction(
1549                        Arc::clone(&provider),
1550                        turn,
1551                        compaction,
1552                        prepared,
1553                        latest_snapshot,
1554                        max_context_tokens,
1555                        token.clone(),
1556                    )
1557                    .await
1558                    {
1559                        Ok(result) => {
1560                            let mut retry_request = request;
1561                            retry_request.messages = result.replacement_messages.clone();
1562                            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1563                            join_logged(relay.take(), "stream_relay").await;
1564                            dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1565                                .await;
1566                            return;
1567                        },
1568                        Err(compact_err) => {
1569                            let _ = msg_tx
1570                                .send(Msg::CompactionFailed {
1571                                    turn,
1572                                    trigger: CompactionTrigger::ContextLimitRetry,
1573                                    message: compact_err.to_string(),
1574                                    kind: crate::domain::StatusKind::Error,
1575                                })
1576                                .await;
1577                        },
1578                    }
1579                }
1580            }
1581            let error = classify_error_for_ui(&e);
1582            run_provider_error_hook(&request.model_id, &error).await;
1583            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1584        },
1585    }
1586
1587    join_logged(relay.take(), "stream_relay").await;
1588
1589    // Post-turn (success only): verify the model actually fit VRAM. Skipped when
1590    // the user allowed RAM offload (no warning possible) and a no-op for
1591    // non-Ollama providers (verify_placement returns None). Off the critical path
1592    // — StreamDone is already enqueued, so any warning renders after the answer.
1593    if completed_ok
1594        && request.ollama_allow_ram_offload != Some(true)
1595        && let Some(p) = provider.verify_placement(sizing.effective).await
1596    {
1597        tracing::debug!(
1598            size_vram_bytes = p.size_vram_bytes,
1599            total_bytes = p.total_bytes,
1600            offloaded = p.size_vram_bytes < p.total_bytes,
1601            suggested_num_ctx = ?p.suggested_num_ctx,
1602            "Ollama placement"
1603        );
1604        let _ = msg_tx
1605            .send(Msg::OllamaPlacementResolved {
1606                model_id: request.model_id.clone(),
1607                size_vram_bytes: p.size_vram_bytes,
1608                total_bytes: p.total_bytes,
1609                suggested_num_ctx: p.suggested_num_ctx,
1610            })
1611            .await;
1612    }
1613}
1614
1615async fn dispatch_provider_stream(
1616    msg_tx: MsgSender,
1617    provider: Arc<dyn ModelProvider>,
1618    turn: TurnId,
1619    request: crate::domain::ChatRequest,
1620    token: tokio_util::sync::CancellationToken,
1621) {
1622    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1623    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1624    let relay_tx = msg_tx.clone();
1625    let relay_token = token.clone();
1626    let relay = spawn_guarded(async move {
1627        loop {
1628            let event = tokio::select! {
1629                biased;
1630                _ = relay_token.cancelled() => {
1631                    // #F40: a cancel landing right after the provider finished must
1632                    // not discard the terminal Done it already enqueued. Drain the
1633                    // buffered events and relay only a terminal Done — so the
1634                    // just-completed turn's usage is still recorded — while NOT
1635                    // painting buffered intermediate text (the turn is cancelled).
1636                    // `try_recv` drains the buffer without awaiting more.
1637                    while let Ok(buffered) = stream_rx.try_recv() {
1638                        if let StreamEvent::Done {
1639                            usage,
1640                            thinking_signature,
1641                            stop_reason,
1642                        } = buffered
1643                        {
1644                            let _ = relay_tx
1645                                .send(Msg::StreamDone {
1646                                    turn,
1647                                    usage,
1648                                    thinking_signature,
1649                                    stop_reason,
1650                                })
1651                                .await;
1652                        }
1653                    }
1654                    break;
1655                },
1656                ev = stream_rx.recv() => match ev {
1657                    Some(ev) => ev,
1658                    None => break,
1659                },
1660            };
1661            let msg = match event {
1662                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1663                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1664                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1665                StreamEvent::ThinkingSignature(_) => continue,
1666                StreamEvent::Done {
1667                    usage,
1668                    thinking_signature,
1669                    stop_reason,
1670                } => Msg::StreamDone {
1671                    turn,
1672                    usage,
1673                    thinking_signature,
1674                    stop_reason,
1675                },
1676            };
1677            if relay_tx.send(msg).await.is_err() {
1678                break;
1679            }
1680        }
1681    });
1682
1683    let model_id = request.model_id.clone();
1684    match provider.chat(request, ctx).await {
1685        Ok(_) | Err(ModelError::Cancelled) => {},
1686        Err(e) => {
1687            let error = classify_error_for_ui(&e);
1688            run_provider_error_hook(&model_id, &error).await;
1689            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1690        },
1691    }
1692
1693    join_logged(relay.take(), "stream_relay").await;
1694}
1695
1696/// Run plugin hooks OFF the async executor. `run_plugin_hooks` is synchronous —
1697/// it spawns hook children and bounded-waits on them — so calling it inline
1698/// would block a tokio worker, or (on the `dispatch` path) the whole event loop.
1699/// `spawn_blocking` moves it to the blocking pool. Hooks are fire-and-forget
1700/// observers, so the result is dropped.
1701async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1702    let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1703        .await;
1704}
1705
1706async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1707    fire_plugin_hooks(
1708        "provider_error",
1709        serde_json::json!({
1710            "model_id": model_id,
1711            "summary": &error.summary,
1712            "message": &error.message,
1713            "category": format!("{:?}", error.category),
1714            "recoverable": error.recoverable,
1715        }),
1716    )
1717    .await;
1718}
1719
1720/// Derive a short title for a `/remember` memory from free-text input: the
1721/// first non-empty line, capped to ~8 words / 60 chars. `write_memory`
1722/// slugifies it into the filename.
1723fn memory_title_from_text(text: &str) -> String {
1724    let first = text
1725        .lines()
1726        .find(|l| !l.trim().is_empty())
1727        .unwrap_or("memory")
1728        .trim();
1729    let title: String = first
1730        .split_whitespace()
1731        .take(8)
1732        .collect::<Vec<_>>()
1733        .join(" ")
1734        .chars()
1735        .take(60)
1736        .collect();
1737    if title.trim().is_empty() {
1738        "memory".to_string()
1739    } else {
1740        title
1741    }
1742}
1743
1744const 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.";
1745
1746#[derive(Debug)]
1747struct PrunePlan {
1748    prune: Vec<String>,
1749    reason: String,
1750}
1751
1752/// Extract a `{prune:[...], reason:""}` plan from a model response, tolerating
1753/// prose or code fences around the JSON object.
1754fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1755    let start = text.find('{')?;
1756    let end = text.rfind('}')?;
1757    if end < start {
1758        return None;
1759    }
1760    let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1761    let prune = json
1762        .get("prune")?
1763        .as_array()?
1764        .iter()
1765        .filter_map(|v| v.as_str().map(str::to_string))
1766        .collect();
1767    let reason = json
1768        .get("reason")
1769        .and_then(|v| v.as_str())
1770        .unwrap_or("")
1771        .to_string();
1772    Some(PrunePlan { prune, reason })
1773}
1774
1775/// `/consolidate-memory`: a one-shot model pass that names duplicate/obsolete
1776/// facts to prune (never rewrites — that's the anti-drift rule). The pruned
1777/// files are snapshotted into a checkpoint first, so the prune is reversible.
1778async fn consolidate_memory(
1779    tx: MsgSender,
1780    providers: Option<Arc<ProviderFactory>>,
1781    workdir: std::path::PathBuf,
1782    model_id: String,
1783) {
1784    let items = crate::app::memory::entries_with_bodies(&workdir);
1785    if items.len() < 2 {
1786        let _ = tx
1787            .send(Msg::RuntimeText(format!(
1788                "Nothing to consolidate — {} memor{} saved.",
1789                items.len(),
1790                if items.len() == 1 { "y" } else { "ies" }
1791            )))
1792            .await;
1793        return;
1794    }
1795    let Some(factory) = providers else {
1796        let _ = tx
1797            .send(Msg::RuntimeText(
1798                "Memory consolidation needs a model provider, which isn't bound in this session."
1799                    .to_string(),
1800            ))
1801            .await;
1802        return;
1803    };
1804
1805    let mut listing = String::new();
1806    for (entry, body) in &items {
1807        let id = entry
1808            .path
1809            .file_stem()
1810            .and_then(|s| s.to_str())
1811            .unwrap_or(entry.name.as_str());
1812        listing.push_str(&format!(
1813            "- id: {id}\n  scope: {}\n  description: {}\n  body: {}\n",
1814            entry.scope.as_str(),
1815            entry.description,
1816            body.replace('\n', " ").trim(),
1817        ));
1818    }
1819    let user = format!(
1820        "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1821        items.len(),
1822        listing
1823    );
1824    let request = crate::domain::ChatRequest {
1825        model_id: model_id.clone(),
1826        messages: vec![crate::models::ChatMessage::user(user)],
1827        system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1828        instructions: None,
1829        reasoning: crate::models::ReasoningLevel::None,
1830        temperature: 0.0,
1831        max_tokens: 1024,
1832        tools: Vec::new(),
1833        ollama_num_ctx: None,
1834        ollama_allow_ram_offload: None,
1835    };
1836
1837    let provider = match factory.resolve(&model_id).await {
1838        Ok(p) => p,
1839        Err(e) => {
1840            let _ = tx
1841                .send(Msg::RuntimeText(format!(
1842                    "Memory consolidation failed: {e}"
1843                )))
1844                .await;
1845            return;
1846        },
1847    };
1848    let token = tokio_util::sync::CancellationToken::new();
1849    let text =
1850        match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1851            Ok((t, _)) => t,
1852            Err(e) => {
1853                let _ = tx
1854                    .send(Msg::RuntimeText(format!(
1855                        "Memory consolidation failed: {e}"
1856                    )))
1857                    .await;
1858                return;
1859            },
1860        };
1861
1862    let Some(plan) = parse_prune_plan(&text) else {
1863        let _ = tx
1864            .send(Msg::RuntimeText(
1865                "Memory consolidation: couldn't parse the model's plan; nothing changed."
1866                    .to_string(),
1867            ))
1868            .await;
1869        return;
1870    };
1871    if plan.prune.is_empty() {
1872        let reason = if plan.reason.is_empty() {
1873            String::new()
1874        } else {
1875            format!(" {}", plan.reason)
1876        };
1877        let _ = tx
1878            .send(Msg::RuntimeText(format!(
1879                "Memory consolidation: nothing to prune.{reason}"
1880            )))
1881            .await;
1882        return;
1883    }
1884
1885    // Snapshot the to-be-pruned files first so the prune is reversible. The
1886    // delete below is irreversible, so a failed checkpoint must NOT proceed —
1887    // otherwise the report would advertise "Recoverable from the latest
1888    // checkpoint" for a prune with no checkpoint behind it (#F69). Abort instead;
1889    // nothing has been deleted yet, so no memory is lost.
1890    let paths: Vec<std::path::PathBuf> = plan
1891        .prune
1892        .iter()
1893        .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1894        .collect();
1895    if !paths.is_empty()
1896        && let Err(e) = crate::runtime::create_checkpoint(
1897            &workdir,
1898            &paths,
1899            Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1900        )
1901    {
1902        let _ = tx
1903            .send(Msg::RuntimeText(format!(
1904                "Memory consolidation aborted: couldn't checkpoint the {} file{} marked for pruning, so nothing was deleted (no memory lost). Error: {e}",
1905                paths.len(),
1906                if paths.len() == 1 { "" } else { "s" },
1907            )))
1908            .await;
1909        return;
1910    }
1911
1912    let mut pruned = Vec::new();
1913    for id in &plan.prune {
1914        if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1915            pruned.push(id.clone());
1916        }
1917    }
1918
1919    let cfg = crate::app::load_config().unwrap_or_default().memory;
1920    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1921    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1922
1923    let report = if pruned.is_empty() {
1924        "Memory consolidation: the model named facts to prune, but none matched existing memories."
1925            .to_string()
1926    } else {
1927        format!(
1928            "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1929            pruned.len(),
1930            if pruned.len() == 1 { "" } else { "s" },
1931            pruned.join(", "),
1932            if plan.reason.is_empty() {
1933                String::new()
1934            } else {
1935                format!(" Reason: {}.", plan.reason)
1936            },
1937        )
1938    };
1939    let _ = tx.send(Msg::RuntimeText(report)).await;
1940}
1941
1942async fn dispatch_compact_conversation(
1943    msg_tx: MsgSender,
1944    providers: Option<Arc<ProviderFactory>>,
1945    turn: TurnId,
1946    request: CompactionRequest,
1947    token: tokio_util::sync::CancellationToken,
1948) {
1949    let Some(factory) = providers else {
1950        let _ = msg_tx
1951            .send(Msg::CompactionFailed {
1952                turn,
1953                trigger: request.trigger,
1954                message: "EffectRunner has no ProviderFactory bound".to_string(),
1955                kind: crate::domain::StatusKind::Error,
1956            })
1957            .await;
1958        return;
1959    };
1960
1961    let provider = match factory.resolve(&request.chat.model_id).await {
1962        Ok(provider) => provider,
1963        Err(err) => {
1964            let _ = msg_tx
1965                .send(Msg::CompactionFailed {
1966                    turn,
1967                    trigger: request.trigger,
1968                    message: err.to_string(),
1969                    kind: crate::domain::StatusKind::Error,
1970                })
1971                .await;
1972            return;
1973        },
1974    };
1975
1976    let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
1977        crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
1978    });
1979    let before_snapshot =
1980        crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
1981
1982    let trigger = request.trigger;
1983    // A benign precondition (e.g. too little history to summarize) is a no-op, not
1984    // a failure — surface it as `Info` so the reducer shows a calm note instead of
1985    // a "Compaction failed: Invalid request" error. Real failures (model errors,
1986    // an empty/non-reducing summary) still flow through `run_compaction` as errors.
1987    let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
1988        Ok(prepared) => prepared,
1989        Err(skip) => {
1990            let _ = msg_tx
1991                .send(Msg::CompactionFailed {
1992                    turn,
1993                    trigger,
1994                    message: skip.to_string(),
1995                    kind: crate::domain::StatusKind::Info,
1996                })
1997                .await;
1998            return;
1999        },
2000    };
2001    match run_compaction(
2002        provider,
2003        turn,
2004        request,
2005        prepared,
2006        before_snapshot,
2007        max_context_tokens,
2008        token,
2009    )
2010    .await
2011    {
2012        Ok(result) => {
2013            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2014        },
2015        Err(err) => {
2016            let _ = msg_tx
2017                .send(Msg::CompactionFailed {
2018                    turn,
2019                    trigger,
2020                    message: err.to_string(),
2021                    kind: crate::domain::StatusKind::Error,
2022                })
2023                .await;
2024        },
2025    }
2026}
2027
2028async fn run_compaction(
2029    provider: Arc<dyn ModelProvider>,
2030    turn: TurnId,
2031    request: CompactionRequest,
2032    prepared: crate::domain::PreparedCompaction,
2033    before_snapshot: crate::domain::ContextUsageSnapshot,
2034    max_context_tokens: Option<usize>,
2035    token: tokio_util::sync::CancellationToken,
2036) -> Result<CompactionResult, ModelError> {
2037    let started = Instant::now();
2038
2039    let summary_request = crate::domain::build_summary_request(
2040        &request.chat,
2041        &prepared,
2042        request.instructions.as_deref(),
2043        request.policy,
2044    );
2045    let (draft, draft_usage) =
2046        collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
2047            .await?;
2048    let draft_summary = crate::domain::normalize_summary(&draft);
2049    if draft_summary.trim().is_empty() {
2050        return Err(ModelError::InvalidRequest(
2051            "compaction produced an empty summary".to_string(),
2052        ));
2053    }
2054
2055    let verify_request = crate::domain::build_verification_request(
2056        &request.chat,
2057        &prepared,
2058        &draft_summary,
2059        request.instructions.as_deref(),
2060        request.policy,
2061    );
2062    let (final_summary, verify_usage, verified, verification_error) =
2063        match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
2064            Ok((verified_text, verify_usage)) => {
2065                let verified_summary = crate::domain::normalize_summary(&verified_text);
2066                if verified_summary.trim().is_empty() {
2067                    (
2068                        draft_summary,
2069                        verify_usage,
2070                        false,
2071                        Some("verification returned an empty summary".to_string()),
2072                    )
2073                } else {
2074                    (verified_summary, verify_usage, true, None)
2075                }
2076            },
2077            Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
2078            Err(err) => (draft_summary, None, false, Some(err.to_string())),
2079        };
2080
2081    let id = format!(
2082        "compact_{}",
2083        chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
2084    );
2085    let mut record = crate::domain::CompactionRecord {
2086        id,
2087        trigger: request.trigger,
2088        created_at: chrono::Local::now(),
2089        before_tokens: before_snapshot.used_tokens,
2090        after_tokens: 0,
2091        archived_message_count: prepared.archived_messages.len(),
2092        preserved_message_count: prepared.preserved_messages.len(),
2093        summary_tokens: final_summary.len().div_ceil(4),
2094        duration_secs: started.elapsed().as_secs_f64(),
2095        verified,
2096        verification_error,
2097        focus: request.instructions.clone(),
2098        archive_path: None,
2099    };
2100
2101    let mut replacement =
2102        crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2103    let mut compacted_request = request.chat.clone();
2104    compacted_request.messages = replacement.clone();
2105    let mut after_snapshot =
2106        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2107    record.after_tokens = after_snapshot.used_tokens;
2108    record.duration_secs = started.elapsed().as_secs_f64();
2109    replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2110    compacted_request.messages = replacement.clone();
2111    after_snapshot =
2112        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2113    record.after_tokens = after_snapshot.used_tokens;
2114
2115    if after_snapshot.used_tokens >= before_snapshot.used_tokens {
2116        return Err(ModelError::InvalidRequest(format!(
2117            "compaction did not reduce context ({} -> {} tokens)",
2118            before_snapshot.used_tokens, after_snapshot.used_tokens
2119        )));
2120    }
2121
2122    if crate::domain::context_exceeds_hard_limit(
2123        &after_snapshot,
2124        &compacted_request,
2125        request.policy,
2126    ) {
2127        return Err(ModelError::InvalidRequest(format!(
2128            "compacted context still exceeds response reserve ({} tokens used)",
2129            after_snapshot.used_tokens
2130        )));
2131    }
2132
2133    Ok(CompactionResult {
2134        record,
2135        replacement_messages: replacement,
2136        archived_messages: prepared.archived_messages,
2137        before_snapshot,
2138        after_snapshot,
2139        usage: crate::domain::combine_usage(draft_usage, verify_usage),
2140    })
2141}
2142
2143async fn collect_compaction_text(
2144    provider: Arc<dyn ModelProvider>,
2145    turn: TurnId,
2146    request: crate::domain::ChatRequest,
2147    token: tokio_util::sync::CancellationToken,
2148) -> Result<(String, Option<TokenUsage>), ModelError> {
2149    // Shared with the Auto-mode safety classifier — see
2150    // `crate::providers::model::collect_text`.
2151    crate::providers::model::collect_text(provider, turn, request, token).await
2152}
2153
2154fn record_provider_capabilities(
2155    model_id: &str,
2156    caps: &crate::providers::capabilities::Capabilities,
2157) {
2158    let (provider, model) = split_model_id(model_id);
2159    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2160        for (key, value) in [
2161            ("tools_support", caps.supports_tools.to_string()),
2162            ("vision_support", caps.supports_vision.to_string()),
2163            (
2164                "context_limit",
2165                caps.max_context_tokens
2166                    .map(|v| v.to_string())
2167                    .unwrap_or_else(|| "unknown".to_string()),
2168            ),
2169            (
2170                "reasoning_parameter_shape",
2171                format!("{:?}", caps.supports_reasoning),
2172            ),
2173            (
2174                "streaming_usage_available",
2175                "provider_dependent".to_string(),
2176            ),
2177            ("token_usage_field_shape", "normalized".to_string()),
2178        ] {
2179            let _ = store
2180                .provider_probes()
2181                .upsert(crate::runtime::NewProviderProbe {
2182                    provider: provider.clone(),
2183                    model_id: model.clone(),
2184                    capability_key: key.to_string(),
2185                    capability_value: value,
2186                    confidence: "verified".to_string(),
2187                    error: None,
2188                });
2189        }
2190    }
2191}
2192
2193fn split_model_id(model_id: &str) -> (String, String) {
2194    match model_id.split_once('/') {
2195        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2196            (provider.to_ascii_lowercase(), model.to_string())
2197        },
2198        _ => ("ollama".to_string(), model_id.to_string()),
2199    }
2200}
2201
2202fn is_context_limit_error(error: &ModelError) -> bool {
2203    let text = error.to_string().to_lowercase();
2204    text.contains("context")
2205        && (text.contains("too large")
2206            || text.contains("exceed")
2207            || text.contains("maximum")
2208            || text.contains("token"))
2209}
2210
2211/// Dispatch an `ExecuteTool` command.
2212#[allow(clippy::too_many_arguments)]
2213async fn dispatch_execute_tool(
2214    msg_tx: MsgSender,
2215    tools: Option<Arc<ToolRegistry>>,
2216    workdir: PathBuf,
2217    turn: TurnId,
2218    call_id: crate::domain::ToolCallId,
2219    source: crate::models::tool_call::ToolCall,
2220    token: tokio_util::sync::CancellationToken,
2221    background: tokio_util::sync::CancellationToken,
2222    config: Arc<crate::app::Config>,
2223    model_id: String,
2224    task_id: Option<String>,
2225    safety_mode: crate::runtime::SafetyMode,
2226    intent: Option<String>,
2227    classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2228    approval: Option<crate::providers::ApprovalBroker>,
2229) {
2230    let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2231
2232    let Some(registry) = tools else {
2233        let _ = msg_tx
2234            .send(Msg::ToolFinished {
2235                turn,
2236                call_id,
2237                outcome: crate::domain::ToolOutcome::error(
2238                    "EffectRunner has no ToolRegistry bound",
2239                    0.0,
2240                ),
2241            })
2242            .await;
2243        return;
2244    };
2245
2246    // Route MCP-prefixed calls to the mcp proxy, which takes
2247    // {server_name, tool_name, arguments}. The raw model call has
2248    // those embedded in the function name and arguments respectively.
2249    let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2250        let rest = &source.function.name[5..];
2251        if let Some((server, tool)) = rest.split_once("__") {
2252            (
2253                "mcp_proxy",
2254                serde_json::json!({
2255                    "server_name": server,
2256                    "tool_name": tool,
2257                    "arguments": source.function.arguments.clone(),
2258                }),
2259            )
2260        } else {
2261            let _ = msg_tx
2262                .send(Msg::ToolFinished {
2263                    turn,
2264                    call_id,
2265                    outcome: crate::domain::ToolOutcome::error(
2266                        format!("invalid MCP tool name: {}", source.function.name),
2267                        0.0,
2268                    ),
2269                })
2270                .await;
2271            return;
2272        }
2273    } else {
2274        (
2275            source.function.name.as_str(),
2276            source.function.arguments.clone(),
2277        )
2278    };
2279    let tool_run_id =
2280        start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2281
2282    let Some(tool) = registry.get(tool_key) else {
2283        let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2284        finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2285        let _ = msg_tx
2286            .send(Msg::ToolFinished {
2287                turn,
2288                call_id,
2289                outcome,
2290            })
2291            .await;
2292        return;
2293    };
2294
2295    // Bridge the tool's progress channel to `Msg::ToolProgress`.
2296    // A sibling task drains progress events while the tool runs.
2297    // The channel closes when `progress_tx` drops (when `ctx`
2298    // drops at the end of `tool.execute`), which terminates the
2299    // relay loop cleanly.
2300    let (progress_tx, mut progress_rx) = mpsc::channel(16);
2301    let relay_tx = msg_tx.clone();
2302    let relay_token = token.clone();
2303    let progress_relay = spawn_guarded(async move {
2304        loop {
2305            let event = tokio::select! {
2306                biased;
2307                _ = relay_token.cancelled() => break,
2308                ev = progress_rx.recv() => match ev {
2309                    Some(ev) => ev,
2310                    None => break,
2311                },
2312            };
2313            if relay_tx
2314                .send(Msg::ToolProgress {
2315                    turn,
2316                    call_id,
2317                    event,
2318                })
2319                .await
2320                .is_err()
2321            {
2322                break;
2323            }
2324        }
2325    });
2326
2327    let mut ctx = ExecContext::new(
2328        token,
2329        progress_tx,
2330        call_id,
2331        turn,
2332        workdir,
2333        config,
2334        model_id,
2335        task_id,
2336        safety_mode,
2337        intent,
2338        classifier,
2339        approval,
2340    );
2341    ctx.background = background;
2342    let before_payload = serde_json::json!({
2343        "turn_id": turn.0,
2344        "call_id": call_id.0,
2345        "tool": tool_key,
2346    });
2347    fire_plugin_hooks("before_tool_use", before_payload).await;
2348    let outcome = tool.execute(args, ctx).await;
2349    let after_payload = serde_json::json!({
2350        "turn_id": turn.0,
2351        "call_id": call_id.0,
2352        "tool": tool_key,
2353        "status": tool_status_label(outcome.status),
2354        "summary": &outcome.summary,
2355    });
2356    fire_plugin_hooks("after_tool_use", after_payload).await;
2357    finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2358    join_logged(progress_relay.take(), "tool_progress_relay").await;
2359    let _ = msg_tx
2360        .send(Msg::ToolFinished {
2361            turn,
2362            call_id,
2363            outcome,
2364        })
2365        .await;
2366}
2367
2368async fn start_runtime_tool_run(
2369    task_id: Option<&str>,
2370    turn: TurnId,
2371    call_id: crate::domain::ToolCallId,
2372    tool_name: &str,
2373    args: &serde_json::Value,
2374) -> Option<String> {
2375    // Synchronous rusqlite write on the hot tool-execution path — offload it to
2376    // the blocking pool. The id is needed by `finish`, so we await the result
2377    // (unlike `finish`, which is fire-and-forget) (#39).
2378    let task_id = task_id.map(str::to_string);
2379    let tool_name = tool_name.to_string();
2380    let args_json = serde_json::to_string(args).ok();
2381    tokio::task::spawn_blocking(move || {
2382        crate::runtime::RuntimeStore::open_default()
2383            .and_then(|store| {
2384                store.tool_runs().start(crate::runtime::NewToolRun {
2385                    id: None,
2386                    task_id,
2387                    turn_id: Some(turn.0.to_string()),
2388                    call_id: Some(call_id.0.to_string()),
2389                    tool_name,
2390                    args_json,
2391                })
2392            })
2393            .map(|record| record.id)
2394            .ok()
2395    })
2396    .await
2397    .ok()
2398    .flatten()
2399}
2400
2401fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2402    let Some(tool_run_id) = tool_run_id else {
2403        return;
2404    };
2405    let tool_run_id = tool_run_id.to_string();
2406    let status = tool_status_label(outcome.status).to_string();
2407    let output_json = serde_json::to_string(&serde_json::json!({
2408        "status": tool_status_label(outcome.status),
2409        "summary": &outcome.summary,
2410        "model_content": &outcome.model_content,
2411        "error": &outcome.error,
2412        "metadata": &outcome.metadata,
2413        "artifacts": &outcome.artifacts,
2414        "duration_secs": outcome.duration_secs,
2415    }))
2416    .ok();
2417    // Fire-and-forget telemetry write on the blocking pool — don't stall the
2418    // tool-finish path waiting on rusqlite (#39).
2419    tokio::task::spawn_blocking(move || {
2420        if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2421            let _ = store
2422                .tool_runs()
2423                .finish(&tool_run_id, &status, output_json.as_deref());
2424        }
2425    });
2426}
2427
2428fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2429    match status {
2430        crate::domain::ToolStatus::Success => "success",
2431        crate::domain::ToolStatus::Error => "error",
2432        crate::domain::ToolStatus::Cancelled => "cancelled",
2433    }
2434}
2435
2436fn runtime_model_info_text(model: &str) -> String {
2437    let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2438    let mut lines = vec![
2439        format!("Model info: {}", model),
2440        format!("- provider: {}", snapshot.provider),
2441        format!("- model: {}", snapshot.model),
2442        format!("- supports tools: {}", snapshot.supports_tools),
2443        format!("- supports vision: {}", snapshot.supports_vision),
2444        format!("- reasoning: {}", snapshot.reasoning),
2445        format!(
2446            "- context limit: {}",
2447            snapshot
2448                .max_context_tokens
2449                .map(|value: usize| value.to_string())
2450                .unwrap_or_else(|| "unknown".to_string())
2451        ),
2452    ];
2453    if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2454        && let Ok(probes) = store
2455            .provider_probes()
2456            .list(Some(&snapshot.provider), Some(&snapshot.model))
2457        && !probes.is_empty()
2458    {
2459        lines.push(String::new());
2460        lines.push("Cached provider reality records:".to_string());
2461        for probe in probes {
2462            lines.push(format!(
2463                "- {} = {} ({})",
2464                probe.capability_key, probe.capability_value, probe.confidence
2465            ));
2466        }
2467    }
2468    lines.join("\n")
2469}
2470
2471/// Spawn `ollama pull <model>` and stream its stdout lines as
2472/// `Msg::ModelPullProgress` status updates. Emits a final
2473/// `Msg::ModelPullFinished` on successful exit; on failure, emits a
2474/// single `ModelPullProgress` with the error text.
2475async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2476    use tokio::io::{AsyncBufReadExt, BufReader};
2477    use tokio::process::Command;
2478
2479    let mut cmd = Command::new("ollama");
2480    cmd.arg("pull")
2481        .arg(&model)
2482        .stdin(std::process::Stdio::null())
2483        .stdout(std::process::Stdio::piped())
2484        .stderr(std::process::Stdio::piped())
2485        .kill_on_drop(true);
2486
2487    let mut child = match cmd.spawn() {
2488        Ok(c) => c,
2489        Err(e) => {
2490            let _ = tx
2491                .send(Msg::ModelPullProgress(format!(
2492                    "ollama pull failed to start: {}",
2493                    e
2494                )))
2495                .await;
2496            return;
2497        },
2498    };
2499
2500    // Capture the reader's handle instead of orphaning it: the child's stdout
2501    // closes when it exits, so this task finishes right after `child.wait`
2502    // below — we join it there so a panic is logged, not silently lost (#60).
2503    let reader_handle = child.stdout.take().map(|stdout| {
2504        let tx_inner = tx.clone();
2505        tokio::spawn(async move {
2506            let mut reader = BufReader::new(stdout).lines();
2507            while let Ok(Some(line)) = reader.next_line().await {
2508                let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2509            }
2510        })
2511    });
2512
2513    match child.wait().await {
2514        Ok(status) if status.success() => {
2515            let _ = tx.send(Msg::ModelPullFinished { model }).await;
2516        },
2517        Ok(status) => {
2518            let _ = tx
2519                .send(Msg::ModelPullProgress(format!(
2520                    "ollama pull exited with status {}",
2521                    status.code().unwrap_or(-1)
2522                )))
2523                .await;
2524        },
2525        Err(e) => {
2526            let _ = tx
2527                .send(Msg::ModelPullProgress(format!(
2528                    "ollama pull wait error: {}",
2529                    e
2530                )))
2531                .await;
2532        },
2533    }
2534
2535    // The child has exited; its stdout is closed, so the reader is finishing.
2536    // Join it (logging a panic) so it isn't left orphaned (#60).
2537    if let Some(handle) = reader_handle {
2538        join_logged(handle, "ollama_pull_reader").await;
2539    }
2540}
2541
2542fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2543    if started {
2544        Msg::McpServerReady {
2545            name: name.to_string(),
2546            tools,
2547        }
2548    } else {
2549        Msg::McpServerErrored {
2550            name: name.to_string(),
2551            reason: "server failed to start or initialize".to_string(),
2552        }
2553    }
2554}
2555
2556/// Read the system clipboard on a blocking thread and emit a `Msg`
2557/// back into the main loop. Image content wins when present; falls
2558/// back to text; empty or error surface as `Msg::TransientStatus` so
2559/// the user gets visible feedback (a silent no-op on Ctrl+V would be
2560/// confusing, especially on macOS where `osascript` can take ~300ms).
2561///
2562/// `tokio::task::spawn_blocking` is the right primitive: `clipboard::
2563/// has_image` / `read_image_bytes` / `read_text` shell out to xclip /
2564/// wl-paste / pngpaste / PowerShell, all of which block synchronously —
2565/// bounded, since every clipboard subprocess runs under a kill-on-timeout
2566/// deadline, so a hung helper returns an error here instead of pinning
2567/// this blocking thread forever.
2568async fn dispatch_read_clipboard(tx: MsgSender) {
2569    use crate::domain::Paste;
2570
2571    enum Outcome {
2572        Image { bytes: Vec<u8>, format: String },
2573        Text(String),
2574        Empty,
2575        Error(String),
2576    }
2577
2578    let outcome = tokio::task::spawn_blocking(|| {
2579        if crate::clipboard::has_image() {
2580            match crate::clipboard::read_image_bytes() {
2581                Ok((bytes, format)) => Outcome::Image { bytes, format },
2582                Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2583            }
2584        } else {
2585            match crate::clipboard::read_text() {
2586                Ok(t) if !t.is_empty() => Outcome::Text(t),
2587                Ok(_) => Outcome::Empty,
2588                Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2589            }
2590        }
2591    })
2592    .await
2593    .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2594
2595    let msg = match outcome {
2596        Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2597        Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2598        Outcome::Empty => Msg::TransientStatus {
2599            text: "Clipboard is empty".to_string(),
2600        },
2601        Outcome::Error(text) => Msg::TransientStatus { text },
2602    };
2603    let _ = tx.send(msg).await;
2604}
2605
2606/// Write text to the system clipboard on a blocking thread (the platform
2607/// tools shell out and block), then report the result via a transient status.
2608async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2609    let char_count = text.chars().count();
2610    let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2611        .await
2612        .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2613
2614    let msg = match result {
2615        Ok(()) => Msg::TransientStatus {
2616            text: format!("Copied {char_count} chars to clipboard"),
2617        },
2618        Err(e) => Msg::TransientStatus {
2619            text: format!("Copy failed: {e}"),
2620        },
2621    };
2622    let _ = tx.send(msg).await;
2623}
2624
2625fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2626    use crate::models::{ErrorCategory, ModelError, UserFacingError};
2627    match e {
2628        ModelError::Backend(b) => UserFacingError {
2629            summary: "Backend error".to_string(),
2630            message: b.to_string(),
2631            suggestion: "Check the provider endpoint / API key.".to_string(),
2632            category: ErrorCategory::Connection,
2633            recoverable: true,
2634        },
2635        ModelError::Authentication(msg) => UserFacingError {
2636            summary: "Auth error".to_string(),
2637            message: msg.clone(),
2638            suggestion: "Set the env var the provider expects.".to_string(),
2639            category: ErrorCategory::Auth,
2640            recoverable: false,
2641        },
2642        ModelError::RateLimit { retry_after } => UserFacingError {
2643            summary: "Rate limit".to_string(),
2644            message: format!("retry after {:?}", retry_after),
2645            suggestion: "Wait and try again.".to_string(),
2646            category: ErrorCategory::Temporary,
2647            recoverable: true,
2648        },
2649        ModelError::StreamError(msg) => UserFacingError {
2650            summary: "Stream error".to_string(),
2651            message: msg.clone(),
2652            suggestion: "Retry the request.".to_string(),
2653            category: ErrorCategory::Connection,
2654            recoverable: true,
2655        },
2656        other => UserFacingError {
2657            summary: "Model error".to_string(),
2658            message: other.to_string(),
2659            suggestion: String::new(),
2660            category: ErrorCategory::Internal,
2661            recoverable: false,
2662        },
2663    }
2664}
2665
2666#[cfg(test)]
2667mod tests {
2668    use super::*;
2669    use crate::domain::ToolCallId;
2670    use std::time::Duration;
2671
2672    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2673        EffectRunner::pair(PathBuf::from("/tmp"))
2674    }
2675
2676    #[test]
2677    fn new_child_suppresses_terminal_title() {
2678        // A subagent's child runner must not emit OSC 2 terminal titles —
2679        // otherwise they leak into a headless parent's stdout and corrupt
2680        // `--format json`/`text` output (caught during live headless testing).
2681        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2682        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2683        let tools = Arc::new(ToolRegistry::new());
2684        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2685        assert!(
2686            !child.terminal_title_enabled,
2687            "subagent child runner must suppress terminal-title escapes"
2688        );
2689    }
2690
2691    #[test]
2692    fn new_child_does_not_own_global_mcp_shutdown() {
2693        // The MCP manager is process-global and shared with the parent. A
2694        // child runner's shutdown (which runs after EVERY subagent) must not
2695        // reap it — that would kill the parent's MCP servers for the rest of
2696        // the session. Only the top-level runner owns the reap.
2697        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2698        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2699        let tools = Arc::new(ToolRegistry::new());
2700        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2701        assert!(
2702            !child.owns_global_mcp,
2703            "child runner must not reap the shared global MCP manager"
2704        );
2705        let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
2706        assert!(
2707            top.owns_global_mcp,
2708            "top-level runner still owns the global MCP reap"
2709        );
2710    }
2711
2712    #[test]
2713    fn parse_prune_plan_extracts_json_amid_prose() {
2714        let plan = parse_prune_plan(
2715            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2716        )
2717        .expect("should parse");
2718        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2719        assert_eq!(plan.reason, "dupes");
2720    }
2721
2722    #[test]
2723    fn parse_prune_plan_handles_empty_and_garbage() {
2724        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2725            .expect("empty plan parses");
2726        assert!(empty.prune.is_empty());
2727        assert!(parse_prune_plan("no json here").is_none());
2728    }
2729
2730    #[test]
2731    fn memory_title_from_text_is_short_and_nonempty() {
2732        assert_eq!(
2733            memory_title_from_text("prefer ripgrep over grep"),
2734            "prefer ripgrep over grep"
2735        );
2736        assert_eq!(memory_title_from_text("   "), "memory");
2737        let long = memory_title_from_text("one two three four five six seven eight nine ten");
2738        assert!(long.split_whitespace().count() <= 8);
2739    }
2740
2741    #[tokio::test]
2742    async fn dispatch_exit_is_noop_on_runner_state() {
2743        let (mut r, _rx) = runner();
2744        r.dispatch(Cmd::Exit);
2745        assert_eq!(r.scope_count(), 0);
2746    }
2747
2748    #[tokio::test]
2749    async fn dispatch_save_emits_session_saved() {
2750        let (mut r, mut rx) = runner();
2751        r.dispatch(Cmd::SaveConversation(
2752            crate::session::ConversationHistory::new(
2753                "/p".to_string(),
2754                "m".to_string(),
2755                chrono::Local::now(),
2756            ),
2757        ));
2758        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2759            .await
2760            .expect("sender emits")
2761            .expect("channel alive");
2762        assert!(matches!(msg, Msg::SessionSaved));
2763    }
2764
2765    #[test]
2766    fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2767        let msg = mcp_startup_msg("empty", true, Vec::new());
2768        assert!(matches!(
2769            msg,
2770            Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2771        ));
2772    }
2773
2774    #[test]
2775    fn mcp_startup_msg_reports_unstarted_server_as_error() {
2776        let msg = mcp_startup_msg("bad", false, Vec::new());
2777        assert!(matches!(
2778            msg,
2779            Msg::McpServerErrored { name, reason }
2780                if name == "bad" && reason.contains("failed to start")
2781        ));
2782    }
2783
2784    #[tokio::test]
2785    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2786        let (mut r, mut rx) = runner();
2787        let turn = TurnId(77);
2788        {
2789            let scope = r.scope_mut(turn);
2790            scope.spawn(async {
2791                std::future::pending::<()>().await;
2792            });
2793        }
2794        assert_eq!(r.scope_count(), 1);
2795
2796        let start = std::time::Instant::now();
2797        r.dispatch(Cmd::CancelScope(turn));
2798        assert_eq!(r.scope_count(), 0);
2799        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2800            .await
2801            .expect("bounded cancel should emit terminal message")
2802            .expect("channel alive");
2803        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2804        assert!(
2805            start.elapsed() < Duration::from_millis(500),
2806            "cancel terminal message took {:?}",
2807            start.elapsed()
2808        );
2809    }
2810
2811    #[tokio::test]
2812    async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2813        // Regression (Axis 1 #9): if a turn's tasks complete and
2814        // `reap_empty_scopes` removes the now-empty scope before the user's
2815        // cancel lands, `drop_scope` used to be a silent no-op and the reducer
2816        // stuck forever in `Cancelling`. The terminal `TurnCancelled` must fire
2817        // even when the scope is already gone.
2818        let (mut r, mut rx) = runner();
2819        let turn = TurnId(88);
2820        {
2821            let scope = r.scope_mut(turn);
2822            scope.spawn(async {}); // completes immediately
2823        }
2824        assert_eq!(r.scope_count(), 1);
2825
2826        // Let the task finish, then any dispatch reaps the now-empty scope.
2827        tokio::time::sleep(Duration::from_millis(20)).await;
2828        r.dispatch(Cmd::Exit);
2829        assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2830
2831        // The scope is gone, but the reducer is still `Cancelling`: cancel must
2832        // still produce a terminal message.
2833        r.dispatch(Cmd::CancelScope(turn));
2834        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2835            .await
2836            .expect("cancel on a reaped scope must still emit a terminal message")
2837            .expect("channel alive");
2838        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2839    }
2840
2841    #[tokio::test]
2842    async fn dispatch_call_model_creates_scope() {
2843        let (mut r, _rx) = runner();
2844        let turn = TurnId(7);
2845        let request = crate::domain::ChatRequest {
2846            model_id: "test/m".to_string(),
2847            messages: vec![],
2848            system_prompt: String::new(),
2849            instructions: None,
2850            reasoning: crate::models::ReasoningLevel::Medium,
2851            temperature: 0.7,
2852            max_tokens: 4096,
2853            tools: vec![],
2854
2855            ollama_num_ctx: None,
2856            ollama_allow_ram_offload: None,
2857        };
2858        r.dispatch(Cmd::CallModel { turn, request });
2859        assert_eq!(r.scope_count(), 1);
2860    }
2861
2862    /// F12: after a spawned task completes (here via the
2863    /// no-ProviderFactory error path), the next `dispatch` call reaps
2864    /// the empty scope instead of leaving an orphan entry in the map.
2865    #[tokio::test]
2866    async fn empty_scopes_are_reaped_on_next_dispatch() {
2867        let (mut r, mut rx) = runner();
2868        let turn = TurnId(42);
2869        let request = crate::domain::ChatRequest {
2870            model_id: "test/m".to_string(),
2871            messages: vec![],
2872            system_prompt: String::new(),
2873            instructions: None,
2874            reasoning: crate::models::ReasoningLevel::Medium,
2875            temperature: 0.7,
2876            max_tokens: 4096,
2877            tools: vec![],
2878
2879            ollama_num_ctx: None,
2880            ollama_allow_ram_offload: None,
2881        };
2882        r.dispatch(Cmd::CallModel { turn, request });
2883        assert_eq!(r.scope_count(), 1);
2884
2885        // Runner has no provider bindings → dispatch_call_model hits
2886        // the "not wired" error path and emits UpstreamError, then the
2887        // spawned task returns. Drain that message so we know the task
2888        // ran to completion.
2889        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2890            .await
2891            .expect("upstream error arrived")
2892            .expect("channel alive");
2893        assert!(matches!(msg, Msg::UpstreamError { .. }));
2894
2895        // Give the JoinSet a tick to notice the task finished.
2896        tokio::task::yield_now().await;
2897
2898        // Any subsequent dispatch reaps the now-empty scope.
2899        r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2900        assert_eq!(
2901            r.scope_count(),
2902            0,
2903            "completed scope must be reaped on next dispatch"
2904        );
2905    }
2906
2907    #[tokio::test]
2908    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2909        let (mut r, mut rx) = runner();
2910        let turn = TurnId(7);
2911        let call_id = ToolCallId(1);
2912        let source = crate::models::tool_call::ToolCall {
2913            id: Some("c1".to_string()),
2914            function: crate::models::tool_call::FunctionCall {
2915                name: "read_file".to_string(),
2916                arguments: serde_json::json!({"path": "x"}),
2917            },
2918        };
2919        r.dispatch(Cmd::ExecuteTool {
2920            turn,
2921            call_id,
2922            source,
2923            model_id: "ollama/test".to_string(),
2924            safety_mode: crate::runtime::SafetyMode::Ask,
2925            intent: None,
2926        });
2927        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2928            .await
2929            .expect("some msg")
2930            .expect("channel alive");
2931        assert!(matches!(
2932            first,
2933            Msg::ToolStarted {
2934                turn: t,
2935                call_id: c,
2936            } if t == turn && c == call_id
2937        ));
2938    }
2939
2940    #[tokio::test]
2941    async fn cancel_scope_before_execute_tool_drops_pending_work() {
2942        let (mut r, _rx) = runner();
2943        let turn = TurnId(9);
2944        r.dispatch(Cmd::CallModel {
2945            turn,
2946            request: crate::domain::ChatRequest {
2947                model_id: "m".to_string(),
2948                messages: vec![],
2949                system_prompt: String::new(),
2950                instructions: None,
2951                reasoning: crate::models::ReasoningLevel::Medium,
2952                temperature: 0.7,
2953                max_tokens: 4096,
2954                tools: vec![],
2955
2956                ollama_num_ctx: None,
2957                ollama_allow_ram_offload: None,
2958            },
2959        });
2960        assert_eq!(r.scope_count(), 1);
2961
2962        r.dispatch(Cmd::CancelScope(turn));
2963        assert_eq!(r.scope_count(), 0);
2964    }
2965
2966    #[tokio::test]
2967    async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2968        // F38: once a turn's scope has been cancelled (dropped + tombstoned), a
2969        // stray turn-scoped Cmd bearing the same TurnId must be dropped — not
2970        // used to spin up a fresh, un-cancelled scope via `scope_mut`'s
2971        // `or_insert_with`. Turn ids are monotonic and never reused, so such a
2972        // Cmd can only be a post-cancel straggler.
2973        let (mut r, _rx) = runner();
2974        let req = || crate::domain::ChatRequest {
2975            model_id: "test/m".to_string(),
2976            messages: vec![],
2977            system_prompt: String::new(),
2978            instructions: None,
2979            reasoning: crate::models::ReasoningLevel::Medium,
2980            temperature: 0.7,
2981            max_tokens: 4096,
2982            tools: vec![],
2983            ollama_num_ctx: None,
2984            ollama_allow_ram_offload: None,
2985        };
2986        let turn = TurnId(123);
2987
2988        r.dispatch(Cmd::CallModel {
2989            turn,
2990            request: req(),
2991        });
2992        assert_eq!(r.scope_count(), 1);
2993
2994        // Cancel: drops the scope and tombstones the turn.
2995        r.dispatch(Cmd::CancelScope(turn));
2996        assert_eq!(r.scope_count(), 0);
2997
2998        // A late scoped Cmd for the now-tombstoned turn must be dropped.
2999        r.dispatch(Cmd::CallModel {
3000            turn,
3001            request: req(),
3002        });
3003        assert_eq!(
3004            r.scope_count(),
3005            0,
3006            "a cancelled turn must not be resurrected by a late scoped Cmd"
3007        );
3008
3009        // A fresh, higher turn id is unaffected by the tombstone.
3010        r.dispatch(Cmd::CallModel {
3011            turn: TurnId(124),
3012            request: req(),
3013        });
3014        assert_eq!(
3015            r.scope_count(),
3016            1,
3017            "a fresh turn must still create its scope normally"
3018        );
3019    }
3020
3021    #[tokio::test]
3022    async fn shutdown_drains_pending_saves() {
3023        let (mut r, _rx) = runner();
3024        for _ in 0..5 {
3025            r.dispatch(Cmd::SaveConversation(
3026                crate::session::ConversationHistory::new(
3027                    "/p".to_string(),
3028                    "m".to_string(),
3029                    chrono::Local::now(),
3030                ),
3031            ));
3032        }
3033        // Shutdown waits for all five to complete (should be instant).
3034        let start = std::time::Instant::now();
3035        r.shutdown().await;
3036        assert!(start.elapsed() < Duration::from_secs(2));
3037    }
3038}