1mod config_watch;
35mod turn_scope;
36
37use std::collections::HashMap;
38use std::collections::VecDeque;
39use std::path::PathBuf;
40use std::sync::Arc;
41use std::sync::Mutex;
42
43use tokio::sync::mpsc;
44
45use crate::providers::ctx::{ExecContext, StreamContext};
46use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
47use mermaid_domain::{Cmd, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId};
48use mermaid_domain::{Config, MemoryConfig};
49
50pub use turn_scope::TurnScope;
51
52#[cfg(not(test))]
53const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
54#[cfg(test)]
55const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
56
57const CANCELLED_TOMBSTONE_CAP: usize = 256;
63
64pub type MsgSender = mpsc::Sender<Msg>;
70
71pub const MSG_CHANNEL_CAPACITY: usize = 512;
77
78fn compaction_row(
90 record: &mermaid_domain::CompactionEvent,
91 archive_path: &std::path::Path,
92 task_id: Option<String>,
93 session_id: String,
94) -> mermaid_runtime::NewCompaction {
95 mermaid_runtime::NewCompaction {
96 id: Some(record.id.clone()),
97 task_id,
98 session_id: Some(session_id),
99 source_token_estimate: Some(record.before_tokens as i64),
100 summary_token_count: Some(record.summary_tokens as i64),
101 preserved_turns: Some(record.preserved_turn_count as i64),
102 archive_path: Some(archive_path.display().to_string()),
103 verification_status: Some(record.review_status.as_str().to_string()),
104 }
105}
106
107#[derive(Clone)]
108enum PersistenceJob {
109 Conversation(Box<mermaid_domain::ConversationHistory>),
110 Compaction(Box<PendingCompactionSave>),
111}
112
113#[derive(Clone)]
114struct PendingCompactionSave {
115 archive: mermaid_domain::CompactionArchive,
116 record: mermaid_domain::CompactionEvent,
117 conversation: mermaid_domain::ConversationHistory,
118 task_id: Option<String>,
119}
120
121struct PersistedCompaction {
122 id: String,
123 task_id: Option<String>,
124 session_id: String,
125 archive_path: PathBuf,
126}
127
128struct PersistenceState {
129 workdir: PathBuf,
130 manager: Option<crate::session::ConversationManager>,
131 blocked: HashMap<String, VecDeque<PendingCompactionSave>>,
132}
133
134impl PersistenceState {
135 fn new(workdir: PathBuf) -> Self {
136 Self {
137 workdir,
138 manager: None,
139 blocked: HashMap::new(),
140 }
141 }
142
143 fn manager(&mut self) -> anyhow::Result<&crate::session::ConversationManager> {
144 if self.manager.is_none() {
145 self.manager = Some(crate::session::ConversationManager::new(&self.workdir)?);
146 }
147 Ok(self.manager.as_ref().expect("manager initialized"))
148 }
149
150 fn process(&mut self, job: PersistenceJob) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
155 match job {
156 PersistenceJob::Conversation(history) => {
157 let (persisted, retried) = self.retry_blocked(&history.id);
160 if retried.is_err() {
161 return (persisted, retried);
162 }
163 let saved = self
164 .manager()
165 .and_then(|manager| manager.save_conversation(&history).map(|_| ()));
166 (persisted, saved)
167 },
168 PersistenceJob::Compaction(save) => {
169 let conversation_id = save.archive.conversation_id.clone();
174 self.blocked
175 .entry(conversation_id.clone())
176 .or_default()
177 .push_back(*save);
178 self.retry_blocked(&conversation_id)
179 },
180 }
181 }
182
183 fn retry_blocked(
184 &mut self,
185 conversation_id: &str,
186 ) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
187 let mut persisted = Vec::new();
188 if !self.blocked.contains_key(conversation_id) {
189 return (persisted, Ok(()));
190 }
191 if let Err(error) = self.manager() {
192 return (persisted, Err(error));
193 }
194 let manager = self.manager.as_ref().expect("manager initialized");
198 let queue = self
199 .blocked
200 .get_mut(conversation_id)
201 .expect("checked above");
202 while let Some(save) = queue.front() {
203 match Self::persist_compaction(manager, save) {
204 Ok(event) => {
208 persisted.push(event);
209 queue.pop_front();
210 },
211 Err(error) => return (persisted, Err(error)),
212 }
213 }
214 self.blocked.remove(conversation_id);
215 (persisted, Ok(()))
216 }
217
218 fn retry_all_blocked(&mut self) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
219 let ids: Vec<String> = self.blocked.keys().cloned().collect();
220 let mut persisted = Vec::new();
221 let mut first_error = None;
222 for id in ids {
223 let (events, result) = self.retry_blocked(&id);
226 persisted.extend(events);
227 if let Err(error) = result {
228 first_error.get_or_insert(error);
229 }
230 }
231 match first_error {
232 None => (persisted, Ok(())),
233 Some(error) => (persisted, Err(error)),
234 }
235 }
236
237 fn persist_compaction(
238 manager: &crate::session::ConversationManager,
239 save: &PendingCompactionSave,
240 ) -> anyhow::Result<PersistedCompaction> {
241 let path = manager.save_compaction_archive(&save.archive)?;
242 manager.save_conversation(&save.conversation)?;
243
244 if let Ok(store) = mermaid_runtime::RuntimeStore::open_default() {
245 let _ = store.compactions().create(compaction_row(
246 &save.record,
247 &path,
248 save.task_id.clone(),
249 save.archive.conversation_id.clone(),
250 ));
251 }
252
253 Ok(PersistedCompaction {
254 id: save.record.id.clone(),
255 task_id: save.task_id.clone(),
256 session_id: save.archive.conversation_id.clone(),
257 archive_path: path,
258 })
259 }
260}
261
262async fn fire_compaction_hook(event: &PersistedCompaction) {
264 fire_plugin_hooks(
265 "compaction",
266 serde_json::json!({
267 "id": event.id,
268 "task_id": event.task_id,
269 "session_id": event.session_id,
270 "archive_path": event.archive_path.display().to_string(),
271 }),
272 )
273 .await;
274}
275
276pub struct EffectRunner {
279 msg_tx: MsgSender,
280 scopes: HashMap<TurnId, TurnScope>,
286 cancelled_turns: VecDeque<TurnId>,
292 detached: tokio::task::JoinSet<()>,
296 persistence_state: Arc<Mutex<PersistenceState>>,
300 persistence_tail: Option<tokio::task::JoinHandle<()>>,
301 workdir: PathBuf,
305 providers: Option<Arc<ProviderFactory>>,
310 tools: Option<Arc<ToolRegistry>>,
313 task_id: Option<String>,
315 terminal_title_enabled: bool,
319 owns_global_mcp: bool,
325 approval: Option<crate::providers::ApprovalBroker>,
329 questions: Option<crate::providers::QuestionBroker>,
333 tasks: crate::providers::TaskBroker,
338 config_watch: Option<tokio::task::AbortHandle>,
342}
343
344impl EffectRunner {
345 #[must_use]
347 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
348 let persistence_state = Arc::new(Mutex::new(PersistenceState::new(workdir.clone())));
349 Self {
350 tasks: crate::providers::TaskBroker::new(msg_tx.clone()),
351 msg_tx,
352 scopes: HashMap::new(),
353 cancelled_turns: VecDeque::new(),
354 detached: tokio::task::JoinSet::new(),
355 persistence_state,
356 persistence_tail: None,
357 workdir,
358 providers: None,
359 tools: None,
360 task_id: None,
361 terminal_title_enabled: true,
362 owns_global_mcp: true,
363 approval: None,
364 questions: None,
365 config_watch: None,
366 }
367 }
368
369 #[must_use]
373 pub fn with_interactive_approvals(mut self) -> Self {
374 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
375 self
376 }
377
378 #[must_use]
382 pub fn with_interactive_questions(mut self) -> Self {
383 self.questions = Some(crate::providers::QuestionBroker::new(self.msg_tx.clone()));
384 self
385 }
386
387 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
393 let handle = self.detached.spawn(config_watch::config_watcher(
394 self.msg_tx.clone(),
395 cwd,
396 memory,
397 ));
398 self.config_watch = Some(handle);
399 }
400
401 #[must_use]
404 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
405 self.task_id = task_id;
406 self
407 }
408
409 #[must_use]
411 pub fn without_terminal_title(mut self) -> Self {
412 self.terminal_title_enabled = false;
413 self
414 }
415
416 #[must_use]
419 pub fn without_global_mcp_shutdown(mut self) -> Self {
420 self.owns_global_mcp = false;
421 self
422 }
423
424 pub fn with_bindings(
429 mut self,
430 providers: Arc<ProviderFactory>,
431 tools: Arc<ToolRegistry>,
432 ) -> Self {
433 self.providers = Some(providers);
434 self.tools = Some(tools);
435 self
436 }
437
438 #[must_use]
442 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
443 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
444 (Self::new(tx, workdir), rx)
445 }
446
447 #[must_use]
450 pub fn pair_with_bindings(
451 workdir: PathBuf,
452 config: Config,
453 tools: Arc<ToolRegistry>,
454 ) -> (Self, mpsc::Receiver<Msg>) {
455 let providers = Arc::new(ProviderFactory::new(config));
456 Self::pair_from(workdir, providers, tools)
457 }
458
459 pub fn pair_from(
464 workdir: PathBuf,
465 providers: Arc<ProviderFactory>,
466 tools: Arc<ToolRegistry>,
467 ) -> (Self, mpsc::Receiver<Msg>) {
468 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
469 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
470 }
471
472 pub fn pair_from_with_task(
473 workdir: PathBuf,
474 providers: Arc<ProviderFactory>,
475 tools: Arc<ToolRegistry>,
476 task_id: Option<String>,
477 ) -> (Self, mpsc::Receiver<Msg>) {
478 let (runner, rx) = Self::pair_from(workdir, providers, tools);
479 (runner.with_task_id(task_id), rx)
480 }
481
482 pub fn new_child(
487 msg_tx: MsgSender,
488 workdir: PathBuf,
489 providers: Arc<ProviderFactory>,
490 tools: Arc<ToolRegistry>,
491 ) -> Self {
492 Self::new(msg_tx, workdir)
500 .with_bindings(providers, tools)
501 .without_terminal_title()
502 .without_global_mcp_shutdown()
503 }
504
505 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
509 self.scopes
510 .entry(turn)
511 .or_insert_with(|| TurnScope::new(turn))
512 }
513
514 fn tombstone_turn(&mut self, turn: TurnId) {
518 if self.cancelled_turns.contains(&turn) {
519 return;
520 }
521 if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
522 self.cancelled_turns.pop_front();
523 }
524 self.cancelled_turns.push_back(turn);
525 }
526
527 fn is_tombstoned(&self, turn: TurnId) -> bool {
530 self.cancelled_turns.contains(&turn)
531 }
532
533 fn drop_scope(&mut self, turn: TurnId) {
543 self.tombstone_turn(turn);
548 if let Some(mut scope) = self.scopes.remove(&turn) {
549 scope.cancel();
550 let tx = self.msg_tx.clone();
551 self.detached.spawn(async move {
552 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
553 .await
554 .is_err()
555 {
556 tracing::warn!(
557 turn = %turn,
558 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
559 "cancel drain timed out; aborting remaining scoped tasks"
560 );
561 }
562 let _ = tx.send(Msg::TurnCancelled(turn)).await;
563 });
564 } else {
565 let tx = self.msg_tx.clone();
572 self.detached.spawn(async move {
573 let _ = tx.send(Msg::TurnCancelled(turn)).await;
574 });
575 }
576 }
577
578 #[must_use]
581 pub fn scope_count(&self) -> usize {
582 self.scopes.len()
583 }
584
585 fn reap_empty_scopes(&mut self) {
594 self.reap_detached();
595 self.scopes.retain(|_, scope| {
596 scope.drain_completed();
597 !scope.is_empty()
598 });
599 }
600
601 fn reap_detached(&mut self) {
606 while let Some(result) = self.detached.try_join_next() {
607 if let Err(e) = result
608 && !e.is_cancelled()
609 {
610 tracing::warn!(error = %e, "effect: detached task panicked");
611 }
612 }
613 }
614
615 #[expect(
619 clippy::too_many_lines,
620 reason = "predates the lint; see .github/baselines/expect_budget.txt"
621 )]
622 pub fn dispatch(&mut self, cmd: Cmd) {
623 self.reap_empty_scopes();
626 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
627
628 if let Some(turn) = cmd.scope_turn()
636 && self.is_tombstoned(turn)
637 {
638 tracing::debug!(
639 cmd = %cmd.summary(),
640 turn = %turn,
641 "effect: dropping turn-scoped cmd for an already-cancelled turn"
642 );
643 return;
644 }
645
646 match cmd {
647 Cmd::CallModel { turn, mut request } => {
648 let tx = self.msg_tx.clone();
649 let providers = self.providers.clone();
650 if let Some(tools) = &self.tools
659 && request.output_schema.is_none()
660 {
661 let mut enriched =
662 filter_suppressed(tools.describe_all(), &request.suppressed_builtin_tools);
663 let builtin_tokens = mermaid_domain::estimate_tool_schema_tokens(&enriched);
668 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
674 tracing::debug!(
675 error = %e,
676 "effect: dropped builtin tool-schema token estimate (channel full); \
677 /context preview may be briefly stale"
678 );
679 }
680 enriched.append(&mut request.tools);
681 request.tools = enriched;
682 }
683 self.detached.spawn(fire_plugin_hooks(
686 "prompt_submit",
687 serde_json::json!({
688 "turn_id": turn.0,
689 "model_id": request.model_id.clone(),
690 "message_count": request.messages.len(),
691 "tool_count": request.tools.len(),
692 }),
693 ));
694 let task_usage = self.tasks.clone();
697 let scope = self.scope_mut(turn);
698 let token = scope.token();
699 scope.spawn(async move {
700 use futures::FutureExt;
701 let fallback_tx = tx.clone();
702 if std::panic::AssertUnwindSafe(dispatch_call_model(
703 tx, providers, turn, request, token, task_usage,
704 ))
705 .catch_unwind()
706 .await
707 .is_err()
708 {
709 tracing::error!(turn = %turn, "dispatch_call_model panicked");
714 let _ = fallback_tx
715 .send(Msg::UpstreamError {
716 turn,
717 error: mermaid_model::models::UserFacingError {
718 summary: "Internal error".to_string(),
719 message: "The model dispatch task panicked unexpectedly."
720 .to_string(),
721 suggestion: "This is a bug. Please retry; if it persists, \
722 check the logs."
723 .to_string(),
724 category: mermaid_model::models::ErrorCategory::Internal,
725 recoverable: true,
726 },
727 })
728 .await;
729 }
730 });
731 },
732 Cmd::CompactConversation { turn, mut request } => {
733 let tx = self.msg_tx.clone();
734 let providers = self.providers.clone();
735 if let Some(tools) = &self.tools {
736 let mut enriched = tools.describe_all();
737 enriched.append(&mut request.chat.tools);
738 request.chat.tools = enriched;
739 }
740 let trigger = request.trigger;
743 let scope = self.scope_mut(turn);
744 let token = scope.token();
745 scope.spawn(async move {
746 use futures::FutureExt;
747 let fallback_tx = tx.clone();
748 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
749 tx, providers, turn, request, token,
750 ))
751 .catch_unwind()
752 .await
753 .is_err()
754 {
755 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
761 let _ = fallback_tx
762 .send(Msg::CompactionFailed {
763 turn,
764 trigger,
765 message: "the compaction task panicked unexpectedly".to_string(),
766 kind: mermaid_domain::StatusKind::Error,
767 })
768 .await;
769 }
770 });
771 },
772 Cmd::ExecuteTool {
773 turn,
774 call_id,
775 source,
776 model_id,
777 safety_mode,
778 plan_file,
779 plan_permissions,
780 context_percent,
781 intent,
782 session_id,
783 message_index,
784 scratchpad,
785 } => {
786 let tx = self.msg_tx.clone();
787 let tools = self.tools.clone();
788 let workdir = self.workdir.clone();
789 let config = self
794 .providers
795 .as_ref()
796 .map(|p| Arc::new(p.config().clone()))
797 .unwrap_or_else(|| Arc::new(mermaid_domain::Config::default()));
798 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
806 if safety_mode == mermaid_runtime::SafetyMode::Auto || plan_file.is_some() {
807 self.providers.as_ref().map(|p| {
808 let model = config
809 .safety
810 .auto_classifier_model
811 .clone()
812 .unwrap_or_else(|| model_id.clone());
813 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
814 as Arc<dyn crate::providers::AutoClassifier>
815 })
816 } else {
817 None
818 };
819 let task_id = self.task_id.clone();
820 let approval = self.approval.clone();
821 let questions = self.questions.clone();
822 let task_broker = self.tasks.clone();
823 let scope = self.scope_mut(turn);
824 let token = scope.token();
825 let background = scope.background_token();
826 let web_bytes = scope.web_bytes();
827 scope.spawn(async move {
828 use futures::FutureExt;
829 let fallback_tx = tx.clone();
830 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
831 tx,
832 tools,
833 workdir,
834 turn,
835 call_id,
836 source,
837 token,
838 background,
839 web_bytes,
840 config,
841 model_id,
842 task_id,
843 session_id,
844 message_index,
845 scratchpad,
846 safety_mode,
847 plan_file,
848 plan_permissions,
849 context_percent,
850 intent,
851 classifier,
852 approval,
853 questions,
854 task_broker,
855 ))
856 .catch_unwind()
857 .await
858 .is_err()
859 {
860 tracing::error!(
865 turn = %turn,
866 call_id = call_id.0,
867 "dispatch_execute_tool panicked"
868 );
869 let _ = fallback_tx
870 .send(Msg::ToolFinished {
871 turn,
872 call_id,
873 outcome: mermaid_domain::ToolOutcome::error(
874 "internal error: the tool execution task panicked".to_string(),
875 0.0,
876 ),
877 })
878 .await;
879 }
880 });
881 },
882 Cmd::ResolveApproval { call_id, decision } => {
883 if let Some(broker) = &self.approval {
886 broker.resolve(call_id, decision.into());
887 }
888 },
889 Cmd::ResolveQuestion {
890 call_id,
891 resolution,
892 } => {
893 if let Some(broker) = &self.questions {
896 broker.resolve(call_id, resolution);
897 }
898 },
899 Cmd::SyncTaskStore(store) => {
900 self.tasks.seed(store);
904 },
905 Cmd::EnsureScratchpad { session_id } => {
906 let tx = self.msg_tx.clone();
907 let workdir = self.workdir.clone();
908 self.detached.spawn(async move {
909 match crate::session::scratchpad::ensure(&workdir, &session_id) {
910 Ok(path) => {
911 let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
912 },
913 Err(err) => {
914 tracing::warn!(error = %err, "failed to create session scratchpad");
917 },
918 }
919 if let Err(err) = crate::session::scratchpad::sweep_stale(
922 crate::session::scratchpad::RETENTION_DAYS,
923 ) {
924 tracing::warn!(error = %err, "scratchpad sweep failed");
925 }
926 });
927 },
928 Cmd::ListScratchpad { path } => {
929 let tx = self.msg_tx.clone();
932 self.detached.spawn(async move {
933 let text = tokio::task::spawn_blocking(move || {
934 crate::session::scratchpad::list_text(&path)
935 })
936 .await
937 .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
938 let _ = tx.send(Msg::RuntimeText(text)).await;
939 });
940 },
941 Cmd::UserTaskEdit(edit) => {
942 let broker = self.tasks.clone();
947 let tx = self.msg_tx.clone();
948 self.detached.spawn(async move {
949 let (line, _snapshot) = broker.user_edit(edit).await;
950 let _ = tx
955 .send(Msg::TaskNotice {
956 text: format!(
957 "The user edited the task checklist: {line}. Acknowledge and \
958 incorporate this into your plan."
959 ),
960 })
961 .await;
962 let _ = tx.send(Msg::TransientStatus { text: line }).await;
963 });
964 },
965 Cmd::NotifyTaskCompleted {
966 task,
967 completed,
968 total,
969 } => {
970 let payload = serde_json::json!({
978 "task_id": task.id,
979 "subject": task.subject,
980 "description": task.description,
981 "evidence": task.evidence,
982 "completed": completed,
983 "total": total,
984 });
985 let broker = self.tasks.clone();
986 let tx = self.msg_tx.clone();
987 self.detached.spawn(async move {
988 let gate = run_plugin_hooks_gated("task_completed", payload).await;
989 let Some((plugin, reason)) = gate.deny else {
990 return;
991 };
992 let reason = mermaid_model::utils::redact_secrets(&reason);
993 let _ = broker
994 .update(vec![mermaid_domain::ChecklistEdit {
995 id: task.id,
996 status: Some(mermaid_domain::ChecklistStatus::InProgress),
997 ..mermaid_domain::ChecklistEdit::default()
998 }])
999 .await;
1000 let _ = tx
1001 .send(Msg::TaskNotice {
1002 text: format!(
1003 "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
1004 {reason}. The task is back in_progress; address the reason \
1005 before completing it again.",
1006 task.id, task.subject
1007 ),
1008 })
1009 .await;
1010 let _ = tx
1011 .send(Msg::TransientStatus {
1012 text: format!(
1013 "task #{} completion vetoed by {plugin}: {reason}",
1014 task.id
1015 ),
1016 })
1017 .await;
1018 });
1019 },
1020 Cmd::CancelScope(turn) => {
1021 self.drop_scope(turn);
1022 },
1023 Cmd::BackgroundScope(turn) => {
1024 self.scope_mut(turn).background();
1028 },
1029 Cmd::SaveConversation(history) => {
1030 self.queue_persistence(PersistenceJob::Conversation(Box::new(history)));
1031 },
1032 Cmd::SaveCompactionArchive {
1033 archive,
1034 record,
1035 conversation,
1036 } => {
1037 self.queue_persistence(PersistenceJob::Compaction(Box::new(
1038 PendingCompactionSave {
1039 archive,
1040 record,
1041 conversation,
1042 task_id: self.task_id.clone(),
1043 },
1044 )));
1045 },
1046 Cmd::SaveProcess(process) => {
1047 let task_id = self.task_id.clone();
1048 self.detached.spawn(async move {
1049 let status = process.status;
1050 if let Ok(store) = mermaid_runtime::RuntimeStore::open_default() {
1051 let _ = store.processes().upsert(mermaid_runtime::NewProcess {
1052 id: Some(process.id),
1053 task_id,
1054 pid: process.pid,
1055 command: process.command,
1056 cwd: process.cwd,
1057 log_path: Some(process.log_path),
1058 detected_url: process.detected_url,
1059 status,
1060 health: None,
1061 });
1062 }
1063 });
1064 },
1065 Cmd::PersistPlanConfig(plan) => {
1066 self.detached.spawn(async move {
1067 if let Err(err) = crate::app::persist_plan_config(&plan) {
1068 tracing::warn!(error = %err, "failed to persist [plan] config");
1069 }
1070 });
1071 },
1072 Cmd::PersistLastModel(model) => {
1073 self.detached.spawn(async move {
1074 if let Err(err) = crate::app::persist_last_model(&model) {
1075 tracing::warn!(error = %err, "failed to persist last-used model");
1076 }
1077 });
1078 },
1079 Cmd::PersistReasoningFor { model_id, level } => {
1080 self.detached.spawn(async move {
1081 if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1082 tracing::warn!(error = %err, "failed to persist reasoning level for model");
1083 }
1084 });
1085 },
1086 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1087 self.detached.spawn(async move {
1088 if let Err(err) =
1089 crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1090 {
1091 tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1092 }
1093 });
1094 },
1095 Cmd::PersistOllamaOffload(enabled) => {
1096 self.detached.spawn(async move {
1097 if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1098 tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1099 }
1100 });
1101 },
1102 Cmd::PersistUiTheme(theme) => {
1103 self.detached.spawn(async move {
1104 if let Err(err) = crate::app::persist_ui_theme(theme) {
1105 tracing::warn!(error = %err, "failed to persist theme");
1106 }
1107 });
1108 },
1109 Cmd::ComposeInEditor { .. } => {
1110 tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1114 },
1115 Cmd::ListMemory => {
1116 let tx = self.msg_tx.clone();
1117 let workdir = self.workdir.clone();
1118 self.detached.spawn(async move {
1119 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1120 let text = match crate::app::memory::load(&workdir, &cfg) {
1121 Some(mem) => mem.index,
1122 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(),
1123 };
1124 let _ = tx.send(Msg::RuntimeText(text)).await;
1125 });
1126 },
1127 Cmd::RememberMemory { text } => {
1128 let tx = self.msg_tx.clone();
1129 let workdir = self.workdir.clone();
1130 self.detached.spawn(async move {
1131 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1132 let name = memory_title_from_text(&text);
1133 let status = match crate::app::memory::write_memory(
1134 &workdir,
1135 mermaid_domain::MemoryScope::ProjectPrivate,
1136 &name,
1137 &text,
1138 &[],
1139 &text,
1140 ) {
1141 Ok(_) => format!("Remembered: {name}"),
1142 Err(e) => format!("Couldn't save memory: {e}"),
1143 };
1144 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1145 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1146 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1147 });
1148 },
1149 Cmd::ForgetMemory { id } => {
1150 let tx = self.msg_tx.clone();
1151 let workdir = self.workdir.clone();
1152 self.detached.spawn(async move {
1153 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1154 let status = match crate::app::memory::delete_memory(&workdir, &id) {
1155 Ok(Some(_)) => format!("Forgot: {id}"),
1156 Ok(None) => format!("No memory named '{id}'"),
1157 Err(e) => format!("Couldn't forget memory: {e}"),
1158 };
1159 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1160 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1161 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1162 });
1163 },
1164 Cmd::ConsolidateMemory { model_id } => {
1165 let tx = self.msg_tx.clone();
1166 let workdir = self.workdir.clone();
1167 let providers = self.providers.clone();
1168 self.detached.spawn(async move {
1169 consolidate_memory(tx, providers, workdir, model_id).await;
1170 });
1171 },
1172 Cmd::LoadConversation(id) => {
1173 let tx = self.msg_tx.clone();
1174 let workdir = self.workdir.clone();
1175 self.detached.spawn(async move {
1176 match crate::session::ConversationManager::new(&workdir) {
1177 Ok(mgr) => match mgr.load_conversation(&id) {
1178 Ok(history) => {
1179 let _ = tx.send(Msg::ConversationLoaded(history)).await;
1180 },
1181 Err(e) => {
1182 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
1183 },
1184 },
1185 Err(e) => {
1186 tracing::warn!(error = %e, "ConversationManager init failed");
1187 },
1188 }
1189 });
1190 },
1191 Cmd::ListConversations => {
1192 let tx = self.msg_tx.clone();
1193 let workdir = self.workdir.clone();
1194 self.detached.spawn(async move {
1195 let summaries = match crate::session::ConversationManager::new(&workdir) {
1196 Ok(mgr) => mgr
1197 .list_conversation_metas()
1198 .unwrap_or_default()
1199 .into_iter()
1200 .map(|m| mermaid_domain::ConversationSummary {
1201 id: m.id,
1202 title: m.title,
1203 message_count: m.message_count,
1204 updated_at: m.updated_at.to_rfc3339(),
1205 })
1206 .collect(),
1207 Err(_) => Vec::new(),
1208 };
1209 let _ = tx.send(Msg::ConversationsListed(summaries)).await;
1210 });
1211 },
1212 Cmd::ListAvailableModels => {
1213 let tx = self.msg_tx.clone();
1214 let providers = self.providers.clone();
1215 self.detached.spawn(async move {
1216 let choices = discover_available_models(providers).await;
1217 let _ = tx.send(Msg::AvailableModelsListed(choices)).await;
1218 });
1219 },
1220 Cmd::ListProjectFiles => {
1221 let tx = self.msg_tx.clone();
1222 let workdir = self.workdir.clone();
1223 self.detached.spawn_blocking(move || {
1225 let files = walk_project_files(&workdir);
1226 let _ = tx.blocking_send(Msg::ProjectFilesListed(files));
1227 });
1228 },
1229 Cmd::ListRuntimeTasks { limit } => {
1230 let tx = self.msg_tx.clone();
1231 self.detached.spawn_blocking(move || {
1234 let tasks = crate::runtime_client::RuntimeClient::auto()
1235 .list_tasks(limit)
1236 .map(|read| read.value)
1237 .unwrap_or_default();
1238 let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
1239 });
1240 },
1241 Cmd::LoadRuntimeTask { id } => {
1242 let tx = self.msg_tx.clone();
1243 self.detached.spawn_blocking(move || {
1244 let (task, events) = crate::runtime_client::RuntimeClient::auto()
1245 .task_detail(&id)
1246 .map(|read| (Some(read.value.task), read.value.events))
1247 .unwrap_or((None, Vec::new()));
1248 let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
1249 });
1250 },
1251 Cmd::ListRuntimeProcesses { limit } => {
1252 let tx = self.msg_tx.clone();
1253 self.detached.spawn_blocking(move || {
1254 let processes = crate::runtime_client::RuntimeClient::auto()
1255 .list_processes(limit)
1256 .map(|read| read.value)
1257 .unwrap_or_default();
1258 let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
1259 });
1260 },
1261 Cmd::ShowRuntimeProcessLogs { id } => {
1262 let tx = self.msg_tx.clone();
1263 self.detached.spawn_blocking(move || {
1264 let text = crate::runtime_client::RuntimeClient::auto()
1265 .process_log(&id, None)
1266 .map(|log| format!("Process log {}\n\n{}", id, log.content))
1267 .unwrap_or_else(|err| format!("Process log error: {err}"));
1268 let _ = tx.blocking_send(Msg::RuntimeText(text));
1269 });
1270 },
1271 Cmd::StopRuntimeProcess { id } => {
1272 let tx = self.msg_tx.clone();
1273 self.detached.spawn_blocking(move || {
1274 let msg = match crate::runtime_client::RuntimeClient::auto().stop_process(&id) {
1275 Ok(response) => Msg::TransientStatus {
1276 text: format!("Stopped process {} (pid {})", id, response.item.pid),
1277 },
1278 Err(err) => Msg::TransientStatus {
1279 text: format!("Process stop failed: {err}"),
1280 },
1281 };
1282 let _ = tx.blocking_send(msg);
1283 });
1284 },
1285 Cmd::KillBackgroundAgent { agent_id } => {
1286 let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1290 if let Some(spawner) = spawner {
1291 match agent_id {
1292 Some(id) => {
1293 if let crate::providers::tool::subagent::KillResult::Evicted(
1298 workspace,
1299 ) = spawner.kill_detached(&id)
1300 {
1301 tokio::spawn(workspace.discard());
1302 }
1303 },
1304 None => {
1305 spawner.kill_all_detached();
1306 },
1307 }
1308 }
1309 },
1310 Cmd::RestartRuntimeProcess { id } => {
1311 let tx = self.msg_tx.clone();
1312 self.detached.spawn_blocking(move || {
1313 let msg = match crate::runtime_client::RuntimeClient::auto()
1314 .restart_process(&id)
1315 {
1316 Ok(response) => Msg::TransientStatus {
1317 text: format!("Restarted process {} (pid {})", id, response.item.pid),
1318 },
1319 Err(err) => Msg::TransientStatus {
1320 text: format!("Process restart failed: {err}"),
1321 },
1322 };
1323 let _ = tx.blocking_send(msg);
1324 });
1325 },
1326 Cmd::OpenRuntimeTarget { target } => {
1327 self.detached.spawn_blocking(move || {
1328 let resolved = crate::runtime_client::RuntimeService::open_default()
1329 .and_then(|service| service.resolve_open_target(&target))
1330 .unwrap_or(target);
1331 if let Err(err) = crate::runtime_client::validate_open_target(&resolved) {
1335 tracing::warn!(error = %err, "refusing to open runtime target");
1336 return;
1337 }
1338 mermaid_model::utils::open_file(resolved);
1339 });
1340 },
1341 Cmd::ShowRuntimePorts => {
1342 let tx = self.msg_tx.clone();
1343 self.detached.spawn_blocking(move || {
1344 let text = crate::runtime_client::RuntimeClient::auto()
1345 .ports()
1346 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1347 .unwrap_or_else(|err| format!("Port inspection failed: {err}"));
1348 let _ = tx.blocking_send(Msg::RuntimeText(text));
1349 });
1350 },
1351 Cmd::ListRuntimeApprovals => {
1352 let tx = self.msg_tx.clone();
1353 self.detached.spawn_blocking(move || {
1354 let approvals = crate::runtime_client::RuntimeClient::auto()
1355 .list_approvals()
1356 .map(|read| read.value)
1357 .unwrap_or_default();
1358 let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
1359 });
1360 },
1361 Cmd::DecideRuntimeApproval { id, decision } => {
1362 let tx = self.msg_tx.clone();
1363 self.detached.spawn_blocking(move || {
1364 let result = if decision == "approved" {
1365 crate::runtime_client::RuntimeClient::auto().approve(&id)
1366 } else {
1367 crate::runtime_client::RuntimeClient::auto().deny(&id)
1368 };
1369 let msg = match result {
1370 Ok(result) => Msg::TransientStatus {
1371 text: if result.replayed {
1372 format!("Approval {} {}: {}", id, decision, result.summary)
1373 } else {
1374 format!("Approval {id} {decision}")
1375 },
1376 },
1377 Err(err) => Msg::TransientStatus {
1378 text: format!("Approval update failed: {err}"),
1379 },
1380 };
1381 let _ = tx.blocking_send(msg);
1382 });
1383 },
1384 Cmd::ListRuntimeCheckpoints { limit } => {
1385 let tx = self.msg_tx.clone();
1386 self.detached.spawn_blocking(move || {
1387 let checkpoints = crate::runtime_client::RuntimeClient::auto()
1388 .list_checkpoints(limit)
1389 .map(|read| read.value)
1390 .unwrap_or_default();
1391 let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
1392 });
1393 },
1394 Cmd::ListForkCheckpoints {
1395 session_id,
1396 message_index,
1397 } => {
1398 let tx = self.msg_tx.clone();
1399 self.detached.spawn_blocking(move || {
1400 let checkpoints = mermaid_runtime::RuntimeStore::open_default()
1401 .and_then(|store| {
1402 store
1403 .checkpoints()
1404 .list_for_session(&session_id, message_index as i64)
1405 })
1406 .unwrap_or_default();
1407 let _ = tx.blocking_send(Msg::ForkCheckpointsFound(checkpoints));
1408 });
1409 },
1410 Cmd::ListRuntimePlugins => {
1411 let tx = self.msg_tx.clone();
1412 self.detached.spawn_blocking(move || {
1413 let plugins = crate::runtime_client::RuntimeClient::auto()
1414 .list_plugins()
1415 .map(|read| read.value)
1416 .unwrap_or_default();
1417 let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
1418 });
1419 },
1420 Cmd::UpdateRuntimeTaskStatus {
1421 id,
1422 status,
1423 final_report,
1424 } => {
1425 let tx = self.msg_tx.clone();
1426 self.detached.spawn_blocking(move || {
1427 let msg =
1428 match mermaid_runtime::RuntimeStore::open_default().and_then(|store| {
1429 store
1430 .tasks()
1431 .update_status(&id, status, final_report.as_deref())
1432 }) {
1433 Ok(()) => Msg::TransientStatus {
1434 text: format!("Task {id} -> {status}"),
1435 },
1436 Err(err) => Msg::TransientStatus {
1437 text: format!("Task update failed: {err}"),
1438 },
1439 };
1440 let _ = tx.blocking_send(msg);
1441 });
1442 },
1443 Cmd::CreateRuntimeCheckpoint { paths } => {
1444 let tx = self.msg_tx.clone();
1445 let workdir = self.workdir.clone();
1446 self.detached.spawn_blocking(move || {
1447 let pending_action = Some(serde_json::json!({
1448 "source": "tui",
1449 "command": "checkpoint",
1450 }));
1451 let msg = match mermaid_runtime::create_checkpoint(
1452 &workdir,
1453 &paths,
1454 pending_action,
1455 ) {
1456 Ok(manifest) => Msg::TransientStatus {
1457 text: format!(
1458 "Checkpoint {} created for {} path(s)",
1459 manifest.id,
1460 manifest.files.len()
1461 ),
1462 },
1463 Err(err) => Msg::TransientStatus {
1464 text: format!("Checkpoint failed: {err}"),
1465 },
1466 };
1467 let _ = tx.blocking_send(msg);
1468 });
1469 },
1470 Cmd::RestoreRuntimeCheckpoint { id } => {
1471 let tx = self.msg_tx.clone();
1472 self.detached.spawn_blocking(move || {
1473 let msg = match crate::runtime_client::RuntimeClient::auto()
1474 .restore_checkpoint(&id)
1475 {
1476 Ok(result) => Msg::TransientStatus {
1477 text: format!(
1478 "Restored checkpoint {} ({} file(s)){}",
1479 result.checkpoint.id,
1480 result.checkpoint.files.len(),
1481 if result.checkpoint.pending_action.is_some() {
1482 "; pending action available in checkpoint manifest"
1483 } else {
1484 ""
1485 }
1486 ),
1487 },
1488 Err(err) => Msg::TransientStatus {
1489 text: format!("Restore failed: {err}"),
1490 },
1491 };
1492 let _ = tx.blocking_send(msg);
1493 });
1494 },
1495 Cmd::ShowRuntimeModelInfo { model } => {
1496 let tx = self.msg_tx.clone();
1497 self.detached.spawn_blocking(move || {
1498 let text = runtime_model_info_text(&model);
1499 let _ = tx.blocking_send(Msg::RuntimeText(text));
1500 });
1501 },
1502 Cmd::InitMcpServers(configs) => {
1503 let tx = self.msg_tx.clone();
1504 self.detached
1505 .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1506 },
1507 Cmd::StopMcpServer { name } => {
1508 let tx = self.msg_tx.clone();
1509 self.detached.spawn(async move {
1510 if let Some(mgr) = crate::mcp::manager_ref::get() {
1513 mgr.stop_server(&name).await;
1514 }
1515 let _ = tx.send(Msg::McpServerStopped { name }).await;
1516 });
1517 },
1518 Cmd::PullOllamaModel { model } => {
1519 let tx = self.msg_tx.clone();
1520 self.detached.spawn(async move {
1521 dispatch_pull_ollama_model(tx, model).await;
1522 });
1523 },
1524 Cmd::OpenInSystem(path) => {
1525 self.detached.spawn(async move {
1526 let _ = tokio::task::spawn_blocking(move || {
1527 mermaid_model::utils::open_file(&path);
1528 })
1529 .await;
1530 });
1531 },
1532 Cmd::WriteImageToTemp {
1533 path,
1534 bytes,
1535 format: _,
1536 } => {
1537 self.detached.spawn(async move {
1538 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1539 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1540 }
1541 });
1542 },
1543 Cmd::ReadClipboard => {
1544 let tx = self.msg_tx.clone();
1545 self.detached.spawn(async move {
1546 dispatch_read_clipboard(tx).await;
1547 });
1548 },
1549 Cmd::ProbeVision { model_id, warn } => {
1550 let tx = self.msg_tx.clone();
1551 let providers = self.providers.clone();
1552 self.detached.spawn(async move {
1553 dispatch_probe_vision(model_id, warn, providers, tx).await;
1554 });
1555 },
1556 Cmd::CopyToClipboard(text) => {
1557 let tx = self.msg_tx.clone();
1558 self.detached.spawn(async move {
1559 dispatch_copy_to_clipboard(text, tx).await;
1560 });
1561 },
1562 Cmd::Exit => {
1563 },
1568 Cmd::SetTerminalTitle(title) => {
1569 if !self.terminal_title_enabled {
1570 return;
1571 }
1572 self.detached.spawn_blocking(move || {
1578 use std::io::Write;
1579 let seq = format!("\x1b]2;{title}\x07");
1580 let mut stdout = std::io::stdout();
1581 let _ = stdout.write_all(seq.as_bytes());
1582 let _ = stdout.flush();
1583 });
1584 },
1585 Cmd::AlertUser => {
1586 if !self.terminal_title_enabled {
1587 return;
1588 }
1589 self.detached.spawn_blocking(|| {
1592 use std::io::Write;
1593 let mut stdout = std::io::stdout();
1594 let _ = stdout.write_all(b"\x07");
1595 let _ = stdout.flush();
1596 });
1597 },
1598 }
1599 }
1600
1601 fn queue_persistence(&mut self, job: PersistenceJob) {
1602 let previous = self.persistence_tail.take();
1603 let state = Arc::clone(&self.persistence_state);
1604 let tx = self.msg_tx.clone();
1605 self.persistence_tail = Some(tokio::spawn(async move {
1606 if let Some(previous) = previous
1607 && let Err(error) = previous.await
1608 {
1609 tracing::warn!(error = %error, "previous persistence job panicked");
1610 }
1611
1612 let result = tokio::task::spawn_blocking(move || {
1613 state
1614 .lock()
1615 .unwrap_or_else(|error| error.into_inner())
1616 .process(job)
1617 })
1618 .await;
1619
1620 match result {
1621 Ok((events, outcome)) => {
1622 if outcome.is_ok() || !events.is_empty() {
1626 let _ = tx.send(Msg::SessionSaved).await;
1627 }
1628 for event in events {
1629 fire_compaction_hook(&event).await;
1630 }
1631 if let Err(error) = outcome {
1632 tracing::warn!(
1633 error = %error,
1634 "persistence job failed; compaction barriers remain queued"
1635 );
1636 }
1637 },
1638 Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1639 }
1640 }));
1641 }
1642
1643 pub async fn shutdown(mut self) {
1647 for (id, scope) in self.scopes.iter() {
1648 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1649 scope.cancel();
1650 }
1651
1652 if let Some(handle) = self.config_watch.take() {
1655 handle.abort();
1656 }
1657
1658 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1660
1661 let owns_global_mcp = self.owns_global_mcp;
1662 let persistence_tail = self.persistence_tail.take();
1663 let persistence_state = Arc::clone(&self.persistence_state);
1664 let drain = async {
1665 if let Some(tail) = persistence_tail
1666 && let Err(error) = tail.await
1667 {
1668 tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1669 }
1670 match tokio::task::spawn_blocking(move || {
1671 persistence_state
1672 .lock()
1673 .unwrap_or_else(|error| error.into_inner())
1674 .retry_all_blocked()
1675 })
1676 .await
1677 {
1678 Ok((events, outcome)) => {
1679 for event in events {
1682 fire_compaction_hook(&event).await;
1683 }
1684 if let Err(error) = outcome {
1685 tracing::warn!(
1686 error = %error,
1687 "shutdown: compaction persistence barrier retry failed"
1688 );
1689 }
1690 },
1691 Err(error) => tracing::warn!(
1692 error = %error,
1693 "shutdown: compaction persistence barrier panicked"
1694 ),
1695 }
1696 if owns_global_mcp {
1700 let _ = tokio::time::timeout(
1706 std::time::Duration::from_secs(2),
1707 crate::mcp::manager_ref::wait_ready(),
1708 )
1709 .await;
1710 if let Some(mgr) = crate::mcp::manager_ref::get() {
1717 mgr.shutdown().await;
1718 }
1719 crate::searxng::shutdown().await;
1723 }
1724 for (id, mut scope) in self.scopes.drain() {
1730 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1731 .await
1732 .is_err()
1733 {
1734 tracing::warn!(
1735 turn = %id,
1736 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1737 "shutdown: scope drain timed out; aborting its remaining tasks"
1738 );
1739 }
1740 }
1741 while let Some(result) = self.detached.join_next().await {
1742 if let Err(e) = result
1743 && !e.is_cancelled()
1744 {
1745 tracing::warn!(error = %e, "shutdown: detached task panic");
1746 }
1747 }
1748 };
1749
1750 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1751 }
1752}
1753
1754fn note_stream_usage(
1762 tasks: &crate::providers::TaskBroker,
1763 usage: &Option<mermaid_model::models::TokenUsage>,
1764) {
1765 if let Some(usage) = usage {
1766 tasks.add_tokens(usage.completion_tokens as u64);
1767 }
1768}
1769
1770fn filter_suppressed(
1774 tools: Vec<mermaid_domain::ToolDefinition>,
1775 suppressed: &[&'static str],
1776) -> Vec<mermaid_domain::ToolDefinition> {
1777 if suppressed.is_empty() {
1778 return tools;
1779 }
1780 tools
1781 .into_iter()
1782 .filter(|t| !suppressed.contains(&t.name.as_str()))
1783 .collect()
1784}
1785
1786mod compaction;
1787mod memory;
1788mod model_call;
1789mod tool_call;
1790
1791use compaction::*;
1792use memory::*;
1793use model_call::*;
1794use tool_call::*;
1795
1796#[cfg(test)]
1797mod tests {
1798 #[test]
1802 fn compaction_row_maps_every_field_it_claims_to() {
1803 use mermaid_domain::{CompactionEvent, CompactionReviewStatus, CompactionTrigger};
1804 let record = CompactionEvent {
1805 id: "cmp-1".to_string(),
1806 trigger: CompactionTrigger::Manual,
1807 created_at: chrono::Local::now(),
1808 before_tokens: 9_000,
1809 after_tokens: 1_200,
1810 archived_message_count: 40,
1811 preserved_message_count: 6,
1812 preserved_turn_count: 3,
1813 summary_tokens: 450,
1814 duration_secs: 1.5,
1815 review_status: CompactionReviewStatus::Reviewed,
1816 review_error: None,
1817 focus: None,
1818 archive_path: None,
1819 };
1820 let row = compaction_row(
1821 &record,
1822 std::path::Path::new("/tmp/archive.json"),
1823 Some("task-7".to_string()),
1824 "sess-3".to_string(),
1825 );
1826 assert_eq!(row.id.as_deref(), Some("cmp-1"));
1827 assert_eq!(row.task_id.as_deref(), Some("task-7"));
1828 assert_eq!(row.session_id.as_deref(), Some("sess-3"));
1829 assert_eq!(row.source_token_estimate, Some(9_000));
1830 assert_eq!(row.summary_token_count, Some(450));
1831 assert_eq!(row.preserved_turns, Some(3));
1832 assert!(row.archive_path.is_some_and(|p| p.contains("archive.json")));
1833 assert_eq!(
1834 row.verification_status.as_deref(),
1835 Some(CompactionReviewStatus::Reviewed.as_str())
1836 );
1837 }
1838
1839 use super::*;
1840 use mermaid_domain::ToolCallId;
1841 use std::time::Duration;
1842
1843 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
1844 EffectRunner::pair(PathBuf::from("/tmp"))
1845 }
1846
1847 #[test]
1850 fn filter_suppressed_drops_only_the_named_tools() {
1851 let def = |name: &str| mermaid_domain::ToolDefinition {
1852 name: name.to_string(),
1853 description: String::new(),
1854 input_schema: serde_json::json!({}),
1855 };
1856 let tools = vec![def("task_create"), def("task_list"), def("task_update")];
1857 let kept = filter_suppressed(tools.clone(), &["task_create", "task_update"]);
1858 assert_eq!(
1859 kept.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
1860 vec!["task_list"]
1861 );
1862 let kept = filter_suppressed(tools, &[]);
1863 assert_eq!(kept.len(), 3, "empty suppression list is a no-op");
1864 }
1865
1866 #[test]
1867 fn runtime_tool_payloads_are_redacted_before_serialization() {
1868 let payload = serde_json::json!({
1869 "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
1870 "authorization": "opaque-secret-value",
1871 "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
1872 });
1873 let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
1874 assert!(
1875 !serialized.contains("hunter2"),
1876 "URL password leaked: {serialized}"
1877 );
1878 assert!(
1879 !serialized.contains("opaque-signature"),
1880 "signed URL leaked: {serialized}"
1881 );
1882 assert!(
1883 !serialized.contains("private"),
1884 "URL fragment leaked: {serialized}"
1885 );
1886 assert!(
1887 !serialized.contains("opaque-secret-value"),
1888 "credential-named field leaked: {serialized}"
1889 );
1890 assert!(
1891 !serialized.contains("abcdef123456ghijkl"),
1892 "bearer token leaked: {serialized}"
1893 );
1894 assert!(
1895 !serialized.contains("sk-abcdefghijklmnop1234"),
1896 "secret-shaped fetched content leaked: {serialized}"
1897 );
1898 assert!(serialized.contains("[REDACTED]"));
1899 }
1900
1901 #[test]
1902 fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
1903 let root = std::env::temp_dir().join(format!(
1904 "mermaid-walk-{}-{:?}",
1905 std::process::id(),
1906 std::thread::current().id()
1907 ));
1908 let _ = std::fs::remove_dir_all(&root);
1909 std::fs::create_dir_all(root.join("src")).unwrap();
1910 std::fs::create_dir_all(root.join("target")).unwrap();
1911 std::fs::create_dir_all(root.join(".git")).unwrap();
1912 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
1913 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
1914 std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
1915 std::fs::write(root.join("README.md"), "readme").unwrap();
1916 std::fs::write(root.join(".hidden"), "hidden").unwrap();
1917
1918 let files = walk_project_files(&root);
1919 assert_eq!(
1920 files,
1921 vec![
1922 "README.md".to_string(),
1923 "src/".to_string(),
1924 "src/main.rs".to_string(),
1925 ],
1926 "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
1927 );
1928 let _ = std::fs::remove_dir_all(&root);
1929 }
1930
1931 #[test]
1932 fn new_child_suppresses_terminal_title() {
1933 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
1937 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
1938 let tools = Arc::new(ToolRegistry::new());
1939 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
1940 assert!(
1941 !child.terminal_title_enabled,
1942 "subagent child runner must suppress terminal-title escapes"
1943 );
1944 }
1945
1946 #[test]
1947 fn new_child_does_not_own_global_mcp_shutdown() {
1948 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
1953 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
1954 let tools = Arc::new(ToolRegistry::new());
1955 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
1956 assert!(
1957 !child.owns_global_mcp,
1958 "child runner must not reap the shared global MCP manager"
1959 );
1960 let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
1961 assert!(
1962 top.owns_global_mcp,
1963 "top-level runner still owns the global MCP reap"
1964 );
1965 }
1966
1967 #[test]
1968 fn parse_prune_plan_extracts_json_amid_prose() {
1969 let plan = parse_prune_plan(
1970 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
1971 )
1972 .expect("should parse");
1973 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
1974 assert_eq!(plan.reason, "dupes");
1975 }
1976
1977 #[test]
1978 fn parse_prune_plan_handles_empty_and_garbage() {
1979 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
1980 .expect("empty plan parses");
1981 assert!(empty.prune.is_empty());
1982 assert!(parse_prune_plan("no json here").is_none());
1983 }
1984
1985 #[test]
1986 fn memory_title_from_text_is_short_and_nonempty() {
1987 assert_eq!(
1988 memory_title_from_text("prefer ripgrep over grep"),
1989 "prefer ripgrep over grep"
1990 );
1991 assert_eq!(memory_title_from_text(" "), "memory");
1992 let long = memory_title_from_text("one two three four five six seven eight nine ten");
1993 assert!(long.split_whitespace().count() <= 8);
1994 }
1995
1996 #[tokio::test]
1997 async fn dispatch_exit_is_noop_on_runner_state() {
1998 let (mut r, _rx) = runner();
1999 r.dispatch(Cmd::Exit);
2000 assert_eq!(r.scope_count(), 0);
2001 }
2002
2003 #[tokio::test]
2004 async fn dispatch_save_emits_session_saved() {
2005 let (mut r, mut rx) = runner();
2006 r.dispatch(Cmd::SaveConversation(
2007 mermaid_domain::ConversationHistory::new(
2008 "/p".to_string(),
2009 "m".to_string(),
2010 chrono::Local::now(),
2011 ),
2012 ));
2013 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2014 .await
2015 .expect("sender emits")
2016 .expect("channel alive");
2017 assert!(matches!(msg, Msg::SessionSaved));
2018 }
2019
2020 #[cfg(unix)]
2021 #[tokio::test]
2022 async fn init_mcp_servers_emits_incremental_errored_msgs() {
2023 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
2027 let mut configs = std::collections::HashMap::new();
2028 for name in ["one", "two"] {
2029 configs.insert(
2030 name.to_string(),
2031 mermaid_domain::McpServerConfig {
2032 command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
2033 ..Default::default()
2034 },
2035 );
2036 }
2037 dispatch_init_mcp_servers(configs, tx).await;
2038 let mut errored = Vec::new();
2039 while let Ok(msg) = rx.try_recv() {
2040 match msg {
2041 Msg::McpServerErrored { name, .. } => errored.push(name),
2042 other => panic!("unexpected msg: {other:?}"),
2043 }
2044 }
2045 errored.sort();
2046 assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
2047 assert!(crate::mcp::manager_ref::is_ready());
2048 }
2049
2050 #[tokio::test]
2051 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2052 let (mut r, mut rx) = runner();
2053 let turn = TurnId(77);
2054 {
2055 let scope = r.scope_mut(turn);
2056 scope.spawn(async {
2057 std::future::pending::<()>().await;
2058 });
2059 }
2060 assert_eq!(r.scope_count(), 1);
2061
2062 let start = std::time::Instant::now();
2063 r.dispatch(Cmd::CancelScope(turn));
2064 assert_eq!(r.scope_count(), 0);
2065 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2066 .await
2067 .expect("bounded cancel should emit terminal message")
2068 .expect("channel alive");
2069 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2070 assert!(
2071 start.elapsed() < Duration::from_millis(500),
2072 "cancel terminal message took {:?}",
2073 start.elapsed()
2074 );
2075 }
2076
2077 #[tokio::test]
2078 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2079 let (mut r, mut rx) = runner();
2085 let turn = TurnId(88);
2086 {
2087 let scope = r.scope_mut(turn);
2088 scope.spawn(async {}); }
2090 assert_eq!(r.scope_count(), 1);
2091
2092 tokio::time::sleep(Duration::from_millis(20)).await;
2094 r.dispatch(Cmd::Exit);
2095 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2096
2097 r.dispatch(Cmd::CancelScope(turn));
2100 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2101 .await
2102 .expect("cancel on a reaped scope must still emit a terminal message")
2103 .expect("channel alive");
2104 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2105 }
2106
2107 #[tokio::test]
2108 async fn dispatch_call_model_creates_scope() {
2109 let (mut r, _rx) = runner();
2110 let turn = TurnId(7);
2111 let request = mermaid_domain::ChatRequest {
2112 model_id: "test/m".to_string(),
2113 messages: vec![],
2114 system_prompt: String::new(),
2115 instructions: None,
2116 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2117 temperature: 0.7,
2118 max_tokens: 4096,
2119 tools: vec![],
2120
2121 ollama_num_ctx: None,
2122 ollama_allow_ram_offload: None,
2123 resolved_context_window: None,
2124 resolved_max_output: None,
2125 output_schema: None,
2126 suppress_auto_compact: false,
2127 suppressed_builtin_tools: Vec::new(),
2128 };
2129 r.dispatch(Cmd::CallModel { turn, request });
2130 assert_eq!(r.scope_count(), 1);
2131 }
2132
2133 #[tokio::test]
2137 async fn empty_scopes_are_reaped_on_next_dispatch() {
2138 let (mut r, mut rx) = runner();
2139 let turn = TurnId(42);
2140 let request = mermaid_domain::ChatRequest {
2141 model_id: "test/m".to_string(),
2142 messages: vec![],
2143 system_prompt: String::new(),
2144 instructions: None,
2145 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2146 temperature: 0.7,
2147 max_tokens: 4096,
2148 tools: vec![],
2149
2150 ollama_num_ctx: None,
2151 ollama_allow_ram_offload: None,
2152 resolved_context_window: None,
2153 resolved_max_output: None,
2154 output_schema: None,
2155 suppress_auto_compact: false,
2156 suppressed_builtin_tools: Vec::new(),
2157 };
2158 r.dispatch(Cmd::CallModel { turn, request });
2159 assert_eq!(r.scope_count(), 1);
2160
2161 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2166 .await
2167 .expect("upstream error arrived")
2168 .expect("channel alive");
2169 assert!(matches!(msg, Msg::UpstreamError { .. }));
2170
2171 tokio::task::yield_now().await;
2173
2174 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2176 assert_eq!(
2177 r.scope_count(),
2178 0,
2179 "completed scope must be reaped on next dispatch"
2180 );
2181 }
2182
2183 #[tokio::test]
2184 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2185 let (mut r, mut rx) = runner();
2186 let turn = TurnId(7);
2187 let call_id = ToolCallId(1);
2188 let source = mermaid_model::models::tool_call::ToolCall {
2189 id: Some("c1".to_string()),
2190 function: mermaid_model::models::tool_call::FunctionCall {
2191 name: "read_file".to_string(),
2192 arguments: serde_json::json!({"path": "x"}),
2193 },
2194 };
2195 r.dispatch(Cmd::ExecuteTool {
2196 turn,
2197 call_id,
2198 source,
2199 model_id: "ollama/test".to_string(),
2200 safety_mode: mermaid_runtime::SafetyMode::Ask,
2201 plan_file: None,
2202 plan_permissions: mermaid_domain::PlanPermissions::default(),
2203 context_percent: None,
2204 intent: None,
2205 session_id: "sess-test".to_string(),
2206 message_index: 0,
2207 scratchpad: None,
2208 });
2209 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2210 .await
2211 .expect("some msg")
2212 .expect("channel alive");
2213 assert!(matches!(
2214 first,
2215 Msg::ToolStarted {
2216 turn: t,
2217 call_id: c,
2218 } if t == turn && c == call_id
2219 ));
2220 }
2221
2222 #[tokio::test]
2223 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2224 let (mut r, _rx) = runner();
2225 let turn = TurnId(9);
2226 r.dispatch(Cmd::CallModel {
2227 turn,
2228 request: mermaid_domain::ChatRequest {
2229 model_id: "m".to_string(),
2230 messages: vec![],
2231 system_prompt: String::new(),
2232 instructions: None,
2233 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2234 temperature: 0.7,
2235 max_tokens: 4096,
2236 tools: vec![],
2237
2238 ollama_num_ctx: None,
2239 ollama_allow_ram_offload: None,
2240 resolved_context_window: None,
2241 resolved_max_output: None,
2242 output_schema: None,
2243 suppress_auto_compact: false,
2244 suppressed_builtin_tools: Vec::new(),
2245 },
2246 });
2247 assert_eq!(r.scope_count(), 1);
2248
2249 r.dispatch(Cmd::CancelScope(turn));
2250 assert_eq!(r.scope_count(), 0);
2251 }
2252
2253 #[tokio::test]
2254 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2255 let (mut r, _rx) = runner();
2261 let req = || mermaid_domain::ChatRequest {
2262 model_id: "test/m".to_string(),
2263 messages: vec![],
2264 system_prompt: String::new(),
2265 instructions: None,
2266 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2267 temperature: 0.7,
2268 max_tokens: 4096,
2269 tools: vec![],
2270 ollama_num_ctx: None,
2271 ollama_allow_ram_offload: None,
2272 resolved_context_window: None,
2273 resolved_max_output: None,
2274 output_schema: None,
2275 suppress_auto_compact: false,
2276 suppressed_builtin_tools: Vec::new(),
2277 };
2278 let turn = TurnId(123);
2279
2280 r.dispatch(Cmd::CallModel {
2281 turn,
2282 request: req(),
2283 });
2284 assert_eq!(r.scope_count(), 1);
2285
2286 r.dispatch(Cmd::CancelScope(turn));
2288 assert_eq!(r.scope_count(), 0);
2289
2290 r.dispatch(Cmd::CallModel {
2292 turn,
2293 request: req(),
2294 });
2295 assert_eq!(
2296 r.scope_count(),
2297 0,
2298 "a cancelled turn must not be resurrected by a late scoped Cmd"
2299 );
2300
2301 r.dispatch(Cmd::CallModel {
2303 turn: TurnId(124),
2304 request: req(),
2305 });
2306 assert_eq!(
2307 r.scope_count(),
2308 1,
2309 "a fresh turn must still create its scope normally"
2310 );
2311 }
2312
2313 #[tokio::test]
2314 async fn shutdown_drains_pending_saves() {
2315 let (mut r, _rx) = runner();
2316 for _ in 0..5 {
2317 r.dispatch(Cmd::SaveConversation(
2318 mermaid_domain::ConversationHistory::new(
2319 "/p".to_string(),
2320 "m".to_string(),
2321 chrono::Local::now(),
2322 ),
2323 ));
2324 }
2325 let start = std::time::Instant::now();
2327 r.shutdown().await;
2328 assert!(start.elapsed() < Duration::from_secs(2));
2329 }
2330
2331 fn persistence_fixture(
2332 root: &std::path::Path,
2333 archive_id: &str,
2334 ) -> (mermaid_domain::ConversationHistory, PendingCompactionSave) {
2335 let now = chrono::Local::now();
2336 let mut full = mermaid_domain::ConversationHistory::new(
2337 root.display().to_string(),
2338 "test/model".to_string(),
2339 now,
2340 );
2341 full.add_messages(
2342 &[mermaid_model::models::ChatMessage::user("raw history")],
2343 now,
2344 );
2345 let mut compacted = full.clone();
2346 compacted.replace_messages(
2347 vec![mermaid_model::models::ChatMessage::user(
2348 "compacted checkpoint",
2349 )],
2350 now,
2351 );
2352 let archive = mermaid_domain::CompactionArchive {
2353 id: archive_id.to_string(),
2354 conversation_id: full.id.clone(),
2355 created_at: now,
2356 messages: full.messages().to_vec(),
2357 };
2358 let record = mermaid_domain::CompactionEvent {
2359 id: archive_id.to_string(),
2360 trigger: mermaid_domain::CompactionTrigger::Manual,
2361 created_at: now,
2362 before_tokens: 100,
2363 after_tokens: 20,
2364 archived_message_count: 1,
2365 preserved_message_count: 1,
2366 preserved_turn_count: 1,
2367 summary_tokens: 10,
2368 duration_secs: 0.1,
2369 review_status: mermaid_domain::CompactionReviewStatus::Reviewed,
2370 review_error: None,
2371 focus: None,
2372 archive_path: None,
2373 };
2374 (
2375 full,
2376 PendingCompactionSave {
2377 archive,
2378 record,
2379 conversation: compacted,
2380 task_id: None,
2381 },
2382 )
2383 }
2384
2385 #[test]
2386 fn persistence_orders_compaction_before_newer_conversation_save() {
2387 let root = std::env::temp_dir().join(format!(
2388 "mermaid-persistence-order-{}-{:?}",
2389 std::process::id(),
2390 std::thread::current().id()
2391 ));
2392 let _ = std::fs::remove_dir_all(&root);
2393 let (full, compaction) = persistence_fixture(&root, "compact_ordered");
2394 let manager = crate::session::ConversationManager::new(&root).unwrap();
2395 manager.save_conversation(&full).unwrap();
2396
2397 let mut state = PersistenceState::new(root.clone());
2398 let (events, outcome) =
2399 state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
2400 outcome.unwrap();
2401 assert_eq!(events.len(), 1);
2402 let mut newer = compaction.conversation;
2403 newer.add_messages(
2404 &[mermaid_model::models::ChatMessage::assistant(
2405 "new assistant reply",
2406 )],
2407 chrono::Local::now(),
2408 );
2409 let (_, outcome) = state.process(PersistenceJob::Conversation(Box::new(newer)));
2410 outcome.unwrap();
2411
2412 let loaded = crate::session::ConversationManager::new(&root)
2413 .unwrap()
2414 .load_conversation(&full.id)
2415 .unwrap();
2416 assert!(
2417 loaded
2418 .messages()
2419 .iter()
2420 .any(|message| message.content == "new assistant reply")
2421 );
2422 let _ = std::fs::remove_dir_all(root);
2423 }
2424
2425 #[test]
2426 fn failed_archive_blocks_later_stripped_conversation_save() {
2427 let root = std::env::temp_dir().join(format!(
2428 "mermaid-persistence-barrier-{}-{:?}",
2429 std::process::id(),
2430 std::thread::current().id()
2431 ));
2432 let _ = std::fs::remove_dir_all(&root);
2433 let (full, compaction) = persistence_fixture(&root, "../invalid");
2434 let manager = crate::session::ConversationManager::new(&root).unwrap();
2435 manager.save_conversation(&full).unwrap();
2436
2437 let mut state = PersistenceState::new(root.clone());
2438 assert!(
2439 state
2440 .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
2441 .1
2442 .is_err()
2443 );
2444 assert!(
2445 state
2446 .process(PersistenceJob::Conversation(Box::new(
2447 compaction.conversation,
2448 )))
2449 .1
2450 .is_err()
2451 );
2452 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2453
2454 let loaded = crate::session::ConversationManager::new(&root)
2455 .unwrap()
2456 .load_conversation(&full.id)
2457 .unwrap();
2458 assert_eq!(loaded.messages()[0].content, "raw history");
2459 let _ = std::fs::remove_dir_all(root);
2460 }
2461
2462 #[test]
2463 fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
2464 let root = std::env::temp_dir().join(format!(
2465 "mermaid-persistence-queue-{}-{:?}",
2466 std::process::id(),
2467 std::thread::current().id()
2468 ));
2469 let _ = std::fs::remove_dir_all(&root);
2470 let (full, first) = persistence_fixture(&root, "../invalid");
2471 let mut second = first.clone();
2472 second.archive.id = "compact_second".to_string();
2473 second.record.id = "compact_second".to_string();
2474
2475 let mut state = PersistenceState::new(root.clone());
2476 assert!(
2477 state
2478 .process(PersistenceJob::Compaction(Box::new(first)))
2479 .1
2480 .is_err()
2481 );
2482 assert!(
2485 state
2486 .process(PersistenceJob::Compaction(Box::new(second)))
2487 .1
2488 .is_err()
2489 );
2490 let queued = state.blocked.get(&full.id).expect("barrier queue");
2491 assert_eq!(queued.len(), 2);
2492 assert_eq!(queued[0].archive.id, "../invalid");
2493 assert_eq!(queued[1].archive.id, "compact_second");
2494 let _ = std::fs::remove_dir_all(root);
2495 }
2496
2497 #[test]
2498 fn retry_all_blocked_attempts_every_conversation() {
2499 let root = std::env::temp_dir().join(format!(
2500 "mermaid-persistence-drain-{}-{:?}",
2501 std::process::id(),
2502 std::thread::current().id()
2503 ));
2504 let _ = std::fs::remove_dir_all(&root);
2505 let (bad_full, bad) = persistence_fixture(&root, "../invalid");
2506 let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
2507 good_full.id = "20990101_000000_001".to_string();
2511 good.archive.conversation_id = good_full.id.clone();
2512 good.conversation.id = good_full.id.clone();
2513
2514 let mut state = PersistenceState::new(root.clone());
2515 state
2516 .blocked
2517 .entry(bad_full.id.clone())
2518 .or_default()
2519 .push_back(bad);
2520 state
2521 .blocked
2522 .entry(good_full.id.clone())
2523 .or_default()
2524 .push_back(good);
2525
2526 let (events, outcome) = state.retry_all_blocked();
2530 assert!(outcome.is_err());
2531 assert_eq!(events.len(), 1);
2532 assert_eq!(events[0].id, "compact_good");
2533 assert!(!state.blocked.contains_key(&good_full.id));
2534 assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
2535 let loaded = crate::session::ConversationManager::new(&root)
2536 .unwrap()
2537 .load_conversation(&good_full.id)
2538 .unwrap();
2539 assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
2540 let _ = std::fs::remove_dir_all(root);
2541 }
2542
2543 #[test]
2544 fn partially_drained_barrier_reports_its_persisted_events() {
2545 let root = std::env::temp_dir().join(format!(
2546 "mermaid-persistence-partial-{}-{:?}",
2547 std::process::id(),
2548 std::thread::current().id()
2549 ));
2550 let _ = std::fs::remove_dir_all(&root);
2551 let (full, good) = persistence_fixture(&root, "compact_good");
2552 let mut bad = good.clone();
2553 bad.archive.id = "../invalid".to_string();
2554 bad.record.id = "../invalid".to_string();
2555
2556 let mut state = PersistenceState::new(root.clone());
2557 let queue = state.blocked.entry(full.id.clone()).or_default();
2558 queue.push_back(good);
2559 queue.push_back(bad);
2560
2561 let (events, outcome) = state.retry_blocked(&full.id);
2565 assert!(outcome.is_err());
2566 assert_eq!(events.len(), 1);
2567 assert_eq!(events[0].id, "compact_good");
2568 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2569 let _ = std::fs::remove_dir_all(root);
2570 }
2571}