1mod config_watch;
35mod middleware;
36mod turn_scope;
37
38use std::collections::HashMap;
39use std::collections::VecDeque;
40use std::path::PathBuf;
41use std::sync::Arc;
42use std::sync::Mutex;
43use std::time::Instant;
44
45use tokio::sync::mpsc;
46
47use crate::app::{Config, MemoryConfig};
48use crate::domain::{
49 Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
50};
51use crate::models::{ModelError, TokenUsage};
52use crate::providers::ctx::{ExecContext, StreamContext};
53use crate::providers::model::ModelProvider;
54use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
55use crate::utils::{join_logged, spawn_guarded};
56
57pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
58pub use turn_scope::TurnScope;
59
60#[cfg(not(test))]
61const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
62#[cfg(test)]
63const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
64
65const CANCELLED_TOMBSTONE_CAP: usize = 256;
71
72pub type MsgSender = mpsc::Sender<Msg>;
78
79pub const MSG_CHANNEL_CAPACITY: usize = 512;
85
86#[derive(Clone)]
87enum PersistenceJob {
88 Conversation(Box<crate::session::ConversationHistory>),
89 Compaction(Box<PendingCompactionSave>),
90}
91
92#[derive(Clone)]
93struct PendingCompactionSave {
94 archive: crate::domain::CompactionArchive,
95 record: crate::domain::CompactionRecord,
96 conversation: crate::session::ConversationHistory,
97 task_id: Option<String>,
98}
99
100struct PersistedCompaction {
101 id: String,
102 task_id: Option<String>,
103 session_id: String,
104 archive_path: PathBuf,
105}
106
107struct PersistenceState {
108 workdir: PathBuf,
109 manager: Option<crate::session::ConversationManager>,
110 blocked: HashMap<String, VecDeque<PendingCompactionSave>>,
111}
112
113impl PersistenceState {
114 fn new(workdir: PathBuf) -> Self {
115 Self {
116 workdir,
117 manager: None,
118 blocked: HashMap::new(),
119 }
120 }
121
122 fn manager(&mut self) -> anyhow::Result<&crate::session::ConversationManager> {
123 if self.manager.is_none() {
124 self.manager = Some(crate::session::ConversationManager::new(&self.workdir)?);
125 }
126 Ok(self.manager.as_ref().expect("manager initialized"))
127 }
128
129 fn process(&mut self, job: PersistenceJob) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
134 match job {
135 PersistenceJob::Conversation(history) => {
136 let (persisted, retried) = self.retry_blocked(&history.id);
139 if retried.is_err() {
140 return (persisted, retried);
141 }
142 let saved = self
143 .manager()
144 .and_then(|manager| manager.save_conversation(&history).map(|_| ()));
145 (persisted, saved)
146 },
147 PersistenceJob::Compaction(save) => {
148 let conversation_id = save.archive.conversation_id.clone();
153 self.blocked
154 .entry(conversation_id.clone())
155 .or_default()
156 .push_back(*save);
157 self.retry_blocked(&conversation_id)
158 },
159 }
160 }
161
162 fn retry_blocked(
163 &mut self,
164 conversation_id: &str,
165 ) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
166 let mut persisted = Vec::new();
167 if !self.blocked.contains_key(conversation_id) {
168 return (persisted, Ok(()));
169 }
170 if let Err(error) = self.manager() {
171 return (persisted, Err(error));
172 }
173 let manager = self.manager.as_ref().expect("manager initialized");
177 let queue = self
178 .blocked
179 .get_mut(conversation_id)
180 .expect("checked above");
181 while let Some(save) = queue.front() {
182 match Self::persist_compaction(manager, save) {
183 Ok(event) => {
187 persisted.push(event);
188 queue.pop_front();
189 },
190 Err(error) => return (persisted, Err(error)),
191 }
192 }
193 self.blocked.remove(conversation_id);
194 (persisted, Ok(()))
195 }
196
197 fn retry_all_blocked(&mut self) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
198 let ids: Vec<String> = self.blocked.keys().cloned().collect();
199 let mut persisted = Vec::new();
200 let mut first_error = None;
201 for id in ids {
202 let (events, result) = self.retry_blocked(&id);
205 persisted.extend(events);
206 if let Err(error) = result {
207 first_error.get_or_insert(error);
208 }
209 }
210 match first_error {
211 None => (persisted, Ok(())),
212 Some(error) => (persisted, Err(error)),
213 }
214 }
215
216 fn persist_compaction(
217 manager: &crate::session::ConversationManager,
218 save: &PendingCompactionSave,
219 ) -> anyhow::Result<PersistedCompaction> {
220 let path = manager.save_compaction_archive(&save.archive)?;
221 manager.save_conversation(&save.conversation)?;
222
223 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
224 let _ = store.compactions().create(crate::runtime::NewCompaction {
225 id: Some(save.record.id.clone()),
226 task_id: save.task_id.clone(),
227 session_id: Some(save.archive.conversation_id.clone()),
228 source_token_estimate: Some(save.record.before_tokens as i64),
229 summary_token_count: Some(save.record.summary_tokens as i64),
230 preserved_turns: Some(save.record.preserved_turn_count as i64),
231 archive_path: Some(path.display().to_string()),
232 verification_status: Some(save.record.review_status.as_str().to_string()),
233 });
234 }
235
236 Ok(PersistedCompaction {
237 id: save.record.id.clone(),
238 task_id: save.task_id.clone(),
239 session_id: save.archive.conversation_id.clone(),
240 archive_path: path,
241 })
242 }
243}
244
245async fn fire_compaction_hook(event: &PersistedCompaction) {
247 fire_plugin_hooks(
248 "compaction",
249 serde_json::json!({
250 "id": event.id,
251 "task_id": event.task_id,
252 "session_id": event.session_id,
253 "archive_path": event.archive_path.display().to_string(),
254 }),
255 )
256 .await;
257}
258
259pub struct EffectRunner {
262 msg_tx: MsgSender,
263 scopes: HashMap<TurnId, TurnScope>,
269 cancelled_turns: VecDeque<TurnId>,
275 detached: tokio::task::JoinSet<()>,
279 persistence_state: Arc<Mutex<PersistenceState>>,
283 persistence_tail: Option<tokio::task::JoinHandle<()>>,
284 workdir: PathBuf,
288 providers: Option<Arc<ProviderFactory>>,
293 tools: Option<Arc<ToolRegistry>>,
296 task_id: Option<String>,
298 terminal_title_enabled: bool,
302 owns_global_mcp: bool,
308 approval: Option<crate::providers::ApprovalBroker>,
312 questions: Option<crate::providers::QuestionBroker>,
316 tasks: crate::providers::TaskBroker,
321 config_watch: Option<tokio::task::AbortHandle>,
325}
326
327impl EffectRunner {
328 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
330 let persistence_state = Arc::new(Mutex::new(PersistenceState::new(workdir.clone())));
331 Self {
332 tasks: crate::providers::TaskBroker::new(msg_tx.clone()),
333 msg_tx,
334 scopes: HashMap::new(),
335 cancelled_turns: VecDeque::new(),
336 detached: tokio::task::JoinSet::new(),
337 persistence_state,
338 persistence_tail: None,
339 workdir,
340 providers: None,
341 tools: None,
342 task_id: None,
343 terminal_title_enabled: true,
344 owns_global_mcp: true,
345 approval: None,
346 questions: None,
347 config_watch: None,
348 }
349 }
350
351 pub fn with_interactive_approvals(mut self) -> Self {
355 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
356 self
357 }
358
359 pub fn with_interactive_questions(mut self) -> Self {
363 self.questions = Some(crate::providers::QuestionBroker::new(self.msg_tx.clone()));
364 self
365 }
366
367 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
373 let handle = self.detached.spawn(config_watch::config_watcher(
374 self.msg_tx.clone(),
375 cwd,
376 memory,
377 ));
378 self.config_watch = Some(handle);
379 }
380
381 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
384 self.task_id = task_id;
385 self
386 }
387
388 pub fn without_terminal_title(mut self) -> Self {
390 self.terminal_title_enabled = false;
391 self
392 }
393
394 pub fn without_global_mcp_shutdown(mut self) -> Self {
397 self.owns_global_mcp = false;
398 self
399 }
400
401 pub fn with_bindings(
406 mut self,
407 providers: Arc<ProviderFactory>,
408 tools: Arc<ToolRegistry>,
409 ) -> Self {
410 self.providers = Some(providers);
411 self.tools = Some(tools);
412 self
413 }
414
415 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
419 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
420 (Self::new(tx, workdir), rx)
421 }
422
423 pub fn pair_with_bindings(
426 workdir: PathBuf,
427 config: Config,
428 tools: Arc<ToolRegistry>,
429 ) -> (Self, mpsc::Receiver<Msg>) {
430 let providers = Arc::new(ProviderFactory::new(config));
431 Self::pair_from(workdir, providers, tools)
432 }
433
434 pub fn pair_from(
439 workdir: PathBuf,
440 providers: Arc<ProviderFactory>,
441 tools: Arc<ToolRegistry>,
442 ) -> (Self, mpsc::Receiver<Msg>) {
443 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
444 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
445 }
446
447 pub fn pair_from_with_task(
448 workdir: PathBuf,
449 providers: Arc<ProviderFactory>,
450 tools: Arc<ToolRegistry>,
451 task_id: Option<String>,
452 ) -> (Self, mpsc::Receiver<Msg>) {
453 let (runner, rx) = Self::pair_from(workdir, providers, tools);
454 (runner.with_task_id(task_id), rx)
455 }
456
457 pub fn new_child(
462 msg_tx: MsgSender,
463 workdir: PathBuf,
464 providers: Arc<ProviderFactory>,
465 tools: Arc<ToolRegistry>,
466 ) -> Self {
467 Self::new(msg_tx, workdir)
475 .with_bindings(providers, tools)
476 .without_terminal_title()
477 .without_global_mcp_shutdown()
478 }
479
480 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
484 self.scopes
485 .entry(turn)
486 .or_insert_with(|| TurnScope::new(turn))
487 }
488
489 fn tombstone_turn(&mut self, turn: TurnId) {
493 if self.cancelled_turns.contains(&turn) {
494 return;
495 }
496 if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
497 self.cancelled_turns.pop_front();
498 }
499 self.cancelled_turns.push_back(turn);
500 }
501
502 fn is_tombstoned(&self, turn: TurnId) -> bool {
505 self.cancelled_turns.contains(&turn)
506 }
507
508 fn drop_scope(&mut self, turn: TurnId) {
518 self.tombstone_turn(turn);
523 if let Some(mut scope) = self.scopes.remove(&turn) {
524 scope.cancel();
525 let tx = self.msg_tx.clone();
526 self.detached.spawn(async move {
527 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
528 .await
529 .is_err()
530 {
531 tracing::warn!(
532 turn = %turn,
533 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
534 "cancel drain timed out; aborting remaining scoped tasks"
535 );
536 }
537 let _ = tx.send(Msg::TurnCancelled(turn)).await;
538 });
539 } else {
540 let tx = self.msg_tx.clone();
547 self.detached.spawn(async move {
548 let _ = tx.send(Msg::TurnCancelled(turn)).await;
549 });
550 }
551 }
552
553 pub fn scope_count(&self) -> usize {
556 self.scopes.len()
557 }
558
559 fn reap_empty_scopes(&mut self) {
568 self.reap_detached();
569 self.scopes.retain(|_, scope| {
570 scope.drain_completed();
571 !scope.is_empty()
572 });
573 }
574
575 fn reap_detached(&mut self) {
580 while let Some(result) = self.detached.try_join_next() {
581 if let Err(e) = result
582 && !e.is_cancelled()
583 {
584 tracing::warn!(error = %e, "effect: detached task panicked");
585 }
586 }
587 }
588
589 pub fn dispatch(&mut self, cmd: Cmd) {
593 self.reap_empty_scopes();
596 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
597
598 if let Some(turn) = cmd.scope_turn()
606 && self.is_tombstoned(turn)
607 {
608 tracing::debug!(
609 cmd = %cmd.summary(),
610 turn = %turn,
611 "effect: dropping turn-scoped cmd for an already-cancelled turn"
612 );
613 return;
614 }
615
616 match cmd {
617 Cmd::CallModel { turn, mut request } => {
618 let tx = self.msg_tx.clone();
619 let providers = self.providers.clone();
620 if let Some(tools) = &self.tools
629 && request.output_schema.is_none()
630 {
631 let mut enriched = tools.describe_all();
632 let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
636 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
642 tracing::debug!(
643 error = %e,
644 "effect: dropped builtin tool-schema token estimate (channel full); \
645 /context preview may be briefly stale"
646 );
647 }
648 enriched.append(&mut request.tools);
649 request.tools = enriched;
650 }
651 self.detached.spawn(fire_plugin_hooks(
654 "prompt_submit",
655 serde_json::json!({
656 "turn_id": turn.0,
657 "model_id": request.model_id.clone(),
658 "message_count": request.messages.len(),
659 "tool_count": request.tools.len(),
660 }),
661 ));
662 let task_usage = self.tasks.clone();
665 let scope = self.scope_mut(turn);
666 let token = scope.token();
667 scope.spawn(async move {
668 use futures::FutureExt;
669 let fallback_tx = tx.clone();
670 if std::panic::AssertUnwindSafe(dispatch_call_model(
671 tx, providers, turn, request, token, task_usage,
672 ))
673 .catch_unwind()
674 .await
675 .is_err()
676 {
677 tracing::error!(turn = %turn, "dispatch_call_model panicked");
682 let _ = fallback_tx
683 .send(Msg::UpstreamError {
684 turn,
685 error: crate::models::UserFacingError {
686 summary: "Internal error".to_string(),
687 message: "The model dispatch task panicked unexpectedly."
688 .to_string(),
689 suggestion: "This is a bug. Please retry; if it persists, \
690 check the logs."
691 .to_string(),
692 category: crate::models::ErrorCategory::Internal,
693 recoverable: true,
694 },
695 })
696 .await;
697 }
698 });
699 },
700 Cmd::CompactConversation { turn, mut request } => {
701 let tx = self.msg_tx.clone();
702 let providers = self.providers.clone();
703 if let Some(tools) = &self.tools {
704 let mut enriched = tools.describe_all();
705 enriched.append(&mut request.chat.tools);
706 request.chat.tools = enriched;
707 }
708 let trigger = request.trigger;
711 let scope = self.scope_mut(turn);
712 let token = scope.token();
713 scope.spawn(async move {
714 use futures::FutureExt;
715 let fallback_tx = tx.clone();
716 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
717 tx, providers, turn, request, token,
718 ))
719 .catch_unwind()
720 .await
721 .is_err()
722 {
723 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
729 let _ = fallback_tx
730 .send(Msg::CompactionFailed {
731 turn,
732 trigger,
733 message: "the compaction task panicked unexpectedly".to_string(),
734 kind: crate::domain::StatusKind::Error,
735 })
736 .await;
737 }
738 });
739 },
740 Cmd::ExecuteTool {
741 turn,
742 call_id,
743 source,
744 model_id,
745 safety_mode,
746 plan_file,
747 plan_permissions,
748 context_percent,
749 intent,
750 session_id,
751 message_index,
752 scratchpad,
753 } => {
754 let tx = self.msg_tx.clone();
755 let tools = self.tools.clone();
756 let workdir = self.workdir.clone();
757 let config = self
762 .providers
763 .as_ref()
764 .map(|p| Arc::new(p.config().clone()))
765 .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
766 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
774 if safety_mode == crate::runtime::SafetyMode::Auto || plan_file.is_some() {
775 self.providers.as_ref().map(|p| {
776 let model = config
777 .safety
778 .auto_classifier_model
779 .clone()
780 .unwrap_or_else(|| model_id.clone());
781 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
782 as Arc<dyn crate::providers::AutoClassifier>
783 })
784 } else {
785 None
786 };
787 let task_id = self.task_id.clone();
788 let approval = self.approval.clone();
789 let questions = self.questions.clone();
790 let task_broker = self.tasks.clone();
791 let scope = self.scope_mut(turn);
792 let token = scope.token();
793 let background = scope.background_token();
794 scope.spawn(async move {
795 use futures::FutureExt;
796 let fallback_tx = tx.clone();
797 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
798 tx,
799 tools,
800 workdir,
801 turn,
802 call_id,
803 source,
804 token,
805 background,
806 config,
807 model_id,
808 task_id,
809 session_id,
810 message_index,
811 scratchpad,
812 safety_mode,
813 plan_file,
814 plan_permissions,
815 context_percent,
816 intent,
817 classifier,
818 approval,
819 questions,
820 task_broker,
821 ))
822 .catch_unwind()
823 .await
824 .is_err()
825 {
826 tracing::error!(
831 turn = %turn,
832 call_id = call_id.0,
833 "dispatch_execute_tool panicked"
834 );
835 let _ = fallback_tx
836 .send(Msg::ToolFinished {
837 turn,
838 call_id,
839 outcome: crate::domain::ToolOutcome::error(
840 "internal error: the tool execution task panicked".to_string(),
841 0.0,
842 ),
843 })
844 .await;
845 }
846 });
847 },
848 Cmd::ResolveApproval { call_id, decision } => {
849 if let Some(broker) = &self.approval {
852 broker.resolve(call_id, decision.into());
853 }
854 },
855 Cmd::ResolveQuestion {
856 call_id,
857 resolution,
858 } => {
859 if let Some(broker) = &self.questions {
862 broker.resolve(call_id, resolution);
863 }
864 },
865 Cmd::SyncTaskStore(store) => {
866 self.tasks.seed(store);
870 },
871 Cmd::EnsureScratchpad { session_id } => {
872 let tx = self.msg_tx.clone();
873 let workdir = self.workdir.clone();
874 self.detached.spawn(async move {
875 match crate::session::scratchpad::ensure(&workdir, &session_id) {
876 Ok(path) => {
877 let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
878 },
879 Err(err) => {
880 tracing::warn!(error = %err, "failed to create session scratchpad");
883 },
884 }
885 if let Err(err) = crate::session::scratchpad::sweep_stale(
888 crate::session::scratchpad::RETENTION_DAYS,
889 ) {
890 tracing::warn!(error = %err, "scratchpad sweep failed");
891 }
892 });
893 },
894 Cmd::ListScratchpad { path } => {
895 let tx = self.msg_tx.clone();
898 self.detached.spawn(async move {
899 let text = tokio::task::spawn_blocking(move || {
900 crate::session::scratchpad::list_text(&path)
901 })
902 .await
903 .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
904 let _ = tx.send(Msg::RuntimeText(text)).await;
905 });
906 },
907 Cmd::UserTaskEdit(edit) => {
908 let broker = self.tasks.clone();
913 let tx = self.msg_tx.clone();
914 self.detached.spawn(async move {
915 let (line, _snapshot) = broker.user_edit(edit).await;
916 let _ = tx
921 .send(Msg::TaskNotice {
922 text: format!(
923 "The user edited the task checklist: {line}. Acknowledge and \
924 incorporate this into your plan."
925 ),
926 })
927 .await;
928 let _ = tx.send(Msg::TransientStatus { text: line }).await;
929 });
930 },
931 Cmd::NotifyTaskCompleted {
932 task,
933 completed,
934 total,
935 } => {
936 let payload = serde_json::json!({
944 "task_id": task.id,
945 "subject": task.subject,
946 "description": task.description,
947 "evidence": task.evidence,
948 "completed": completed,
949 "total": total,
950 });
951 let broker = self.tasks.clone();
952 let tx = self.msg_tx.clone();
953 self.detached.spawn(async move {
954 let gate = run_plugin_hooks_gated("task_completed", payload).await;
955 let Some((plugin, reason)) = gate.deny else {
956 return;
957 };
958 let reason = crate::utils::redact_secrets(&reason);
959 let _ = broker
960 .update(vec![crate::domain::TaskEdit {
961 id: task.id,
962 status: Some(crate::domain::TaskStatus::InProgress),
963 ..crate::domain::TaskEdit::default()
964 }])
965 .await;
966 let _ = tx
967 .send(Msg::TaskNotice {
968 text: format!(
969 "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
970 {reason}. The task is back in_progress; address the reason \
971 before completing it again.",
972 task.id, task.subject
973 ),
974 })
975 .await;
976 let _ = tx
977 .send(Msg::TransientStatus {
978 text: format!(
979 "task #{} completion vetoed by {plugin}: {reason}",
980 task.id
981 ),
982 })
983 .await;
984 });
985 },
986 Cmd::CancelScope(turn) => {
987 self.drop_scope(turn);
988 },
989 Cmd::BackgroundScope(turn) => {
990 self.scope_mut(turn).background();
994 },
995 Cmd::SaveConversation(history) => {
996 self.queue_persistence(PersistenceJob::Conversation(Box::new(history)));
997 },
998 Cmd::SaveCompactionArchive {
999 archive,
1000 record,
1001 conversation,
1002 } => {
1003 self.queue_persistence(PersistenceJob::Compaction(Box::new(
1004 PendingCompactionSave {
1005 archive,
1006 record,
1007 conversation,
1008 task_id: self.task_id.clone(),
1009 },
1010 )));
1011 },
1012 Cmd::SaveProcess(process) => {
1013 let task_id = self.task_id.clone();
1014 self.detached.spawn(async move {
1015 let status = match process.status {
1016 crate::domain::ManagedProcessStatus::Running => {
1017 crate::runtime::ProcessStatus::Running
1018 },
1019 crate::domain::ManagedProcessStatus::Exited => {
1020 crate::runtime::ProcessStatus::Exited
1021 },
1022 crate::domain::ManagedProcessStatus::Unknown => {
1023 crate::runtime::ProcessStatus::Unknown
1024 },
1025 };
1026 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
1027 let _ = store.processes().upsert(crate::runtime::NewProcess {
1028 id: Some(process.id),
1029 task_id,
1030 pid: process.pid,
1031 command: process.command,
1032 cwd: process.cwd,
1033 log_path: Some(process.log_path),
1034 detected_url: process.detected_url,
1035 status,
1036 health: None,
1037 });
1038 }
1039 });
1040 },
1041 Cmd::PersistPlanConfig(plan) => {
1042 self.detached.spawn(async move {
1043 if let Err(err) = crate::app::persist_plan_config(&plan) {
1044 tracing::warn!(error = %err, "failed to persist [plan] config");
1045 }
1046 });
1047 },
1048 Cmd::PersistLastModel(model) => {
1049 self.detached.spawn(async move {
1050 if let Err(err) = crate::app::persist_last_model(&model) {
1051 tracing::warn!(error = %err, "failed to persist last-used model");
1052 }
1053 });
1054 },
1055 Cmd::PersistReasoningFor { model_id, level } => {
1056 self.detached.spawn(async move {
1057 if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1058 tracing::warn!(error = %err, "failed to persist reasoning level for model");
1059 }
1060 });
1061 },
1062 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1063 self.detached.spawn(async move {
1064 if let Err(err) =
1065 crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1066 {
1067 tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1068 }
1069 });
1070 },
1071 Cmd::PersistOllamaOffload(enabled) => {
1072 self.detached.spawn(async move {
1073 if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1074 tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1075 }
1076 });
1077 },
1078 Cmd::PersistUiTheme(theme) => {
1079 self.detached.spawn(async move {
1080 if let Err(err) = crate::app::persist_ui_theme(theme) {
1081 tracing::warn!(error = %err, "failed to persist theme");
1082 }
1083 });
1084 },
1085 Cmd::ComposeInEditor { .. } => {
1086 tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1090 },
1091 Cmd::ListMemory => {
1092 let tx = self.msg_tx.clone();
1093 let workdir = self.workdir.clone();
1094 self.detached.spawn(async move {
1095 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1096 let text = match crate::app::memory::load(&workdir, &cfg) {
1097 Some(mem) => mem.index,
1098 None => "No memories saved yet. Durable facts (yours or mine) show up here — use `/remember <fact>` or just ask me to remember something.".to_string(),
1099 };
1100 let _ = tx.send(Msg::RuntimeText(text)).await;
1101 });
1102 },
1103 Cmd::RememberMemory { text } => {
1104 let tx = self.msg_tx.clone();
1105 let workdir = self.workdir.clone();
1106 self.detached.spawn(async move {
1107 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1108 let name = memory_title_from_text(&text);
1109 let status = match crate::app::memory::write_memory(
1110 &workdir,
1111 crate::app::memory::MemoryScope::ProjectPrivate,
1112 &name,
1113 &text,
1114 &[],
1115 &text,
1116 ) {
1117 Ok(_) => format!("Remembered: {name}"),
1118 Err(e) => format!("Couldn't save memory: {e}"),
1119 };
1120 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1121 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1122 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1123 });
1124 },
1125 Cmd::ForgetMemory { id } => {
1126 let tx = self.msg_tx.clone();
1127 let workdir = self.workdir.clone();
1128 self.detached.spawn(async move {
1129 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1130 let status = match crate::app::memory::delete_memory(&workdir, &id) {
1131 Ok(Some(_)) => format!("Forgot: {id}"),
1132 Ok(None) => format!("No memory named '{id}'"),
1133 Err(e) => format!("Couldn't forget memory: {e}"),
1134 };
1135 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1136 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1137 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1138 });
1139 },
1140 Cmd::ConsolidateMemory { model_id } => {
1141 let tx = self.msg_tx.clone();
1142 let workdir = self.workdir.clone();
1143 let providers = self.providers.clone();
1144 self.detached.spawn(async move {
1145 consolidate_memory(tx, providers, workdir, model_id).await;
1146 });
1147 },
1148 Cmd::LoadConversation(id) => {
1149 let tx = self.msg_tx.clone();
1150 let workdir = self.workdir.clone();
1151 self.detached.spawn(async move {
1152 match crate::session::ConversationManager::new(&workdir) {
1153 Ok(mgr) => match mgr.load_conversation(&id) {
1154 Ok(history) => {
1155 let _ = tx.send(Msg::ConversationLoaded(history)).await;
1156 },
1157 Err(e) => {
1158 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
1159 },
1160 },
1161 Err(e) => {
1162 tracing::warn!(error = %e, "ConversationManager init failed");
1163 },
1164 }
1165 });
1166 },
1167 Cmd::ListConversations => {
1168 let tx = self.msg_tx.clone();
1169 let workdir = self.workdir.clone();
1170 self.detached.spawn(async move {
1171 let summaries = match crate::session::ConversationManager::new(&workdir) {
1172 Ok(mgr) => mgr
1173 .list_conversation_metas()
1174 .unwrap_or_default()
1175 .into_iter()
1176 .map(|m| crate::domain::ConversationSummary {
1177 id: m.id,
1178 title: m.title,
1179 message_count: m.message_count,
1180 updated_at: m.updated_at.to_rfc3339(),
1181 })
1182 .collect(),
1183 Err(_) => Vec::new(),
1184 };
1185 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
1186 });
1187 },
1188 Cmd::ListProjectFiles => {
1189 let tx = self.msg_tx.clone();
1190 let workdir = self.workdir.clone();
1191 self.detached.spawn_blocking(move || {
1193 let files = walk_project_files(&workdir);
1194 let _ = tx.blocking_send(Msg::ProjectFilesListed(files));
1195 });
1196 },
1197 Cmd::ListRuntimeTasks { limit } => {
1198 let tx = self.msg_tx.clone();
1199 self.detached.spawn_blocking(move || {
1202 let tasks = crate::runtime::RuntimeClient::auto()
1203 .list_tasks(limit)
1204 .map(|read| read.value)
1205 .unwrap_or_default();
1206 let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
1207 });
1208 },
1209 Cmd::LoadRuntimeTask { id } => {
1210 let tx = self.msg_tx.clone();
1211 self.detached.spawn_blocking(move || {
1212 let (task, events) = crate::runtime::RuntimeClient::auto()
1213 .task_detail(&id)
1214 .map(|read| (Some(read.value.task), read.value.events))
1215 .unwrap_or((None, Vec::new()));
1216 let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
1217 });
1218 },
1219 Cmd::ListRuntimeProcesses { limit } => {
1220 let tx = self.msg_tx.clone();
1221 self.detached.spawn_blocking(move || {
1222 let processes = crate::runtime::RuntimeClient::auto()
1223 .list_processes(limit)
1224 .map(|read| read.value)
1225 .unwrap_or_default();
1226 let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
1227 });
1228 },
1229 Cmd::ShowRuntimeProcessLogs { id } => {
1230 let tx = self.msg_tx.clone();
1231 self.detached.spawn_blocking(move || {
1232 let text = crate::runtime::RuntimeClient::auto()
1233 .process_log(&id, None)
1234 .map(|log| format!("Process log {}\n\n{}", id, log.content))
1235 .unwrap_or_else(|err| format!("Process log error: {}", err));
1236 let _ = tx.blocking_send(Msg::RuntimeText(text));
1237 });
1238 },
1239 Cmd::StopRuntimeProcess { id } => {
1240 let tx = self.msg_tx.clone();
1241 self.detached.spawn_blocking(move || {
1242 let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
1243 Ok(response) => Msg::TransientStatus {
1244 text: format!("Stopped process {} (pid {})", id, response.item.pid),
1245 },
1246 Err(err) => Msg::TransientStatus {
1247 text: format!("Process stop failed: {}", err),
1248 },
1249 };
1250 let _ = tx.blocking_send(msg);
1251 });
1252 },
1253 Cmd::KillBackgroundAgent { agent_id } => {
1254 let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1258 if let Some(spawner) = spawner {
1259 match agent_id {
1260 Some(id) => {
1261 spawner.kill_detached(&id);
1262 },
1263 None => {
1264 spawner.kill_all_detached();
1265 },
1266 }
1267 }
1268 },
1269 Cmd::RestartRuntimeProcess { id } => {
1270 let tx = self.msg_tx.clone();
1271 self.detached.spawn_blocking(move || {
1272 let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
1273 Ok(response) => Msg::TransientStatus {
1274 text: format!("Restarted process {} (pid {})", id, response.item.pid),
1275 },
1276 Err(err) => Msg::TransientStatus {
1277 text: format!("Process restart failed: {}", err),
1278 },
1279 };
1280 let _ = tx.blocking_send(msg);
1281 });
1282 },
1283 Cmd::OpenRuntimeTarget { target } => {
1284 self.detached.spawn_blocking(move || {
1285 let resolved = crate::runtime::RuntimeService::open_default()
1286 .and_then(|service| service.resolve_open_target(&target))
1287 .unwrap_or(target);
1288 if let Err(err) = crate::runtime::validate_open_target(&resolved) {
1292 tracing::warn!(error = %err, "refusing to open runtime target");
1293 return;
1294 }
1295 crate::utils::open_file(resolved);
1296 });
1297 },
1298 Cmd::ShowRuntimePorts => {
1299 let tx = self.msg_tx.clone();
1300 self.detached.spawn_blocking(move || {
1301 let text = crate::runtime::RuntimeClient::auto()
1302 .ports()
1303 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1304 .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
1305 let _ = tx.blocking_send(Msg::RuntimeText(text));
1306 });
1307 },
1308 Cmd::ListRuntimeApprovals => {
1309 let tx = self.msg_tx.clone();
1310 self.detached.spawn_blocking(move || {
1311 let approvals = crate::runtime::RuntimeClient::auto()
1312 .list_approvals()
1313 .map(|read| read.value)
1314 .unwrap_or_default();
1315 let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
1316 });
1317 },
1318 Cmd::DecideRuntimeApproval { id, decision } => {
1319 let tx = self.msg_tx.clone();
1320 self.detached.spawn_blocking(move || {
1321 let result = if decision == "approved" {
1322 crate::runtime::RuntimeClient::auto().approve(&id)
1323 } else {
1324 crate::runtime::RuntimeClient::auto().deny(&id)
1325 };
1326 let msg = match result {
1327 Ok(result) => Msg::TransientStatus {
1328 text: if result.replayed {
1329 format!("Approval {} {}: {}", id, decision, result.summary)
1330 } else {
1331 format!("Approval {} {}", id, decision)
1332 },
1333 },
1334 Err(err) => Msg::TransientStatus {
1335 text: format!("Approval update failed: {}", err),
1336 },
1337 };
1338 let _ = tx.blocking_send(msg);
1339 });
1340 },
1341 Cmd::ListRuntimeCheckpoints { limit } => {
1342 let tx = self.msg_tx.clone();
1343 self.detached.spawn_blocking(move || {
1344 let checkpoints = crate::runtime::RuntimeClient::auto()
1345 .list_checkpoints(limit)
1346 .map(|read| read.value)
1347 .unwrap_or_default();
1348 let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
1349 });
1350 },
1351 Cmd::ListForkCheckpoints {
1352 session_id,
1353 message_index,
1354 } => {
1355 let tx = self.msg_tx.clone();
1356 self.detached.spawn_blocking(move || {
1357 let checkpoints = crate::runtime::RuntimeStore::open_default()
1358 .and_then(|store| {
1359 store
1360 .checkpoints()
1361 .list_for_session(&session_id, message_index as i64)
1362 })
1363 .unwrap_or_default();
1364 let _ = tx.blocking_send(Msg::ForkCheckpointsFound(checkpoints));
1365 });
1366 },
1367 Cmd::ListRuntimePlugins => {
1368 let tx = self.msg_tx.clone();
1369 self.detached.spawn_blocking(move || {
1370 let plugins = crate::runtime::RuntimeClient::auto()
1371 .list_plugins()
1372 .map(|read| read.value)
1373 .unwrap_or_default();
1374 let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
1375 });
1376 },
1377 Cmd::UpdateRuntimeTaskStatus {
1378 id,
1379 status,
1380 final_report,
1381 } => {
1382 let tx = self.msg_tx.clone();
1383 self.detached.spawn_blocking(move || {
1384 let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
1385 store
1386 .tasks()
1387 .update_status(&id, status, final_report.as_deref())
1388 }) {
1389 Ok(()) => Msg::TransientStatus {
1390 text: format!("Task {} -> {}", id, status),
1391 },
1392 Err(err) => Msg::TransientStatus {
1393 text: format!("Task update failed: {}", err),
1394 },
1395 };
1396 let _ = tx.blocking_send(msg);
1397 });
1398 },
1399 Cmd::CreateRuntimeCheckpoint { paths } => {
1400 let tx = self.msg_tx.clone();
1401 let workdir = self.workdir.clone();
1402 self.detached.spawn_blocking(move || {
1403 let pending_action = Some(serde_json::json!({
1404 "source": "tui",
1405 "command": "checkpoint",
1406 }));
1407 let msg =
1408 match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
1409 Ok(manifest) => Msg::TransientStatus {
1410 text: format!(
1411 "Checkpoint {} created for {} path(s)",
1412 manifest.id,
1413 manifest.files.len()
1414 ),
1415 },
1416 Err(err) => Msg::TransientStatus {
1417 text: format!("Checkpoint failed: {}", err),
1418 },
1419 };
1420 let _ = tx.blocking_send(msg);
1421 });
1422 },
1423 Cmd::RestoreRuntimeCheckpoint { id } => {
1424 let tx = self.msg_tx.clone();
1425 self.detached.spawn_blocking(move || {
1426 let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1427 Ok(result) => Msg::TransientStatus {
1428 text: format!(
1429 "Restored checkpoint {} ({} file(s)){}",
1430 result.checkpoint.id,
1431 result.checkpoint.files.len(),
1432 if result.checkpoint.pending_action.is_some() {
1433 "; pending action available in checkpoint manifest"
1434 } else {
1435 ""
1436 }
1437 ),
1438 },
1439 Err(err) => Msg::TransientStatus {
1440 text: format!("Restore failed: {}", err),
1441 },
1442 };
1443 let _ = tx.blocking_send(msg);
1444 });
1445 },
1446 Cmd::ShowRuntimeModelInfo { model } => {
1447 let tx = self.msg_tx.clone();
1448 self.detached.spawn_blocking(move || {
1449 let text = runtime_model_info_text(&model);
1450 let _ = tx.blocking_send(Msg::RuntimeText(text));
1451 });
1452 },
1453 Cmd::InitMcpServers(configs) => {
1454 let tx = self.msg_tx.clone();
1455 self.detached
1456 .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1457 },
1458 Cmd::StopMcpServer { name } => {
1459 let tx = self.msg_tx.clone();
1460 self.detached.spawn(async move {
1461 if let Some(mgr) = crate::mcp::manager_ref::get() {
1464 mgr.stop_server(&name).await;
1465 }
1466 let _ = tx.send(Msg::McpServerStopped { name }).await;
1467 });
1468 },
1469 Cmd::PullOllamaModel { model } => {
1470 let tx = self.msg_tx.clone();
1471 self.detached.spawn(async move {
1472 dispatch_pull_ollama_model(tx, model).await;
1473 });
1474 },
1475 Cmd::OpenInSystem(path) => {
1476 self.detached.spawn(async move {
1477 let _ = tokio::task::spawn_blocking(move || {
1478 crate::utils::open_file(&path);
1479 })
1480 .await;
1481 });
1482 },
1483 Cmd::WriteImageToTemp {
1484 path,
1485 bytes,
1486 format: _,
1487 } => {
1488 self.detached.spawn(async move {
1489 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1490 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1491 }
1492 });
1493 },
1494 Cmd::ReadClipboard => {
1495 let tx = self.msg_tx.clone();
1496 self.detached.spawn(async move {
1497 dispatch_read_clipboard(tx).await;
1498 });
1499 },
1500 Cmd::ProbeVision { model_id, warn } => {
1501 let tx = self.msg_tx.clone();
1502 let providers = self.providers.clone();
1503 self.detached.spawn(async move {
1504 dispatch_probe_vision(model_id, warn, providers, tx).await;
1505 });
1506 },
1507 Cmd::CopyToClipboard(text) => {
1508 let tx = self.msg_tx.clone();
1509 self.detached.spawn(async move {
1510 dispatch_copy_to_clipboard(text, tx).await;
1511 });
1512 },
1513 Cmd::Exit => {
1514 },
1519 Cmd::SetTerminalTitle(title) => {
1520 if !self.terminal_title_enabled {
1521 return;
1522 }
1523 self.detached.spawn_blocking(move || {
1529 use std::io::Write;
1530 let seq = format!("\x1b]2;{}\x07", title);
1531 let mut stdout = std::io::stdout();
1532 let _ = stdout.write_all(seq.as_bytes());
1533 let _ = stdout.flush();
1534 });
1535 },
1536 Cmd::AlertUser => {
1537 if !self.terminal_title_enabled {
1538 return;
1539 }
1540 self.detached.spawn_blocking(|| {
1543 use std::io::Write;
1544 let mut stdout = std::io::stdout();
1545 let _ = stdout.write_all(b"\x07");
1546 let _ = stdout.flush();
1547 });
1548 },
1549 }
1550 }
1551
1552 fn queue_persistence(&mut self, job: PersistenceJob) {
1553 let previous = self.persistence_tail.take();
1554 let state = Arc::clone(&self.persistence_state);
1555 let tx = self.msg_tx.clone();
1556 self.persistence_tail = Some(tokio::spawn(async move {
1557 if let Some(previous) = previous
1558 && let Err(error) = previous.await
1559 {
1560 tracing::warn!(error = %error, "previous persistence job panicked");
1561 }
1562
1563 let result = tokio::task::spawn_blocking(move || {
1564 state
1565 .lock()
1566 .unwrap_or_else(|error| error.into_inner())
1567 .process(job)
1568 })
1569 .await;
1570
1571 match result {
1572 Ok((events, outcome)) => {
1573 if outcome.is_ok() || !events.is_empty() {
1577 let _ = tx.send(Msg::SessionSaved).await;
1578 }
1579 for event in events {
1580 fire_compaction_hook(&event).await;
1581 }
1582 if let Err(error) = outcome {
1583 tracing::warn!(
1584 error = %error,
1585 "persistence job failed; compaction barriers remain queued"
1586 );
1587 }
1588 },
1589 Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1590 }
1591 }));
1592 }
1593
1594 pub async fn shutdown(mut self) {
1598 for (id, scope) in self.scopes.iter() {
1599 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1600 scope.cancel();
1601 }
1602
1603 if let Some(handle) = self.config_watch.take() {
1606 handle.abort();
1607 }
1608
1609 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1611
1612 let owns_global_mcp = self.owns_global_mcp;
1613 let persistence_tail = self.persistence_tail.take();
1614 let persistence_state = Arc::clone(&self.persistence_state);
1615 let drain = async {
1616 if let Some(tail) = persistence_tail
1617 && let Err(error) = tail.await
1618 {
1619 tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1620 }
1621 match tokio::task::spawn_blocking(move || {
1622 persistence_state
1623 .lock()
1624 .unwrap_or_else(|error| error.into_inner())
1625 .retry_all_blocked()
1626 })
1627 .await
1628 {
1629 Ok((events, outcome)) => {
1630 for event in events {
1633 fire_compaction_hook(&event).await;
1634 }
1635 if let Err(error) = outcome {
1636 tracing::warn!(
1637 error = %error,
1638 "shutdown: compaction persistence barrier retry failed"
1639 );
1640 }
1641 },
1642 Err(error) => tracing::warn!(
1643 error = %error,
1644 "shutdown: compaction persistence barrier panicked"
1645 ),
1646 }
1647 if owns_global_mcp {
1651 let _ = tokio::time::timeout(
1657 std::time::Duration::from_secs(2),
1658 crate::mcp::manager_ref::wait_ready(),
1659 )
1660 .await;
1661 if let Some(mgr) = crate::mcp::manager_ref::get() {
1668 mgr.shutdown().await;
1669 }
1670 crate::searxng::shutdown().await;
1674 }
1675 for (id, mut scope) in self.scopes.drain() {
1681 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1682 .await
1683 .is_err()
1684 {
1685 tracing::warn!(
1686 turn = %id,
1687 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1688 "shutdown: scope drain timed out; aborting its remaining tasks"
1689 );
1690 }
1691 }
1692 while let Some(result) = self.detached.join_next().await {
1693 if let Err(e) = result
1694 && !e.is_cancelled()
1695 {
1696 tracing::warn!(error = %e, "shutdown: detached task panic");
1697 }
1698 }
1699 };
1700
1701 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1702 }
1703}
1704
1705fn note_stream_usage(
1713 tasks: &crate::providers::TaskBroker,
1714 usage: &Option<crate::models::TokenUsage>,
1715) {
1716 if let Some(usage) = usage {
1717 tasks.add_tokens(usage.completion_tokens as u64);
1718 }
1719}
1720
1721async fn dispatch_call_model(
1722 msg_tx: MsgSender,
1723 providers: Option<Arc<ProviderFactory>>,
1724 turn: TurnId,
1725 mut request: crate::domain::ChatRequest,
1726 token: tokio_util::sync::CancellationToken,
1727 tasks: crate::providers::TaskBroker,
1728) {
1729 use crate::models::UserFacingError;
1730
1731 let Some(factory) = providers else {
1732 let error = UserFacingError {
1733 summary: "not wired".to_string(),
1734 message: "EffectRunner has no ProviderFactory bound".to_string(),
1735 suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1736 category: crate::models::ErrorCategory::Internal,
1737 recoverable: false,
1738 };
1739 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1740 return;
1741 };
1742
1743 let provider = match factory.resolve(&request.model_id).await {
1745 Ok(p) => p,
1746 Err(e) => {
1747 let error = classify_error_for_ui(&e);
1748 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1749 return;
1750 },
1751 };
1752 {
1753 let model_id = request.model_id.clone();
1757 let caps = provider.capabilities().clone();
1758 if let Err(e) =
1765 tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
1766 .await
1767 {
1768 tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
1769 }
1770 }
1771 if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1772 let _ = msg_tx
1773 .send(Msg::TransientStatus {
1774 text: format!(
1775 "{} does not advertise tool support; Mermaid will send the turn without tools",
1776 request.model_id
1777 ),
1778 })
1779 .await;
1780 request.tools.clear();
1781 }
1782
1783 let sizing = provider.resolve_context_window(&request).await;
1789 let max_context_tokens = sizing.effective.or_else(|| {
1790 crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1791 });
1792 request.resolved_context_window = sizing.effective.or(sizing.model_max);
1798 request.resolved_max_output = sizing.max_output;
1799 let _ = msg_tx
1803 .send(Msg::ProviderContextResolved {
1804 model_id: request.model_id.clone(),
1805 model_max: sizing.model_max,
1806 effective: sizing.effective,
1807 source: sizing.source,
1808 max_output: sizing.max_output,
1809 })
1810 .await;
1811 if request
1817 .messages
1818 .iter()
1819 .any(|m| m.images.as_ref().is_some_and(|v| !v.is_empty()))
1820 {
1821 let supports_vision = provider.supports_vision().await;
1822 let _ = msg_tx
1823 .send(Msg::ProviderVisionResolved {
1824 model_id: request.model_id.clone(),
1825 supports_vision,
1826 warn: true,
1827 })
1828 .await;
1829 }
1830 let context_snapshot =
1831 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1832 let _ = msg_tx
1833 .send(Msg::ContextUsageEstimated {
1834 turn,
1835 snapshot: context_snapshot.clone(),
1836 })
1837 .await;
1838
1839 let policy = CompactionPolicy::default();
1840 let mut compacted_before_stream = false;
1841 if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1842 let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1843 if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1846 match run_compaction(
1847 Arc::clone(&provider),
1848 turn,
1849 compaction,
1850 prepared,
1851 context_snapshot.clone(),
1852 max_context_tokens,
1853 token.clone(),
1854 )
1855 .await
1856 {
1857 Ok(result) => {
1858 request.messages = result.replacement_messages.clone();
1859 compacted_before_stream = true;
1860 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1861 },
1862 Err(err) => {
1863 if token.is_cancelled() {
1871 return;
1872 }
1873 tracing::warn!(
1874 turn = %turn,
1875 error = %err,
1876 "auto-compaction failed; proceeding with the un-compacted request",
1877 );
1878 let _ = msg_tx
1879 .send(Msg::CompactionFailed {
1880 turn,
1881 trigger: CompactionTrigger::AutoThreshold,
1882 message: err.to_string(),
1883 kind: crate::domain::StatusKind::Warn,
1884 })
1885 .await;
1886 },
1887 }
1888 }
1889 }
1890
1891 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1894 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1895
1896 let relay_tx = msg_tx.clone();
1903 let relay_token = token.clone();
1904 let relay_tasks = tasks.clone();
1905 let relay = spawn_guarded(async move {
1906 loop {
1907 let event = tokio::select! {
1908 biased;
1909 _ = relay_token.cancelled() => {
1910 while let Ok(buffered) = stream_rx.try_recv() {
1917 if let StreamEvent::Done {
1918 usage,
1919 provider_continuation,
1920 stop_reason,
1921 } = buffered
1922 {
1923 note_stream_usage(&relay_tasks, &usage);
1924 let _ = relay_tx
1925 .send(Msg::StreamDone {
1926 turn,
1927 usage,
1928 provider_continuation,
1929 stop_reason,
1930 })
1931 .await;
1932 }
1933 }
1934 break;
1935 },
1936 ev = stream_rx.recv() => match ev {
1937 Some(ev) => ev,
1938 None => break,
1939 },
1940 };
1941 let msg = match event {
1942 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1943 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1944 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1945 StreamEvent::Status(text) => Msg::TransientStatus { text },
1948 StreamEvent::Done {
1949 usage,
1950 provider_continuation,
1951 stop_reason,
1952 } => {
1953 note_stream_usage(&relay_tasks, &usage);
1954 Msg::StreamDone {
1955 turn,
1956 usage,
1957 provider_continuation,
1958 stop_reason,
1959 }
1960 },
1961 };
1962 if relay_tx.send(msg).await.is_err() {
1963 break;
1964 }
1965 }
1966 });
1967
1968 let mut completed_ok = false;
1977 match provider.chat(request.clone(), ctx).await {
1978 Ok(_final_response) => {
1979 completed_ok = true;
1981 },
1982 Err(crate::models::ModelError::Cancelled) => {
1983 },
1985 Err(e) => {
1986 let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1987 if retry_context_limit {
1988 let latest_snapshot =
1989 crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1990 let compaction =
1991 CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1992 if let Ok(prepared) =
1995 crate::domain::prepare_compaction(&compaction, max_context_tokens)
1996 {
1997 match run_compaction(
1998 Arc::clone(&provider),
1999 turn,
2000 compaction,
2001 prepared,
2002 latest_snapshot,
2003 max_context_tokens,
2004 token.clone(),
2005 )
2006 .await
2007 {
2008 Ok(result) => {
2009 let mut retry_request = request;
2010 retry_request.messages = result.replacement_messages.clone();
2011 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2012 join_logged(relay.take(), "stream_relay").await;
2013 dispatch_provider_stream(
2014 msg_tx,
2015 provider,
2016 turn,
2017 retry_request,
2018 token,
2019 tasks,
2020 )
2021 .await;
2022 return;
2023 },
2024 Err(compact_err) => {
2025 let _ = msg_tx
2026 .send(Msg::CompactionFailed {
2027 turn,
2028 trigger: CompactionTrigger::ContextLimitRetry,
2029 message: compact_err.to_string(),
2030 kind: crate::domain::StatusKind::Error,
2031 })
2032 .await;
2033 },
2034 }
2035 }
2036 }
2037 let error = classify_error_for_ui(&e);
2038 run_provider_error_hook(&request.model_id, &error).await;
2039 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
2040 },
2041 }
2042
2043 join_logged(relay.take(), "stream_relay").await;
2044
2045 if completed_ok
2050 && request.ollama_allow_ram_offload != Some(true)
2051 && let Some(p) = provider.verify_placement(sizing.effective).await
2052 {
2053 tracing::debug!(
2054 size_vram_bytes = p.size_vram_bytes,
2055 total_bytes = p.total_bytes,
2056 offloaded = p.size_vram_bytes < p.total_bytes,
2057 suggested_num_ctx = ?p.suggested_num_ctx,
2058 "Ollama placement"
2059 );
2060 let _ = msg_tx
2061 .send(Msg::OllamaPlacementResolved {
2062 model_id: request.model_id.clone(),
2063 size_vram_bytes: p.size_vram_bytes,
2064 total_bytes: p.total_bytes,
2065 suggested_num_ctx: p.suggested_num_ctx,
2066 })
2067 .await;
2068 }
2069}
2070
2071struct TurnTimer {
2075 turn: TurnId,
2076 model_id: String,
2077 started: std::time::Instant,
2078}
2079
2080impl Drop for TurnTimer {
2081 fn drop(&mut self) {
2082 tracing::debug!(
2083 turn = %self.turn,
2084 model = %self.model_id,
2085 elapsed_ms = self.started.elapsed().as_millis() as u64,
2086 "model turn complete"
2087 );
2088 }
2089}
2090
2091async fn dispatch_provider_stream(
2092 msg_tx: MsgSender,
2093 provider: Arc<dyn ModelProvider>,
2094 turn: TurnId,
2095 request: crate::domain::ChatRequest,
2096 token: tokio_util::sync::CancellationToken,
2097 tasks: crate::providers::TaskBroker,
2098) {
2099 let _turn_timer = TurnTimer {
2100 turn,
2101 model_id: request.model_id.clone(),
2102 started: std::time::Instant::now(),
2103 };
2104 let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
2105 let ctx = StreamContext::new(token.clone(), stream_tx, turn);
2106 let relay_tx = msg_tx.clone();
2107 let relay_token = token.clone();
2108 let relay_tasks = tasks.clone();
2109 let relay = spawn_guarded(async move {
2110 loop {
2111 let event = tokio::select! {
2112 biased;
2113 _ = relay_token.cancelled() => {
2114 while let Ok(buffered) = stream_rx.try_recv() {
2121 if let StreamEvent::Done {
2122 usage,
2123 provider_continuation,
2124 stop_reason,
2125 } = buffered
2126 {
2127 note_stream_usage(&relay_tasks, &usage);
2128 let _ = relay_tx
2129 .send(Msg::StreamDone {
2130 turn,
2131 usage,
2132 provider_continuation,
2133 stop_reason,
2134 })
2135 .await;
2136 }
2137 }
2138 break;
2139 },
2140 ev = stream_rx.recv() => match ev {
2141 Some(ev) => ev,
2142 None => break,
2143 },
2144 };
2145 let msg = match event {
2146 StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
2147 StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
2148 StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
2149 StreamEvent::Status(text) => Msg::TransientStatus { text },
2151 StreamEvent::Done {
2152 usage,
2153 provider_continuation,
2154 stop_reason,
2155 } => {
2156 note_stream_usage(&relay_tasks, &usage);
2157 Msg::StreamDone {
2158 turn,
2159 usage,
2160 provider_continuation,
2161 stop_reason,
2162 }
2163 },
2164 };
2165 if relay_tx.send(msg).await.is_err() {
2166 break;
2167 }
2168 }
2169 });
2170
2171 let model_id = request.model_id.clone();
2172 match provider.chat(request, ctx).await {
2173 Ok(_) | Err(ModelError::Cancelled) => {},
2174 Err(e) => {
2175 let error = classify_error_for_ui(&e);
2176 run_provider_error_hook(&model_id, &error).await;
2177 let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
2178 },
2179 }
2180
2181 join_logged(relay.take(), "stream_relay").await;
2182}
2183
2184async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
2190 let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
2191 .await;
2192}
2193
2194async fn run_plugin_hooks_gated(
2199 event: &'static str,
2200 payload: serde_json::Value,
2201) -> crate::runtime::HookGate {
2202 tokio::task::spawn_blocking(move || {
2203 crate::runtime::run_plugin_hooks(event, &payload)
2204 .map(crate::runtime::aggregate_hook_responses)
2205 .unwrap_or_default()
2206 })
2207 .await
2208 .unwrap_or_default()
2209}
2210
2211async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
2212 fire_plugin_hooks(
2213 "provider_error",
2214 serde_json::json!({
2215 "model_id": model_id,
2216 "summary": &error.summary,
2217 "message": &error.message,
2218 "category": format!("{:?}", error.category),
2219 "recoverable": error.recoverable,
2220 }),
2221 )
2222 .await;
2223}
2224
2225fn memory_title_from_text(text: &str) -> String {
2229 let first = text
2230 .lines()
2231 .find(|l| !l.trim().is_empty())
2232 .unwrap_or("memory")
2233 .trim();
2234 let title: String = first
2235 .split_whitespace()
2236 .take(8)
2237 .collect::<Vec<_>>()
2238 .join(" ")
2239 .chars()
2240 .take(60)
2241 .collect();
2242 if title.trim().is_empty() {
2243 "memory".to_string()
2244 } else {
2245 title
2246 }
2247}
2248
2249const CONSOLIDATE_SYSTEM_PROMPT: &str = "You maintain a coding agent's durable memory: a set of atomic facts. Your only job is to find facts that are EXACT DUPLICATES or CLEARLY OBSOLETE/SUPERSEDED by another fact, and list their ids for pruning. Never prune facts that are merely related or similar but carry distinct information. Never rewrite or merge facts. When in doubt, keep. Reply with ONLY a JSON object: {\"prune\": [\"id1\", \"id2\"], \"reason\": \"one short sentence\"}. If nothing should be pruned, return an empty prune list.";
2250
2251#[derive(Debug)]
2252struct PrunePlan {
2253 prune: Vec<String>,
2254 reason: String,
2255}
2256
2257fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
2260 let start = text.find('{')?;
2261 let end = text.rfind('}')?;
2262 if end < start {
2263 return None;
2264 }
2265 let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
2266 let prune = json
2267 .get("prune")?
2268 .as_array()?
2269 .iter()
2270 .filter_map(|v| v.as_str().map(str::to_string))
2271 .collect();
2272 let reason = json
2273 .get("reason")
2274 .and_then(|v| v.as_str())
2275 .unwrap_or("")
2276 .to_string();
2277 Some(PrunePlan { prune, reason })
2278}
2279
2280async fn consolidate_memory(
2284 tx: MsgSender,
2285 providers: Option<Arc<ProviderFactory>>,
2286 workdir: std::path::PathBuf,
2287 model_id: String,
2288) {
2289 let items = crate::app::memory::entries_with_bodies(&workdir);
2290 if items.len() < 2 {
2291 let _ = tx
2292 .send(Msg::RuntimeText(format!(
2293 "Nothing to consolidate — {} memor{} saved.",
2294 items.len(),
2295 if items.len() == 1 { "y" } else { "ies" }
2296 )))
2297 .await;
2298 return;
2299 }
2300 let Some(factory) = providers else {
2301 let _ = tx
2302 .send(Msg::RuntimeText(
2303 "Memory consolidation needs a model provider, which isn't bound in this session."
2304 .to_string(),
2305 ))
2306 .await;
2307 return;
2308 };
2309
2310 let mut listing = String::new();
2311 for (entry, body) in &items {
2312 let id = entry
2313 .path
2314 .file_stem()
2315 .and_then(|s| s.to_str())
2316 .unwrap_or(entry.name.as_str());
2317 listing.push_str(&format!(
2318 "- id: {id}\n scope: {}\n description: {}\n body: {}\n",
2319 entry.scope.as_str(),
2320 entry.description,
2321 body.replace('\n', " ").trim(),
2322 ));
2323 }
2324 let user = format!(
2325 "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
2326 items.len(),
2327 listing
2328 );
2329 let request = crate::domain::ChatRequest {
2330 model_id: model_id.clone(),
2331 messages: vec![crate::models::ChatMessage::user(user)],
2332 system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
2333 instructions: None,
2334 reasoning: crate::models::ReasoningLevel::None,
2335 temperature: 0.0,
2336 max_tokens: 1024,
2337 tools: Vec::new(),
2338 ollama_num_ctx: None,
2339 ollama_allow_ram_offload: None,
2340 resolved_context_window: None,
2341 resolved_max_output: None,
2342 output_schema: None,
2343 suppress_auto_compact: false,
2344 };
2345
2346 let provider = match factory.resolve(&model_id).await {
2347 Ok(p) => p,
2348 Err(e) => {
2349 let _ = tx
2350 .send(Msg::RuntimeText(format!(
2351 "Memory consolidation failed: {e}"
2352 )))
2353 .await;
2354 return;
2355 },
2356 };
2357 let token = tokio_util::sync::CancellationToken::new();
2358 let text =
2359 match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
2360 Ok((t, _)) => t,
2361 Err(e) => {
2362 let _ = tx
2363 .send(Msg::RuntimeText(format!(
2364 "Memory consolidation failed: {e}"
2365 )))
2366 .await;
2367 return;
2368 },
2369 };
2370
2371 let Some(plan) = parse_prune_plan(&text) else {
2372 let _ = tx
2373 .send(Msg::RuntimeText(
2374 "Memory consolidation: couldn't parse the model's plan; nothing changed."
2375 .to_string(),
2376 ))
2377 .await;
2378 return;
2379 };
2380 if plan.prune.is_empty() {
2381 let reason = if plan.reason.is_empty() {
2382 String::new()
2383 } else {
2384 format!(" {}", plan.reason)
2385 };
2386 let _ = tx
2387 .send(Msg::RuntimeText(format!(
2388 "Memory consolidation: nothing to prune.{reason}"
2389 )))
2390 .await;
2391 return;
2392 }
2393
2394 let paths: Vec<std::path::PathBuf> = plan
2400 .prune
2401 .iter()
2402 .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
2403 .collect();
2404 if !paths.is_empty()
2405 && let Err(e) = crate::runtime::create_checkpoint(
2406 &workdir,
2407 &paths,
2408 Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
2409 )
2410 {
2411 let _ = tx
2412 .send(Msg::RuntimeText(format!(
2413 "Memory consolidation aborted: couldn't checkpoint the {} file{} marked for pruning, so nothing was deleted (no memory lost). Error: {e}",
2414 paths.len(),
2415 if paths.len() == 1 { "" } else { "s" },
2416 )))
2417 .await;
2418 return;
2419 }
2420
2421 let mut pruned = Vec::new();
2422 for id in &plan.prune {
2423 if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
2424 pruned.push(id.clone());
2425 }
2426 }
2427
2428 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
2429 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
2430 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
2431
2432 let report = if pruned.is_empty() {
2433 "Memory consolidation: the model named facts to prune, but none matched existing memories."
2434 .to_string()
2435 } else {
2436 format!(
2437 "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
2438 pruned.len(),
2439 if pruned.len() == 1 { "" } else { "s" },
2440 pruned.join(", "),
2441 if plan.reason.is_empty() {
2442 String::new()
2443 } else {
2444 format!(" Reason: {}.", plan.reason)
2445 },
2446 )
2447 };
2448 let _ = tx.send(Msg::RuntimeText(report)).await;
2449}
2450
2451async fn dispatch_compact_conversation(
2452 msg_tx: MsgSender,
2453 providers: Option<Arc<ProviderFactory>>,
2454 turn: TurnId,
2455 mut request: CompactionRequest,
2456 token: tokio_util::sync::CancellationToken,
2457) {
2458 let Some(factory) = providers else {
2459 let _ = msg_tx
2460 .send(Msg::CompactionFailed {
2461 turn,
2462 trigger: request.trigger,
2463 message: "EffectRunner has no ProviderFactory bound".to_string(),
2464 kind: crate::domain::StatusKind::Error,
2465 })
2466 .await;
2467 return;
2468 };
2469
2470 let provider = match factory.resolve(&request.chat.model_id).await {
2471 Ok(provider) => provider,
2472 Err(err) => {
2473 let _ = msg_tx
2474 .send(Msg::CompactionFailed {
2475 turn,
2476 trigger: request.trigger,
2477 message: err.to_string(),
2478 kind: crate::domain::StatusKind::Error,
2479 })
2480 .await;
2481 return;
2482 },
2483 };
2484
2485 let sizing = provider.resolve_context_window(&request.chat).await;
2490 request.chat.resolved_context_window = sizing.effective.or(sizing.model_max);
2491 request.chat.resolved_max_output = sizing.max_output;
2492 let max_context_tokens = request.chat.resolved_context_window.or_else(|| {
2493 crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
2494 });
2495 let before_snapshot =
2496 crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
2497
2498 let trigger = request.trigger;
2499 let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
2504 Ok(prepared) => prepared,
2505 Err(skip) => {
2506 let _ = msg_tx
2507 .send(Msg::CompactionFailed {
2508 turn,
2509 trigger,
2510 message: skip.to_string(),
2511 kind: crate::domain::StatusKind::Info,
2512 })
2513 .await;
2514 return;
2515 },
2516 };
2517 match run_compaction(
2518 provider,
2519 turn,
2520 request,
2521 prepared,
2522 before_snapshot,
2523 max_context_tokens,
2524 token,
2525 )
2526 .await
2527 {
2528 Ok(result) => {
2529 let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2530 },
2531 Err(err) => {
2532 let _ = msg_tx
2533 .send(Msg::CompactionFailed {
2534 turn,
2535 trigger,
2536 message: err.to_string(),
2537 kind: crate::domain::StatusKind::Error,
2538 })
2539 .await;
2540 },
2541 }
2542}
2543
2544async fn run_compaction(
2545 provider: Arc<dyn ModelProvider>,
2546 turn: TurnId,
2547 request: CompactionRequest,
2548 prepared: crate::domain::PreparedCompaction,
2549 before_snapshot: crate::domain::ContextUsageSnapshot,
2550 max_context_tokens: Option<usize>,
2551 token: tokio_util::sync::CancellationToken,
2552) -> Result<CompactionResult, ModelError> {
2553 let started = Instant::now();
2554
2555 let summary_request = crate::domain::build_summary_request(
2556 &request.chat,
2557 &prepared,
2558 request.instructions.as_deref(),
2559 request.policy,
2560 );
2561 ensure_compaction_request_fits(&summary_request, max_context_tokens)?;
2562 let (draft, draft_usage) =
2563 collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
2564 .await?;
2565 let draft_summary = crate::domain::normalize_summary(&draft);
2566 let draft_validation = crate::domain::validate_summary_structure(&draft_summary);
2567
2568 let verify_request = crate::domain::build_verification_request(
2569 &request.chat,
2570 &prepared,
2571 &draft_summary,
2572 request.instructions.as_deref(),
2573 request.policy,
2574 );
2575 let review_fits = compaction_request_fits(&verify_request, max_context_tokens);
2576 let (final_summary, verify_usage, review_status, review_error) = if review_fits {
2577 match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
2578 Ok((verified_text, verify_usage)) => {
2579 let verified_summary = crate::domain::normalize_summary(&verified_text);
2580 match crate::domain::validate_summary_structure(&verified_summary) {
2581 Ok(()) => (
2582 verified_summary,
2583 verify_usage,
2584 crate::domain::CompactionReviewStatus::Reviewed,
2585 None,
2586 ),
2587 Err(error) => match draft_validation {
2588 Ok(()) => (
2589 draft_summary,
2590 verify_usage,
2591 crate::domain::CompactionReviewStatus::DraftValidated,
2592 Some(format!("review returned an invalid checkpoint: {error}")),
2593 ),
2594 Err(draft_error) => {
2595 return Err(ModelError::InvalidRequest(format!(
2596 "compaction produced no structurally valid checkpoint (draft: {draft_error}; review: {error})"
2597 )));
2598 },
2599 },
2600 }
2601 },
2602 Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
2603 Err(err) => match draft_validation {
2604 Ok(()) => (
2605 draft_summary,
2606 None,
2607 crate::domain::CompactionReviewStatus::DraftValidated,
2608 Some(format!("review failed: {err}")),
2609 ),
2610 Err(draft_error) => {
2611 return Err(ModelError::InvalidRequest(format!(
2612 "compaction draft was invalid and review failed (draft: {draft_error}; review: {err})"
2613 )));
2614 },
2615 },
2616 }
2617 } else {
2618 match draft_validation {
2619 Ok(()) => (
2620 draft_summary,
2621 None,
2622 crate::domain::CompactionReviewStatus::DraftValidated,
2623 Some(
2624 "review skipped because the complete request would exceed the context window"
2625 .to_string(),
2626 ),
2627 ),
2628 Err(error) => {
2629 return Err(ModelError::InvalidRequest(format!(
2630 "compaction draft was invalid and the review request did not fit: {error}"
2631 )));
2632 },
2633 }
2634 };
2635
2636 let id = format!(
2637 "compact_{}",
2638 chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
2639 );
2640 let mut record = crate::domain::CompactionRecord {
2641 id,
2642 trigger: request.trigger,
2643 created_at: chrono::Local::now(),
2644 before_tokens: before_snapshot.used_tokens,
2645 after_tokens: 0,
2646 archived_message_count: prepared.archived_messages.len(),
2647 preserved_message_count: prepared.preserved_messages.len(),
2648 preserved_turn_count: prepared
2649 .preserved_messages
2650 .iter()
2651 .filter(|message| message.role == crate::models::MessageRole::User)
2652 .count(),
2653 summary_tokens: final_summary.len().div_ceil(4),
2654 duration_secs: started.elapsed().as_secs_f64(),
2655 review_status,
2656 review_error,
2657 focus: request.instructions.clone(),
2658 archive_path: None,
2659 };
2660
2661 let mut replacement =
2662 crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2663 let mut compacted_request = request.chat.clone();
2664 compacted_request.messages = replacement.clone();
2665 let mut after_snapshot =
2666 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2667 record.after_tokens = after_snapshot.used_tokens;
2668 record.duration_secs = started.elapsed().as_secs_f64();
2669 replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2670 compacted_request.messages = replacement.clone();
2671 after_snapshot =
2672 crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2673 record.after_tokens = after_snapshot.used_tokens;
2674
2675 if after_snapshot.used_tokens >= before_snapshot.used_tokens {
2676 return Err(ModelError::InvalidRequest(format!(
2677 "compaction did not reduce context ({} -> {} tokens)",
2678 before_snapshot.used_tokens, after_snapshot.used_tokens
2679 )));
2680 }
2681
2682 if crate::domain::context_exceeds_hard_limit(
2683 &after_snapshot,
2684 &compacted_request,
2685 request.policy,
2686 ) {
2687 return Err(ModelError::InvalidRequest(format!(
2688 "compacted context still exceeds response reserve ({} tokens used)",
2689 after_snapshot.used_tokens
2690 )));
2691 }
2692
2693 Ok(CompactionResult {
2694 record,
2695 replacement_messages: replacement,
2696 archived_messages: prepared.archived_messages,
2697 before_snapshot,
2698 after_snapshot,
2699 usage: crate::domain::combine_usage(draft_usage, verify_usage),
2700 source_boundaries: request
2701 .chat
2702 .messages
2703 .iter()
2704 .map(crate::domain::CompactionBoundary::from_message)
2705 .collect(),
2706 })
2707}
2708
2709fn compaction_request_fits(
2710 request: &crate::domain::ChatRequest,
2711 max_context_tokens: Option<usize>,
2712) -> bool {
2713 let Some(max_tokens) = max_context_tokens else {
2714 return true;
2715 };
2716 let used = crate::domain::estimate_context_usage_for_request(request, Some(max_tokens));
2717 used.used_tokens.saturating_add(request.max_tokens) <= max_tokens
2718}
2719
2720fn ensure_compaction_request_fits(
2721 request: &crate::domain::ChatRequest,
2722 max_context_tokens: Option<usize>,
2723) -> Result<(), ModelError> {
2724 if compaction_request_fits(request, max_context_tokens) {
2725 Ok(())
2726 } else {
2727 Err(ModelError::InvalidRequest(
2728 "complete compaction request exceeds the model context window".to_string(),
2729 ))
2730 }
2731}
2732
2733async fn collect_compaction_text(
2734 provider: Arc<dyn ModelProvider>,
2735 turn: TurnId,
2736 request: crate::domain::ChatRequest,
2737 token: tokio_util::sync::CancellationToken,
2738) -> Result<(String, Option<TokenUsage>), ModelError> {
2739 crate::providers::model::collect_text(provider, turn, request, token).await
2742}
2743
2744fn record_provider_capabilities(
2745 model_id: &str,
2746 caps: &crate::providers::capabilities::Capabilities,
2747) {
2748 let (provider, model) = split_model_id(model_id);
2749 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2750 for (key, value) in [
2751 ("tools_support", caps.supports_tools.to_string()),
2752 ("vision_support", caps.supports_vision.to_string()),
2753 (
2754 "context_limit",
2755 caps.max_context_tokens
2756 .map(|v| v.to_string())
2757 .unwrap_or_else(|| "unknown".to_string()),
2758 ),
2759 (
2760 "reasoning_parameter_shape",
2761 format!("{:?}", caps.supports_reasoning),
2762 ),
2763 (
2764 "streaming_usage_available",
2765 "provider_dependent".to_string(),
2766 ),
2767 ("token_usage_field_shape", "normalized".to_string()),
2768 ] {
2769 let _ = store
2770 .provider_probes()
2771 .upsert(crate::runtime::NewProviderProbe {
2772 provider: provider.clone(),
2773 model_id: model.clone(),
2774 capability_key: key.to_string(),
2775 capability_value: value,
2776 confidence: "verified".to_string(),
2777 error: None,
2778 });
2779 }
2780 }
2781}
2782
2783fn split_model_id(model_id: &str) -> (String, String) {
2784 match model_id.split_once('/') {
2785 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2786 (provider.to_ascii_lowercase(), model.to_string())
2787 },
2788 _ => ("ollama".to_string(), model_id.to_string()),
2789 }
2790}
2791
2792const MAX_PROJECT_FILES: usize = 20_000;
2795
2796fn walk_project_files(root: &std::path::Path) -> Vec<String> {
2803 let mut files = Vec::new();
2804 for entry in ignore::WalkBuilder::new(root)
2805 .hidden(true)
2806 .follow_links(false)
2807 .build()
2808 .flatten()
2809 {
2810 if files.len() >= MAX_PROJECT_FILES {
2811 break;
2812 }
2813 let path = entry.path();
2814 if path == root {
2815 continue;
2816 }
2817 let Ok(rel) = path.strip_prefix(root) else {
2818 continue;
2819 };
2820 let Some(mut rel) = rel.to_str().map(str::to_string) else {
2821 continue;
2822 };
2823 if std::path::MAIN_SEPARATOR != '/' {
2825 rel = rel.replace(std::path::MAIN_SEPARATOR, "/");
2826 }
2827 if entry.file_type().is_some_and(|t| t.is_dir()) {
2828 rel.push('/');
2829 }
2830 files.push(rel);
2831 }
2832 files.sort();
2833 files
2834}
2835
2836fn is_context_limit_error(error: &ModelError) -> bool {
2837 let text = error.to_string().to_lowercase();
2838 text.contains("context")
2839 && (text.contains("too large")
2840 || text.contains("exceed")
2841 || text.contains("maximum")
2842 || text.contains("token"))
2843}
2844
2845#[allow(clippy::too_many_arguments)]
2847async fn dispatch_execute_tool(
2848 msg_tx: MsgSender,
2849 tools: Option<Arc<ToolRegistry>>,
2850 workdir: PathBuf,
2851 turn: TurnId,
2852 call_id: crate::domain::ToolCallId,
2853 source: crate::models::tool_call::ToolCall,
2854 token: tokio_util::sync::CancellationToken,
2855 background: tokio_util::sync::CancellationToken,
2856 config: Arc<crate::app::Config>,
2857 model_id: String,
2858 task_id: Option<String>,
2859 session_id: String,
2860 message_index: usize,
2861 scratchpad: Option<PathBuf>,
2862 safety_mode: crate::runtime::SafetyMode,
2863 plan_file: Option<PathBuf>,
2864 plan_permissions: crate::app::PlanPermissions,
2865 context_percent: Option<u8>,
2866 intent: Option<String>,
2867 classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2868 approval: Option<crate::providers::ApprovalBroker>,
2869 questions: Option<crate::providers::QuestionBroker>,
2870 tasks: crate::providers::TaskBroker,
2871) {
2872 let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2873
2874 let Some(registry) = tools else {
2875 let _ = msg_tx
2876 .send(Msg::ToolFinished {
2877 turn,
2878 call_id,
2879 outcome: crate::domain::ToolOutcome::error(
2880 "EffectRunner has no ToolRegistry bound",
2881 0.0,
2882 ),
2883 })
2884 .await;
2885 return;
2886 };
2887
2888 let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2892 let rest = &source.function.name[5..];
2893 if let Some((server, tool)) = rest.split_once("__") {
2894 (
2895 "mcp_proxy",
2896 serde_json::json!({
2897 "server_name": server,
2898 "tool_name": tool,
2899 "arguments": source.function.arguments.clone(),
2900 }),
2901 )
2902 } else {
2903 let _ = msg_tx
2904 .send(Msg::ToolFinished {
2905 turn,
2906 call_id,
2907 outcome: crate::domain::ToolOutcome::error(
2908 format!("invalid MCP tool name: {}", source.function.name),
2909 0.0,
2910 ),
2911 })
2912 .await;
2913 return;
2914 }
2915 } else {
2916 (
2917 source.function.name.as_str(),
2918 source.function.arguments.clone(),
2919 )
2920 };
2921 let tool_run_id =
2922 start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2923
2924 let Some(tool) = registry.get(tool_key) else {
2925 let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2926 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2927 let _ = msg_tx
2928 .send(Msg::ToolFinished {
2929 turn,
2930 call_id,
2931 outcome,
2932 })
2933 .await;
2934 return;
2935 };
2936
2937 let (progress_tx, mut progress_rx) = mpsc::channel(16);
2943 let relay_tx = msg_tx.clone();
2944 let relay_token = token.clone();
2945 let progress_relay = spawn_guarded(async move {
2946 loop {
2947 let event = tokio::select! {
2948 biased;
2949 _ = relay_token.cancelled() => break,
2950 ev = progress_rx.recv() => match ev {
2951 Some(ev) => ev,
2952 None => break,
2953 },
2954 };
2955 if relay_tx
2956 .send(Msg::ToolProgress {
2957 turn,
2958 call_id,
2959 event,
2960 })
2961 .await
2962 .is_err()
2963 {
2964 break;
2965 }
2966 }
2967 });
2968
2969 let mut ctx = ExecContext::new(
2970 token,
2971 progress_tx,
2972 call_id,
2973 turn,
2974 workdir,
2975 config,
2976 model_id,
2977 task_id,
2978 Some(session_id),
2979 Some(message_index as i64),
2980 safety_mode,
2981 intent,
2982 classifier,
2983 approval,
2984 questions,
2985 Some(tasks.clone()),
2986 );
2987 ctx.background = background;
2988 ctx.plan_file = plan_file;
2989 ctx.plan_permissions = plan_permissions;
2990 ctx.context_percent = context_percent;
2991 ctx.notify = Some(msg_tx.clone());
2994 ctx.scratchpad = scratchpad;
2996 let before_payload = serde_json::json!({
3000 "turn_id": turn.0,
3001 "call_id": call_id.0,
3002 "tool": tool_key,
3003 "arguments": args,
3004 });
3005 let gate = run_plugin_hooks_gated("before_tool_use", before_payload).await;
3006 if !gate.context.is_empty() {
3007 let texts = gate
3010 .context
3011 .iter()
3012 .map(|t| crate::utils::redact_secrets(t))
3013 .collect();
3014 let _ = msg_tx.send(Msg::HookContext { turn, texts }).await;
3015 }
3016 if let Some((plugin, reason)) = gate.deny {
3017 drop(ctx);
3021 let reason = crate::utils::redact_secrets(&reason);
3022 let outcome = crate::domain::ToolOutcome::error(
3023 format!("Denied by plugin hook ({plugin}): {reason}"),
3024 0.0,
3025 );
3026 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
3027 join_logged(progress_relay.take(), "tool_progress_relay").await;
3028 let _ = msg_tx
3029 .send(Msg::ToolFinished {
3030 turn,
3031 call_id,
3032 outcome,
3033 })
3034 .await;
3035 return;
3036 }
3037 let args = gate.updated_input.unwrap_or(args);
3042 let outcome = tool.execute(args, ctx).await;
3043 if !source.function.name.starts_with("task_") {
3048 let (action, target) = crate::domain::display_info_for(&crate::domain::PendingToolCall {
3049 call_id,
3050 source: source.clone(),
3051 });
3052 tasks
3053 .record_evidence(crate::domain::EvidenceEntry {
3054 tool: action,
3055 target,
3056 status: tool_status_label(outcome.status).to_string(),
3057 })
3058 .await;
3059 }
3060 let after_payload = serde_json::json!({
3061 "turn_id": turn.0,
3062 "call_id": call_id.0,
3063 "tool": tool_key,
3064 "status": tool_status_label(outcome.status),
3065 "summary": &outcome.summary,
3066 });
3067 fire_plugin_hooks("after_tool_use", after_payload).await;
3068 finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
3069 join_logged(progress_relay.take(), "tool_progress_relay").await;
3070 let _ = msg_tx
3071 .send(Msg::ToolFinished {
3072 turn,
3073 call_id,
3074 outcome,
3075 })
3076 .await;
3077}
3078
3079async fn start_runtime_tool_run(
3080 task_id: Option<&str>,
3081 turn: TurnId,
3082 call_id: crate::domain::ToolCallId,
3083 tool_name: &str,
3084 args: &serde_json::Value,
3085) -> Option<String> {
3086 let task_id = task_id.map(str::to_string);
3090 let tool_name = tool_name.to_string();
3091 let args_json = serde_json::to_string(args).ok();
3092 tokio::task::spawn_blocking(move || {
3093 crate::runtime::RuntimeStore::open_default()
3094 .and_then(|store| {
3095 store.tool_runs().start(crate::runtime::NewToolRun {
3096 id: None,
3097 task_id,
3098 turn_id: Some(turn.0.to_string()),
3099 call_id: Some(call_id.0.to_string()),
3100 tool_name,
3101 args_json,
3102 })
3103 })
3104 .map(|record| record.id)
3105 .ok()
3106 })
3107 .await
3108 .ok()
3109 .flatten()
3110}
3111
3112fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
3113 let Some(tool_run_id) = tool_run_id else {
3114 return;
3115 };
3116 let tool_run_id = tool_run_id.to_string();
3117 let status = tool_status_label(outcome.status).to_string();
3118 let output_json = serde_json::to_string(&serde_json::json!({
3119 "status": tool_status_label(outcome.status),
3120 "summary": &outcome.summary,
3121 "model_content": &outcome.model_content,
3122 "error": &outcome.error,
3123 "metadata": &outcome.metadata,
3124 "artifacts": &outcome.artifacts,
3125 "duration_secs": outcome.duration_secs,
3126 }))
3127 .ok();
3128 tokio::task::spawn_blocking(move || {
3131 if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
3132 let _ = store
3133 .tool_runs()
3134 .finish(&tool_run_id, &status, output_json.as_deref());
3135 }
3136 });
3137}
3138
3139fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
3140 match status {
3141 crate::domain::ToolStatus::Success => "success",
3142 crate::domain::ToolStatus::Error => "error",
3143 crate::domain::ToolStatus::Cancelled => "cancelled",
3144 }
3145}
3146
3147fn runtime_model_info_text(model: &str) -> String {
3148 let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
3149 let mut lines = vec![
3150 format!("Model info: {}", model),
3151 format!("- provider: {}", snapshot.provider),
3152 format!("- model: {}", snapshot.model),
3153 format!("- supports tools: {}", snapshot.supports_tools),
3154 format!("- supports vision: {}", snapshot.supports_vision),
3155 format!("- reasoning: {}", snapshot.reasoning),
3156 format!(
3157 "- context limit: {}",
3158 snapshot
3159 .max_context_tokens
3160 .map(|value: usize| value.to_string())
3161 .unwrap_or_else(|| "unknown".to_string())
3162 ),
3163 ];
3164 if let Ok(store) = crate::runtime::RuntimeStore::open_default()
3165 && let Ok(probes) = store
3166 .provider_probes()
3167 .list(Some(&snapshot.provider), Some(&snapshot.model))
3168 && !probes.is_empty()
3169 {
3170 lines.push(String::new());
3171 lines.push("Cached provider reality records:".to_string());
3172 for probe in probes {
3173 lines.push(format!(
3174 "- {} = {} ({})",
3175 probe.capability_key, probe.capability_value, probe.confidence
3176 ));
3177 }
3178 }
3179 lines.join("\n")
3180}
3181
3182async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
3187 use tokio::io::{AsyncBufReadExt, BufReader};
3188 use tokio::process::Command;
3189
3190 let mut cmd = Command::new("ollama");
3191 cmd.arg("pull")
3192 .arg(&model)
3193 .stdin(std::process::Stdio::null())
3194 .stdout(std::process::Stdio::piped())
3195 .stderr(std::process::Stdio::piped())
3196 .kill_on_drop(true);
3197
3198 let mut child = match cmd.spawn() {
3199 Ok(c) => c,
3200 Err(e) => {
3201 let _ = tx
3202 .send(Msg::ModelPullProgress(format!(
3203 "ollama pull failed to start: {}",
3204 e
3205 )))
3206 .await;
3207 return;
3208 },
3209 };
3210
3211 let reader_handle = child.stdout.take().map(|stdout| {
3215 let tx_inner = tx.clone();
3216 tokio::spawn(async move {
3217 let mut reader = BufReader::new(stdout).lines();
3218 while let Ok(Some(line)) = reader.next_line().await {
3219 let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
3220 }
3221 })
3222 });
3223
3224 match child.wait().await {
3225 Ok(status) if status.success() => {
3226 let _ = tx.send(Msg::ModelPullFinished { model }).await;
3227 },
3228 Ok(status) => {
3229 let _ = tx
3230 .send(Msg::ModelPullProgress(format!(
3231 "ollama pull exited with status {}",
3232 status.code().unwrap_or(-1)
3233 )))
3234 .await;
3235 },
3236 Err(e) => {
3237 let _ = tx
3238 .send(Msg::ModelPullProgress(format!(
3239 "ollama pull wait error: {}",
3240 e
3241 )))
3242 .await;
3243 },
3244 }
3245
3246 if let Some(handle) = reader_handle {
3249 join_logged(handle, "ollama_pull_reader").await;
3250 }
3251}
3252
3253async fn dispatch_init_mcp_servers(
3262 configs: std::collections::HashMap<String, crate::app::McpServerConfig>,
3263 tx: tokio::sync::mpsc::Sender<Msg>,
3264) {
3265 if configs.is_empty() {
3266 return;
3267 }
3268 crate::mcp::manager_ref::mark_init_started();
3269 let manager = std::sync::Arc::new(crate::mcp::McpServerManager::new(&configs));
3270 crate::mcp::manager_ref::set_manager(manager.clone());
3271 let mut join = tokio::task::JoinSet::new();
3272 for (name, config) in configs {
3273 let manager = manager.clone();
3274 let tx = tx.clone();
3275 join.spawn(async move {
3276 let msg = match manager.start_server(&name, &config).await {
3277 Ok(tools) => Msg::McpServerReady { name, tools },
3278 Err(e) => Msg::McpServerErrored {
3279 name,
3280 reason: e.to_string(),
3281 },
3282 };
3283 let _ = tx.send(msg).await;
3284 });
3285 }
3286 while join.join_next().await.is_some() {}
3287 crate::mcp::manager_ref::mark_init_complete();
3288}
3289
3290async fn dispatch_read_clipboard(tx: MsgSender) {
3303 use crate::domain::ClipboardRead;
3304
3305 enum Outcome {
3306 Image { bytes: Vec<u8>, format: String },
3307 Text(String),
3308 Empty,
3309 Error(String),
3310 }
3311
3312 let outcome = tokio::task::spawn_blocking(|| {
3313 if crate::clipboard::has_image() {
3314 match crate::clipboard::read_image_bytes() {
3315 Ok((bytes, format)) => Outcome::Image { bytes, format },
3316 Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
3317 }
3318 } else {
3319 match crate::clipboard::read_text() {
3320 Ok(t) if !t.is_empty() => Outcome::Text(t),
3321 Ok(_) => Outcome::Empty,
3322 Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
3323 }
3324 }
3325 })
3326 .await
3327 .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
3328
3329 let msg = match outcome {
3334 Outcome::Image { bytes, format } => {
3335 Msg::ClipboardRead(ClipboardRead::Image { bytes, format })
3336 },
3337 Outcome::Text(text) => Msg::ClipboardRead(ClipboardRead::Text(text)),
3338 Outcome::Empty => Msg::ClipboardRead(ClipboardRead::Empty),
3339 Outcome::Error(text) => Msg::ClipboardRead(ClipboardRead::Error(text)),
3340 };
3341 let _ = tx.send(msg).await;
3342}
3343
3344async fn dispatch_probe_vision(
3350 model_id: String,
3351 warn: bool,
3352 providers: Option<Arc<ProviderFactory>>,
3353 tx: MsgSender,
3354) {
3355 let supports_vision = match providers {
3356 Some(factory) => match factory.resolve(&model_id).await {
3357 Ok(provider) => provider.supports_vision().await,
3358 Err(_) => None,
3359 },
3360 None => None,
3361 };
3362 let _ = tx
3363 .send(Msg::ProviderVisionResolved {
3364 model_id,
3365 supports_vision,
3366 warn,
3367 })
3368 .await;
3369}
3370
3371async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
3374 let char_count = text.chars().count();
3375 let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
3376 .await
3377 .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
3378
3379 let msg = match result {
3380 Ok(()) => Msg::TransientStatus {
3381 text: format!("Copied {char_count} chars to clipboard"),
3382 },
3383 Err(e) => Msg::TransientStatus {
3384 text: format!("Copy failed: {e}"),
3385 },
3386 };
3387 let _ = tx.send(msg).await;
3388}
3389
3390fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
3391 use crate::models::{ErrorCategory, ModelError, UserFacingError};
3392 match e {
3393 ModelError::Backend(b) => UserFacingError {
3394 summary: "Backend error".to_string(),
3395 message: b.to_string(),
3396 suggestion: "Check the provider endpoint / API key.".to_string(),
3397 category: ErrorCategory::Connection,
3398 recoverable: true,
3399 },
3400 ModelError::Authentication(msg) => UserFacingError {
3401 summary: "Auth error".to_string(),
3402 message: msg.clone(),
3403 suggestion: "Set the env var the provider expects.".to_string(),
3404 category: ErrorCategory::Auth,
3405 recoverable: false,
3406 },
3407 ModelError::RateLimit {
3408 retry_after,
3409 message,
3410 } => UserFacingError {
3411 summary: "Rate limited".to_string(),
3412 message: message.clone().unwrap_or_else(|| {
3415 "The provider rejected the request with 429 (too many requests).".to_string()
3416 }),
3417 suggestion: match retry_after {
3418 Some(secs) => format!("The provider asked to retry after {secs}s."),
3419 None => "Retry shortly; if it persists, check your plan's quota.".to_string(),
3420 },
3421 category: ErrorCategory::Temporary,
3422 recoverable: true,
3423 },
3424 ModelError::StreamError(msg) => UserFacingError {
3425 summary: "Stream error".to_string(),
3426 message: msg.clone(),
3427 suggestion: "Retry the request.".to_string(),
3428 category: ErrorCategory::Connection,
3429 recoverable: true,
3430 },
3431 other => UserFacingError {
3432 summary: "Model error".to_string(),
3433 message: other.to_string(),
3434 suggestion: String::new(),
3435 category: ErrorCategory::Internal,
3436 recoverable: false,
3437 },
3438 }
3439}
3440
3441#[cfg(test)]
3442mod tests {
3443 use super::*;
3444 use crate::domain::ToolCallId;
3445 use std::time::Duration;
3446
3447 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
3448 EffectRunner::pair(PathBuf::from("/tmp"))
3449 }
3450
3451 #[test]
3452 fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
3453 let root = std::env::temp_dir().join(format!(
3454 "mermaid-walk-{}-{:?}",
3455 std::process::id(),
3456 std::thread::current().id()
3457 ));
3458 let _ = std::fs::remove_dir_all(&root);
3459 std::fs::create_dir_all(root.join("src")).unwrap();
3460 std::fs::create_dir_all(root.join("target")).unwrap();
3461 std::fs::create_dir_all(root.join(".git")).unwrap();
3462 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
3463 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
3464 std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
3465 std::fs::write(root.join("README.md"), "readme").unwrap();
3466 std::fs::write(root.join(".hidden"), "hidden").unwrap();
3467
3468 let files = walk_project_files(&root);
3469 assert_eq!(
3470 files,
3471 vec![
3472 "README.md".to_string(),
3473 "src/".to_string(),
3474 "src/main.rs".to_string(),
3475 ],
3476 "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
3477 );
3478 let _ = std::fs::remove_dir_all(&root);
3479 }
3480
3481 #[test]
3482 fn new_child_suppresses_terminal_title() {
3483 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
3487 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
3488 let tools = Arc::new(ToolRegistry::new());
3489 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
3490 assert!(
3491 !child.terminal_title_enabled,
3492 "subagent child runner must suppress terminal-title escapes"
3493 );
3494 }
3495
3496 #[test]
3497 fn new_child_does_not_own_global_mcp_shutdown() {
3498 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
3503 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
3504 let tools = Arc::new(ToolRegistry::new());
3505 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
3506 assert!(
3507 !child.owns_global_mcp,
3508 "child runner must not reap the shared global MCP manager"
3509 );
3510 let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
3511 assert!(
3512 top.owns_global_mcp,
3513 "top-level runner still owns the global MCP reap"
3514 );
3515 }
3516
3517 #[test]
3518 fn parse_prune_plan_extracts_json_amid_prose() {
3519 let plan = parse_prune_plan(
3520 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
3521 )
3522 .expect("should parse");
3523 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
3524 assert_eq!(plan.reason, "dupes");
3525 }
3526
3527 #[test]
3528 fn parse_prune_plan_handles_empty_and_garbage() {
3529 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
3530 .expect("empty plan parses");
3531 assert!(empty.prune.is_empty());
3532 assert!(parse_prune_plan("no json here").is_none());
3533 }
3534
3535 #[test]
3536 fn memory_title_from_text_is_short_and_nonempty() {
3537 assert_eq!(
3538 memory_title_from_text("prefer ripgrep over grep"),
3539 "prefer ripgrep over grep"
3540 );
3541 assert_eq!(memory_title_from_text(" "), "memory");
3542 let long = memory_title_from_text("one two three four five six seven eight nine ten");
3543 assert!(long.split_whitespace().count() <= 8);
3544 }
3545
3546 #[tokio::test]
3547 async fn dispatch_exit_is_noop_on_runner_state() {
3548 let (mut r, _rx) = runner();
3549 r.dispatch(Cmd::Exit);
3550 assert_eq!(r.scope_count(), 0);
3551 }
3552
3553 #[tokio::test]
3554 async fn dispatch_save_emits_session_saved() {
3555 let (mut r, mut rx) = runner();
3556 r.dispatch(Cmd::SaveConversation(
3557 crate::session::ConversationHistory::new(
3558 "/p".to_string(),
3559 "m".to_string(),
3560 chrono::Local::now(),
3561 ),
3562 ));
3563 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3564 .await
3565 .expect("sender emits")
3566 .expect("channel alive");
3567 assert!(matches!(msg, Msg::SessionSaved));
3568 }
3569
3570 #[cfg(unix)]
3571 #[tokio::test]
3572 async fn init_mcp_servers_emits_incremental_errored_msgs() {
3573 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
3577 let mut configs = std::collections::HashMap::new();
3578 for name in ["one", "two"] {
3579 configs.insert(
3580 name.to_string(),
3581 crate::app::McpServerConfig {
3582 command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
3583 ..Default::default()
3584 },
3585 );
3586 }
3587 dispatch_init_mcp_servers(configs, tx).await;
3588 let mut errored = Vec::new();
3589 while let Ok(msg) = rx.try_recv() {
3590 match msg {
3591 Msg::McpServerErrored { name, .. } => errored.push(name),
3592 other => panic!("unexpected msg: {other:?}"),
3593 }
3594 }
3595 errored.sort();
3596 assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
3597 assert!(crate::mcp::manager_ref::is_ready());
3598 }
3599
3600 #[tokio::test]
3601 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
3602 let (mut r, mut rx) = runner();
3603 let turn = TurnId(77);
3604 {
3605 let scope = r.scope_mut(turn);
3606 scope.spawn(async {
3607 std::future::pending::<()>().await;
3608 });
3609 }
3610 assert_eq!(r.scope_count(), 1);
3611
3612 let start = std::time::Instant::now();
3613 r.dispatch(Cmd::CancelScope(turn));
3614 assert_eq!(r.scope_count(), 0);
3615 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
3616 .await
3617 .expect("bounded cancel should emit terminal message")
3618 .expect("channel alive");
3619 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
3620 assert!(
3621 start.elapsed() < Duration::from_millis(500),
3622 "cancel terminal message took {:?}",
3623 start.elapsed()
3624 );
3625 }
3626
3627 #[tokio::test]
3628 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
3629 let (mut r, mut rx) = runner();
3635 let turn = TurnId(88);
3636 {
3637 let scope = r.scope_mut(turn);
3638 scope.spawn(async {}); }
3640 assert_eq!(r.scope_count(), 1);
3641
3642 tokio::time::sleep(Duration::from_millis(20)).await;
3644 r.dispatch(Cmd::Exit);
3645 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
3646
3647 r.dispatch(Cmd::CancelScope(turn));
3650 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
3651 .await
3652 .expect("cancel on a reaped scope must still emit a terminal message")
3653 .expect("channel alive");
3654 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
3655 }
3656
3657 #[tokio::test]
3658 async fn dispatch_call_model_creates_scope() {
3659 let (mut r, _rx) = runner();
3660 let turn = TurnId(7);
3661 let request = crate::domain::ChatRequest {
3662 model_id: "test/m".to_string(),
3663 messages: vec![],
3664 system_prompt: String::new(),
3665 instructions: None,
3666 reasoning: crate::models::ReasoningLevel::Medium,
3667 temperature: 0.7,
3668 max_tokens: 4096,
3669 tools: vec![],
3670
3671 ollama_num_ctx: None,
3672 ollama_allow_ram_offload: None,
3673 resolved_context_window: None,
3674 resolved_max_output: None,
3675 output_schema: None,
3676 suppress_auto_compact: false,
3677 };
3678 r.dispatch(Cmd::CallModel { turn, request });
3679 assert_eq!(r.scope_count(), 1);
3680 }
3681
3682 #[tokio::test]
3686 async fn empty_scopes_are_reaped_on_next_dispatch() {
3687 let (mut r, mut rx) = runner();
3688 let turn = TurnId(42);
3689 let request = crate::domain::ChatRequest {
3690 model_id: "test/m".to_string(),
3691 messages: vec![],
3692 system_prompt: String::new(),
3693 instructions: None,
3694 reasoning: crate::models::ReasoningLevel::Medium,
3695 temperature: 0.7,
3696 max_tokens: 4096,
3697 tools: vec![],
3698
3699 ollama_num_ctx: None,
3700 ollama_allow_ram_offload: None,
3701 resolved_context_window: None,
3702 resolved_max_output: None,
3703 output_schema: None,
3704 suppress_auto_compact: false,
3705 };
3706 r.dispatch(Cmd::CallModel { turn, request });
3707 assert_eq!(r.scope_count(), 1);
3708
3709 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3714 .await
3715 .expect("upstream error arrived")
3716 .expect("channel alive");
3717 assert!(matches!(msg, Msg::UpstreamError { .. }));
3718
3719 tokio::task::yield_now().await;
3721
3722 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
3724 assert_eq!(
3725 r.scope_count(),
3726 0,
3727 "completed scope must be reaped on next dispatch"
3728 );
3729 }
3730
3731 #[tokio::test]
3732 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
3733 let (mut r, mut rx) = runner();
3734 let turn = TurnId(7);
3735 let call_id = ToolCallId(1);
3736 let source = crate::models::tool_call::ToolCall {
3737 id: Some("c1".to_string()),
3738 function: crate::models::tool_call::FunctionCall {
3739 name: "read_file".to_string(),
3740 arguments: serde_json::json!({"path": "x"}),
3741 },
3742 };
3743 r.dispatch(Cmd::ExecuteTool {
3744 turn,
3745 call_id,
3746 source,
3747 model_id: "ollama/test".to_string(),
3748 safety_mode: crate::runtime::SafetyMode::Ask,
3749 plan_file: None,
3750 plan_permissions: crate::app::PlanPermissions::default(),
3751 context_percent: None,
3752 intent: None,
3753 session_id: "sess-test".to_string(),
3754 message_index: 0,
3755 scratchpad: None,
3756 });
3757 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
3758 .await
3759 .expect("some msg")
3760 .expect("channel alive");
3761 assert!(matches!(
3762 first,
3763 Msg::ToolStarted {
3764 turn: t,
3765 call_id: c,
3766 } if t == turn && c == call_id
3767 ));
3768 }
3769
3770 #[tokio::test]
3771 async fn cancel_scope_before_execute_tool_drops_pending_work() {
3772 let (mut r, _rx) = runner();
3773 let turn = TurnId(9);
3774 r.dispatch(Cmd::CallModel {
3775 turn,
3776 request: crate::domain::ChatRequest {
3777 model_id: "m".to_string(),
3778 messages: vec![],
3779 system_prompt: String::new(),
3780 instructions: None,
3781 reasoning: crate::models::ReasoningLevel::Medium,
3782 temperature: 0.7,
3783 max_tokens: 4096,
3784 tools: vec![],
3785
3786 ollama_num_ctx: None,
3787 ollama_allow_ram_offload: None,
3788 resolved_context_window: None,
3789 resolved_max_output: None,
3790 output_schema: None,
3791 suppress_auto_compact: false,
3792 },
3793 });
3794 assert_eq!(r.scope_count(), 1);
3795
3796 r.dispatch(Cmd::CancelScope(turn));
3797 assert_eq!(r.scope_count(), 0);
3798 }
3799
3800 #[tokio::test]
3801 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
3802 let (mut r, _rx) = runner();
3808 let req = || crate::domain::ChatRequest {
3809 model_id: "test/m".to_string(),
3810 messages: vec![],
3811 system_prompt: String::new(),
3812 instructions: None,
3813 reasoning: crate::models::ReasoningLevel::Medium,
3814 temperature: 0.7,
3815 max_tokens: 4096,
3816 tools: vec![],
3817 ollama_num_ctx: None,
3818 ollama_allow_ram_offload: None,
3819 resolved_context_window: None,
3820 resolved_max_output: None,
3821 output_schema: None,
3822 suppress_auto_compact: false,
3823 };
3824 let turn = TurnId(123);
3825
3826 r.dispatch(Cmd::CallModel {
3827 turn,
3828 request: req(),
3829 });
3830 assert_eq!(r.scope_count(), 1);
3831
3832 r.dispatch(Cmd::CancelScope(turn));
3834 assert_eq!(r.scope_count(), 0);
3835
3836 r.dispatch(Cmd::CallModel {
3838 turn,
3839 request: req(),
3840 });
3841 assert_eq!(
3842 r.scope_count(),
3843 0,
3844 "a cancelled turn must not be resurrected by a late scoped Cmd"
3845 );
3846
3847 r.dispatch(Cmd::CallModel {
3849 turn: TurnId(124),
3850 request: req(),
3851 });
3852 assert_eq!(
3853 r.scope_count(),
3854 1,
3855 "a fresh turn must still create its scope normally"
3856 );
3857 }
3858
3859 #[tokio::test]
3860 async fn shutdown_drains_pending_saves() {
3861 let (mut r, _rx) = runner();
3862 for _ in 0..5 {
3863 r.dispatch(Cmd::SaveConversation(
3864 crate::session::ConversationHistory::new(
3865 "/p".to_string(),
3866 "m".to_string(),
3867 chrono::Local::now(),
3868 ),
3869 ));
3870 }
3871 let start = std::time::Instant::now();
3873 r.shutdown().await;
3874 assert!(start.elapsed() < Duration::from_secs(2));
3875 }
3876
3877 fn persistence_fixture(
3878 root: &std::path::Path,
3879 archive_id: &str,
3880 ) -> (crate::session::ConversationHistory, PendingCompactionSave) {
3881 let now = chrono::Local::now();
3882 let mut full = crate::session::ConversationHistory::new(
3883 root.display().to_string(),
3884 "test/model".to_string(),
3885 now,
3886 );
3887 full.add_messages(&[crate::models::ChatMessage::user("raw history")], now);
3888 let mut compacted = full.clone();
3889 compacted.replace_messages(
3890 vec![crate::models::ChatMessage::user("compacted checkpoint")],
3891 now,
3892 );
3893 let archive = crate::domain::CompactionArchive {
3894 id: archive_id.to_string(),
3895 conversation_id: full.id.clone(),
3896 created_at: now,
3897 messages: full.messages.clone(),
3898 };
3899 let record = crate::domain::CompactionRecord {
3900 id: archive_id.to_string(),
3901 trigger: crate::domain::CompactionTrigger::Manual,
3902 created_at: now,
3903 before_tokens: 100,
3904 after_tokens: 20,
3905 archived_message_count: 1,
3906 preserved_message_count: 1,
3907 preserved_turn_count: 1,
3908 summary_tokens: 10,
3909 duration_secs: 0.1,
3910 review_status: crate::domain::CompactionReviewStatus::Reviewed,
3911 review_error: None,
3912 focus: None,
3913 archive_path: None,
3914 };
3915 (
3916 full,
3917 PendingCompactionSave {
3918 archive,
3919 record,
3920 conversation: compacted,
3921 task_id: None,
3922 },
3923 )
3924 }
3925
3926 #[test]
3927 fn persistence_orders_compaction_before_newer_conversation_save() {
3928 let root = std::env::temp_dir().join(format!(
3929 "mermaid-persistence-order-{}-{:?}",
3930 std::process::id(),
3931 std::thread::current().id()
3932 ));
3933 let _ = std::fs::remove_dir_all(&root);
3934 let (full, compaction) = persistence_fixture(&root, "compact_ordered");
3935 let manager = crate::session::ConversationManager::new(&root).unwrap();
3936 manager.save_conversation(&full).unwrap();
3937
3938 let mut state = PersistenceState::new(root.clone());
3939 let (events, outcome) =
3940 state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
3941 outcome.unwrap();
3942 assert_eq!(events.len(), 1);
3943 let mut newer = compaction.conversation;
3944 newer.add_messages(
3945 &[crate::models::ChatMessage::assistant("new assistant reply")],
3946 chrono::Local::now(),
3947 );
3948 let (_, outcome) = state.process(PersistenceJob::Conversation(Box::new(newer)));
3949 outcome.unwrap();
3950
3951 let loaded = crate::session::ConversationManager::new(&root)
3952 .unwrap()
3953 .load_conversation(&full.id)
3954 .unwrap();
3955 assert!(
3956 loaded
3957 .messages
3958 .iter()
3959 .any(|message| message.content == "new assistant reply")
3960 );
3961 let _ = std::fs::remove_dir_all(root);
3962 }
3963
3964 #[test]
3965 fn failed_archive_blocks_later_stripped_conversation_save() {
3966 let root = std::env::temp_dir().join(format!(
3967 "mermaid-persistence-barrier-{}-{:?}",
3968 std::process::id(),
3969 std::thread::current().id()
3970 ));
3971 let _ = std::fs::remove_dir_all(&root);
3972 let (full, compaction) = persistence_fixture(&root, "../invalid");
3973 let manager = crate::session::ConversationManager::new(&root).unwrap();
3974 manager.save_conversation(&full).unwrap();
3975
3976 let mut state = PersistenceState::new(root.clone());
3977 assert!(
3978 state
3979 .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
3980 .1
3981 .is_err()
3982 );
3983 assert!(
3984 state
3985 .process(PersistenceJob::Conversation(Box::new(
3986 compaction.conversation,
3987 )))
3988 .1
3989 .is_err()
3990 );
3991 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
3992
3993 let loaded = crate::session::ConversationManager::new(&root)
3994 .unwrap()
3995 .load_conversation(&full.id)
3996 .unwrap();
3997 assert_eq!(loaded.messages[0].content, "raw history");
3998 let _ = std::fs::remove_dir_all(root);
3999 }
4000
4001 #[test]
4002 fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
4003 let root = std::env::temp_dir().join(format!(
4004 "mermaid-persistence-queue-{}-{:?}",
4005 std::process::id(),
4006 std::thread::current().id()
4007 ));
4008 let _ = std::fs::remove_dir_all(&root);
4009 let (full, first) = persistence_fixture(&root, "../invalid");
4010 let mut second = first.clone();
4011 second.archive.id = "compact_second".to_string();
4012 second.record.id = "compact_second".to_string();
4013
4014 let mut state = PersistenceState::new(root.clone());
4015 assert!(
4016 state
4017 .process(PersistenceJob::Compaction(Box::new(first)))
4018 .1
4019 .is_err()
4020 );
4021 assert!(
4024 state
4025 .process(PersistenceJob::Compaction(Box::new(second)))
4026 .1
4027 .is_err()
4028 );
4029 let queued = state.blocked.get(&full.id).expect("barrier queue");
4030 assert_eq!(queued.len(), 2);
4031 assert_eq!(queued[0].archive.id, "../invalid");
4032 assert_eq!(queued[1].archive.id, "compact_second");
4033 let _ = std::fs::remove_dir_all(root);
4034 }
4035
4036 #[test]
4037 fn retry_all_blocked_attempts_every_conversation() {
4038 let root = std::env::temp_dir().join(format!(
4039 "mermaid-persistence-drain-{}-{:?}",
4040 std::process::id(),
4041 std::thread::current().id()
4042 ));
4043 let _ = std::fs::remove_dir_all(&root);
4044 let (bad_full, bad) = persistence_fixture(&root, "../invalid");
4045 let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
4046 good_full.id = "20990101_000000_001".to_string();
4050 good.archive.conversation_id = good_full.id.clone();
4051 good.conversation.id = good_full.id.clone();
4052
4053 let mut state = PersistenceState::new(root.clone());
4054 state
4055 .blocked
4056 .entry(bad_full.id.clone())
4057 .or_default()
4058 .push_back(bad);
4059 state
4060 .blocked
4061 .entry(good_full.id.clone())
4062 .or_default()
4063 .push_back(good);
4064
4065 let (events, outcome) = state.retry_all_blocked();
4069 assert!(outcome.is_err());
4070 assert_eq!(events.len(), 1);
4071 assert_eq!(events[0].id, "compact_good");
4072 assert!(!state.blocked.contains_key(&good_full.id));
4073 assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
4074 let loaded = crate::session::ConversationManager::new(&root)
4075 .unwrap()
4076 .load_conversation(&good_full.id)
4077 .unwrap();
4078 assert_eq!(loaded.messages[0].content, "compacted checkpoint");
4079 let _ = std::fs::remove_dir_all(root);
4080 }
4081
4082 #[test]
4083 fn partially_drained_barrier_reports_its_persisted_events() {
4084 let root = std::env::temp_dir().join(format!(
4085 "mermaid-persistence-partial-{}-{:?}",
4086 std::process::id(),
4087 std::thread::current().id()
4088 ));
4089 let _ = std::fs::remove_dir_all(&root);
4090 let (full, good) = persistence_fixture(&root, "compact_good");
4091 let mut bad = good.clone();
4092 bad.archive.id = "../invalid".to_string();
4093 bad.record.id = "../invalid".to_string();
4094
4095 let mut state = PersistenceState::new(root.clone());
4096 let queue = state.blocked.entry(full.id.clone()).or_default();
4097 queue.push_back(good);
4098 queue.push_back(bad);
4099
4100 let (events, outcome) = state.retry_blocked(&full.id);
4104 assert!(outcome.is_err());
4105 assert_eq!(events.len(), 1);
4106 assert_eq!(events[0].id, "compact_good");
4107 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
4108 let _ = std::fs::remove_dir_all(root);
4109 }
4110}