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