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::{
48 Cmd, CompactionRequest, CompactionResult, CompactionTrigger, Msg, Query, QueryResult, TurnId,
49};
50use mermaid_domain::{Config, MemoryConfig};
51
52pub use turn_scope::TurnScope;
53
54#[cfg(not(test))]
55const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
56#[cfg(test)]
57const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
58
59const CANCELLED_TOMBSTONE_CAP: usize = 256;
65
66pub type MsgSender = mpsc::Sender<Msg>;
72
73pub const MSG_CHANNEL_CAPACITY: usize = 512;
79
80fn compaction_row(
92 record: &mermaid_domain::CompactionEvent,
93 archive_path: &std::path::Path,
94 task_id: Option<String>,
95 session_id: String,
96) -> mermaid_runtime::NewCompaction {
97 mermaid_runtime::NewCompaction {
98 id: Some(record.id.clone()),
99 task_id,
100 session_id: Some(session_id),
101 source_token_estimate: Some(record.before_tokens as i64),
102 summary_token_count: Some(record.summary_tokens as i64),
103 preserved_turns: Some(record.preserved_turn_count as i64),
104 archive_path: Some(archive_path.display().to_string()),
105 verification_status: Some(record.review_status.as_str().to_string()),
106 }
107}
108
109fn upsert_session_index(
113 manager: &crate::session::ConversationManager,
114 snapshot: &mermaid_domain::ConversationHistory,
115) {
116 let conversation_path = manager
117 .conversations_dir()
118 .join(format!("{}.json", snapshot.id));
119 let _ = mermaid_runtime::with_shared_store(|store| {
120 store.sessions().upsert(mermaid_runtime::NewSession {
121 id: Some(snapshot.id.clone()),
122 project_path: snapshot.project_path.clone(),
126 model_id: snapshot.model_name.clone(),
127 title: Some(snapshot.title.clone()),
128 conversation_path: Some(conversation_path.display().to_string()),
129 total_tokens: Some(
132 i64::try_from(snapshot.cumulative_token_usage.total_tokens()).unwrap_or(i64::MAX),
133 ),
134 })
135 });
136}
137
138#[derive(Clone)]
139enum PersistenceJob {
140 Conversation {
141 snapshot: Box<mermaid_domain::ConversationHistory>,
142 events: Vec<mermaid_domain::SessionEvent>,
143 },
144 Compaction(Box<PendingCompactionSave>),
145}
146
147#[derive(Clone)]
148struct PendingCompactionSave {
149 record: mermaid_domain::CompactionEvent,
150 conversation: mermaid_domain::ConversationHistory,
151 events: Vec<mermaid_domain::SessionEvent>,
152 events_appended: bool,
156 task_id: Option<String>,
157}
158
159struct PersistedCompaction {
160 id: String,
161 task_id: Option<String>,
162 session_id: String,
163 archive_path: PathBuf,
164}
165
166const CHECKPOINT_EVERY_EVENTS: usize = 200;
175
176struct PersistenceState {
177 workdir: PathBuf,
178 manager: Option<crate::session::ConversationManager>,
179 blocked: HashMap<String, VecDeque<PendingCompactionSave>>,
180 dirty: HashMap<String, DirtySession>,
184 unappended: HashMap<String, Vec<mermaid_domain::SessionEvent>>,
189}
190
191struct DirtySession {
192 snapshot: mermaid_domain::ConversationHistory,
193 events_since_checkpoint: usize,
194}
195
196impl PersistenceState {
197 fn new(workdir: PathBuf) -> Self {
198 Self {
199 workdir,
200 manager: None,
201 blocked: HashMap::new(),
202 dirty: HashMap::new(),
203 unappended: HashMap::new(),
204 }
205 }
206
207 fn manager(&mut self) -> anyhow::Result<&crate::session::ConversationManager> {
208 if self.manager.is_none() {
209 self.manager = Some(crate::session::ConversationManager::new(&self.workdir)?);
210 }
211 Ok(self.manager.as_ref().expect("manager initialized"))
212 }
213
214 fn process(&mut self, job: PersistenceJob) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
219 match job {
220 PersistenceJob::Conversation { snapshot, events } => {
221 let (persisted, retried) = self.retry_blocked(&snapshot.id);
224 if retried.is_err() {
225 return (persisted, retried);
226 }
227 let saved = self.save_session(*snapshot, events);
228 (persisted, saved)
229 },
230 PersistenceJob::Compaction(save) => {
231 let conversation_id = save.conversation.id.clone();
237 self.blocked
238 .entry(conversation_id.clone())
239 .or_default()
240 .push_back(*save);
241 self.retry_blocked(&conversation_id)
242 },
243 }
244 }
245
246 fn retry_blocked(
247 &mut self,
248 conversation_id: &str,
249 ) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
250 let mut persisted = Vec::new();
251 if !self.blocked.contains_key(conversation_id) {
252 return (persisted, Ok(()));
253 }
254 if let Err(error) = self.manager() {
255 return (persisted, Err(error));
256 }
257 let manager = self.manager.as_ref().expect("manager initialized");
261 let dirty = &mut self.dirty;
262 let queue = self
263 .blocked
264 .get_mut(conversation_id)
265 .expect("checked above");
266 while let Some(save) = queue.front_mut() {
267 match Self::persist_compaction(manager, dirty, save) {
268 Ok(event) => {
272 persisted.push(event);
273 queue.pop_front();
274 },
275 Err(error) => return (persisted, Err(error)),
276 }
277 }
278 self.blocked.remove(conversation_id);
279 (persisted, Ok(()))
280 }
281
282 fn save_session(
291 &mut self,
292 snapshot: mermaid_domain::ConversationHistory,
293 events: Vec<mermaid_domain::SessionEvent>,
294 ) -> anyhow::Result<()> {
295 let id = snapshot.id.clone();
296 let mut batch = self.unappended.remove(&id).unwrap_or_default();
298 batch.extend(events);
299
300 let manager = self.manager()?;
301 if let Err(error) = manager.append_session_events(&snapshot, &batch) {
302 tracing::warn!(
303 id = %id,
304 pending = batch.len(),
305 %error,
306 "session event append failed; holding the events for the next save"
307 );
308 self.unappended.insert(id, batch);
309 return Err(error);
310 }
311 upsert_session_index(manager, &snapshot);
312
313 let entry = self
314 .dirty
315 .entry(id.clone())
316 .or_insert_with(|| DirtySession {
317 snapshot: snapshot.clone(),
318 events_since_checkpoint: 0,
319 });
320 entry.snapshot = snapshot;
321 entry.events_since_checkpoint += batch.len();
322 if entry.events_since_checkpoint >= CHECKPOINT_EVERY_EVENTS {
323 return self.write_checkpoint(&id);
324 }
325 Ok(())
326 }
327
328 fn write_checkpoint(&mut self, id: &str) -> anyhow::Result<()> {
331 let Some(dirty) = self.dirty.remove(id) else {
332 return Ok(());
333 };
334 let manager = self.manager()?;
335 if let Err(error) = manager.save_conversation(&dirty.snapshot) {
336 self.dirty.insert(id.to_string(), dirty);
340 return Err(error);
341 }
342 Ok(())
343 }
344
345 fn flush_checkpoints(&mut self) -> anyhow::Result<()> {
349 let ids: Vec<String> = self.dirty.keys().cloned().collect();
350 let mut first_error = None;
351 for id in ids {
352 if let Err(error) = self.write_checkpoint(&id) {
353 first_error.get_or_insert(error);
354 }
355 }
356 first_error.map_or(Ok(()), Err)
357 }
358
359 fn retry_all_blocked(&mut self) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
360 let ids: Vec<String> = self.blocked.keys().cloned().collect();
361 let mut persisted = Vec::new();
362 let mut first_error = None;
363 for id in ids {
364 let (events, result) = self.retry_blocked(&id);
367 persisted.extend(events);
368 if let Err(error) = result {
369 first_error.get_or_insert(error);
370 }
371 }
372 match first_error {
373 None => (persisted, Ok(())),
374 Some(error) => (persisted, Err(error)),
375 }
376 }
377
378 fn persist_compaction(
379 manager: &crate::session::ConversationManager,
380 dirty: &mut HashMap<String, DirtySession>,
381 save: &mut PendingCompactionSave,
382 ) -> anyhow::Result<PersistedCompaction> {
383 if !save.events_appended {
392 manager.append_session_events(&save.conversation, &save.events)?;
393 save.events_appended = true;
394 }
395 manager.save_conversation(&save.conversation)?;
396 upsert_session_index(manager, &save.conversation);
397 dirty.remove(&save.conversation.id);
401
402 let log_path = manager.event_log_path(&save.conversation.id);
403 let _ = mermaid_runtime::with_shared_store(|store| {
404 store.compactions().create(compaction_row(
405 &save.record,
406 &log_path,
407 save.task_id.clone(),
408 save.conversation.id.clone(),
409 ))
410 });
411
412 Ok(PersistedCompaction {
413 id: save.record.id.clone(),
414 task_id: save.task_id.clone(),
415 session_id: save.conversation.id.clone(),
416 archive_path: log_path,
417 })
418 }
419}
420
421async fn fire_compaction_hook(event: &PersistedCompaction) {
423 fire_plugin_hooks(
424 "compaction",
425 serde_json::json!({
426 "id": event.id,
427 "task_id": event.task_id,
428 "session_id": event.session_id,
429 "archive_path": event.archive_path.display().to_string(),
430 }),
431 )
432 .await;
433}
434
435pub struct EffectRunner {
438 msg_tx: MsgSender,
439 scopes: HashMap<TurnId, TurnScope>,
445 cancelled_turns: VecDeque<TurnId>,
451 detached: tokio::task::JoinSet<()>,
455 persistence_state: Arc<Mutex<PersistenceState>>,
459 persistence_tail: Option<tokio::task::JoinHandle<()>>,
460 workdir: PathBuf,
464 providers: Option<Arc<ProviderFactory>>,
469 tools: Option<Arc<ToolRegistry>>,
472 task_id: Option<String>,
474 terminal_title_enabled: bool,
478 owns_global_mcp: bool,
484 approval: Option<crate::providers::ApprovalBroker>,
488 questions: Option<crate::providers::QuestionBroker>,
492 tasks: crate::providers::TaskBroker,
497 config_watch: Option<tokio::task::AbortHandle>,
501}
502
503impl EffectRunner {
504 #[must_use]
506 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
507 let persistence_state = Arc::new(Mutex::new(PersistenceState::new(workdir.clone())));
508 Self {
509 tasks: crate::providers::TaskBroker::new(msg_tx.clone()),
510 msg_tx,
511 scopes: HashMap::new(),
512 cancelled_turns: VecDeque::new(),
513 detached: tokio::task::JoinSet::new(),
514 persistence_state,
515 persistence_tail: None,
516 workdir,
517 providers: None,
518 tools: None,
519 task_id: None,
520 terminal_title_enabled: true,
521 owns_global_mcp: true,
522 approval: None,
523 questions: None,
524 config_watch: None,
525 }
526 }
527
528 #[must_use]
534 pub fn sender(&self) -> MsgSender {
535 self.msg_tx.clone()
536 }
537
538 #[must_use]
542 pub fn with_interactive_approvals(mut self) -> Self {
543 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
544 self
545 }
546
547 #[must_use]
551 pub fn with_interactive_questions(mut self) -> Self {
552 self.questions = Some(crate::providers::QuestionBroker::new(self.msg_tx.clone()));
553 self
554 }
555
556 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
562 let handle = self.detached.spawn(config_watch::config_watcher(
563 self.msg_tx.clone(),
564 cwd,
565 memory,
566 ));
567 self.config_watch = Some(handle);
568 }
569
570 #[must_use]
573 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
574 self.task_id = task_id;
575 self
576 }
577
578 #[must_use]
580 pub fn without_terminal_title(mut self) -> Self {
581 self.terminal_title_enabled = false;
582 self
583 }
584
585 #[must_use]
588 pub fn without_global_mcp_shutdown(mut self) -> Self {
589 self.owns_global_mcp = false;
590 self
591 }
592
593 pub fn with_bindings(
598 mut self,
599 providers: Arc<ProviderFactory>,
600 tools: Arc<ToolRegistry>,
601 ) -> Self {
602 self.providers = Some(providers);
603 self.tools = Some(tools);
604 self
605 }
606
607 #[must_use]
611 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
612 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
613 (Self::new(tx, workdir), rx)
614 }
615
616 #[must_use]
619 pub fn pair_with_bindings(
620 workdir: PathBuf,
621 config: Config,
622 tools: Arc<ToolRegistry>,
623 ) -> (Self, mpsc::Receiver<Msg>) {
624 let providers = Arc::new(ProviderFactory::new(config));
625 Self::pair_from(workdir, providers, tools)
626 }
627
628 pub fn pair_from(
633 workdir: PathBuf,
634 providers: Arc<ProviderFactory>,
635 tools: Arc<ToolRegistry>,
636 ) -> (Self, mpsc::Receiver<Msg>) {
637 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
638 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
639 }
640
641 pub fn pair_from_with_task(
642 workdir: PathBuf,
643 providers: Arc<ProviderFactory>,
644 tools: Arc<ToolRegistry>,
645 task_id: Option<String>,
646 ) -> (Self, mpsc::Receiver<Msg>) {
647 let (runner, rx) = Self::pair_from(workdir, providers, tools);
648 (runner.with_task_id(task_id), rx)
649 }
650
651 pub fn new_child(
656 msg_tx: MsgSender,
657 workdir: PathBuf,
658 providers: Arc<ProviderFactory>,
659 tools: Arc<ToolRegistry>,
660 ) -> Self {
661 Self::new(msg_tx, workdir)
669 .with_bindings(providers, tools)
670 .without_terminal_title()
671 .without_global_mcp_shutdown()
672 }
673
674 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
678 self.scopes
679 .entry(turn)
680 .or_insert_with(|| TurnScope::new(turn))
681 }
682
683 fn tombstone_turn(&mut self, turn: TurnId) {
687 if self.cancelled_turns.contains(&turn) {
688 return;
689 }
690 if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
691 self.cancelled_turns.pop_front();
692 }
693 self.cancelled_turns.push_back(turn);
694 }
695
696 fn is_tombstoned(&self, turn: TurnId) -> bool {
699 self.cancelled_turns.contains(&turn)
700 }
701
702 fn dispatch_query(&mut self, query: Query) {
708 let tx = self.msg_tx.clone();
709 match query {
710 Query::LoadConversation { id } => self.query_load_conversation(id, tx),
711 Query::ListConversations => self.query_list_conversations(tx),
712 Query::ListAvailableModels => {
713 let providers = self.providers.clone();
714 self.detached.spawn(async move {
715 let choices = discover_available_models(providers).await;
716 let _ = tx
717 .send(Msg::QueryResult(QueryResult::AvailableModelsListed(
718 choices,
719 )))
720 .await;
721 });
722 },
723 Query::ListProjectFiles => {
724 let workdir = self.workdir.clone();
725 self.send_blocking_query(move || {
726 QueryResult::ProjectFilesListed(walk_project_files(&workdir))
727 });
728 },
729 Query::ListRuntimeTasks { limit } => self.send_blocking_query(move || {
730 QueryResult::RuntimeTasksListed(
731 crate::runtime_client::RuntimeClient::auto()
732 .list_tasks(limit)
733 .map(|read| read.value)
734 .unwrap_or_default(),
735 )
736 }),
737 Query::LoadRuntimeTask { id } => self.send_blocking_query(move || {
738 let (task, events) = crate::runtime_client::RuntimeClient::auto()
739 .task_detail(&id)
740 .map(|read| (Some(Box::new(read.value.task)), read.value.events))
741 .unwrap_or((None, Vec::new()));
742 QueryResult::RuntimeTaskLoaded { task, events }
743 }),
744 Query::ListRuntimeProcesses { limit } => self.send_blocking_query(move || {
745 QueryResult::RuntimeProcessesListed(
746 crate::runtime_client::RuntimeClient::auto()
747 .list_processes(limit)
748 .map(|read| read.value)
749 .unwrap_or_default(),
750 )
751 }),
752 Query::ListRuntimeApprovals => self.send_blocking_query(move || {
753 QueryResult::RuntimeApprovalsListed(
754 crate::runtime_client::RuntimeClient::auto()
755 .list_approvals()
756 .map(|read| read.value)
757 .unwrap_or_default(),
758 )
759 }),
760 Query::ListRuntimeCheckpoints { limit } => self.send_blocking_query(move || {
761 QueryResult::RuntimeCheckpointsListed(
762 crate::runtime_client::RuntimeClient::auto()
763 .list_checkpoints(limit)
764 .map(|read| read.value)
765 .unwrap_or_default(),
766 )
767 }),
768 Query::ListForkCheckpoints {
769 session_id,
770 message_index,
771 } => self.send_blocking_query(move || {
772 QueryResult::ForkCheckpointsFound(
773 mermaid_runtime::with_shared_store(|store| {
774 store
775 .checkpoints()
776 .list_for_session(&session_id, message_index as i64)
777 })
778 .unwrap_or_default(),
779 )
780 }),
781 Query::ListRuntimePlugins => self.send_blocking_query(move || {
782 QueryResult::RuntimePluginsListed(
783 crate::runtime_client::RuntimeClient::auto()
784 .list_plugins()
785 .map(|read| read.value)
786 .unwrap_or_default(),
787 )
788 }),
789 }
790 }
791
792 fn query_load_conversation(&mut self, id: String, tx: MsgSender) {
796 let workdir = self.workdir.clone();
797 self.detached.spawn(async move {
798 match crate::session::ConversationManager::new(&workdir) {
799 Ok(mgr) => match mgr.load_conversation(&id) {
800 Ok(history) => {
801 let _ = tx
802 .send(Msg::QueryResult(QueryResult::ConversationLoaded(Box::new(
803 history,
804 ))))
805 .await;
806 },
807 Err(e) => {
808 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
809 },
810 },
811 Err(e) => {
812 tracing::warn!(error = %e, "ConversationManager init failed");
813 },
814 }
815 });
816 }
817
818 fn query_list_conversations(&mut self, tx: MsgSender) {
821 let workdir = self.workdir.clone();
822 self.detached.spawn(async move {
823 let summaries = match crate::session::ConversationManager::new(&workdir) {
824 Ok(mgr) => mgr
825 .list_conversation_metas()
826 .unwrap_or_default()
827 .into_iter()
828 .map(|m| mermaid_domain::ConversationSummary {
829 id: m.id,
830 title: m.title,
831 message_count: m.message_count,
832 updated_at: m.updated_at.to_rfc3339(),
833 })
834 .collect(),
835 Err(_) => Vec::new(),
836 };
837 let _ = tx
838 .send(Msg::QueryResult(QueryResult::ConversationsListed(
839 summaries,
840 )))
841 .await;
842 });
843 }
844
845 fn send_blocking_query(&mut self, run: impl FnOnce() -> QueryResult + Send + 'static) {
850 let tx = self.msg_tx.clone();
851 self.detached.spawn_blocking(move || {
852 let _ = tx.blocking_send(Msg::QueryResult(run()));
853 });
854 }
855
856 fn drop_scope(&mut self, turn: TurnId) {
866 self.tombstone_turn(turn);
871 if let Some(mut scope) = self.scopes.remove(&turn) {
872 scope.cancel();
873 let tx = self.msg_tx.clone();
874 self.detached.spawn(async move {
875 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
876 .await
877 .is_err()
878 {
879 tracing::warn!(
880 turn = %turn,
881 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
882 "cancel drain timed out; aborting remaining scoped tasks"
883 );
884 }
885 let _ = tx.send(Msg::TurnCancelled(turn)).await;
886 });
887 } else {
888 let tx = self.msg_tx.clone();
895 self.detached.spawn(async move {
896 let _ = tx.send(Msg::TurnCancelled(turn)).await;
897 });
898 }
899 }
900
901 #[must_use]
904 pub fn scope_count(&self) -> usize {
905 self.scopes.len()
906 }
907
908 fn reap_empty_scopes(&mut self) {
917 self.reap_detached();
918 self.scopes.retain(|_, scope| {
919 scope.drain_completed();
920 !scope.is_empty()
921 });
922 }
923
924 fn reap_detached(&mut self) {
929 while let Some(result) = self.detached.try_join_next() {
930 if let Err(e) = result
931 && !e.is_cancelled()
932 {
933 tracing::warn!(error = %e, "effect: detached task panicked");
934 }
935 }
936 }
937
938 #[expect(
942 clippy::too_many_lines,
943 reason = "predates the lint; see .github/baselines/expect_budget.txt"
944 )]
945 pub fn dispatch(&mut self, cmd: Cmd) {
946 self.reap_empty_scopes();
949 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
950
951 if let Some(turn) = cmd.scope_turn()
959 && self.is_tombstoned(turn)
960 {
961 tracing::debug!(
962 cmd = %cmd.summary(),
963 turn = %turn,
964 "effect: dropping turn-scoped cmd for an already-cancelled turn"
965 );
966 return;
967 }
968
969 match cmd {
970 Cmd::CallModel { turn, mut request } => {
971 let tx = self.msg_tx.clone();
972 let providers = self.providers.clone();
973 if let Some(tools) = &self.tools
982 && request.output_schema.is_none()
983 {
984 let mut enriched =
985 filter_suppressed(tools.describe_all(), &request.suppressed_builtin_tools);
986 let builtin_tokens = mermaid_domain::estimate_tool_schema_tokens(&enriched);
991 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
997 tracing::debug!(
998 error = %e,
999 "effect: dropped builtin tool-schema token estimate (channel full); \
1000 /context preview may be briefly stale"
1001 );
1002 }
1003 enriched.append(&mut request.tools);
1004 request.tools = enriched;
1005 }
1006 self.detached.spawn(fire_plugin_hooks(
1009 "prompt_submit",
1010 serde_json::json!({
1011 "turn_id": turn.0,
1012 "model_id": request.model_id.clone(),
1013 "message_count": request.messages.len(),
1014 "tool_count": request.tools.len(),
1015 }),
1016 ));
1017 let task_usage = self.tasks.clone();
1020 let scope = self.scope_mut(turn);
1021 let token = scope.token();
1022 scope.spawn(async move {
1023 use futures::FutureExt;
1024 let fallback_tx = tx.clone();
1025 if std::panic::AssertUnwindSafe(dispatch_call_model(
1026 tx, providers, turn, request, token, task_usage,
1027 ))
1028 .catch_unwind()
1029 .await
1030 .is_err()
1031 {
1032 tracing::error!(turn = %turn, "dispatch_call_model panicked");
1037 let _ = fallback_tx
1038 .send(Msg::UpstreamError {
1039 turn,
1040 error: mermaid_model::models::UserFacingError {
1041 summary: "Internal error".to_string(),
1042 message: "The model dispatch task panicked unexpectedly."
1043 .to_string(),
1044 suggestion: "This is a bug. Please retry; if it persists, \
1045 check the logs."
1046 .to_string(),
1047 category: mermaid_model::models::ErrorCategory::Internal,
1048 recoverable: true,
1049 },
1050 })
1051 .await;
1052 }
1053 });
1054 },
1055 Cmd::CompactConversation { turn, mut request } => {
1056 let tx = self.msg_tx.clone();
1057 let providers = self.providers.clone();
1058 if let Some(tools) = &self.tools {
1059 let mut enriched = tools.describe_all();
1060 enriched.append(&mut request.chat.tools);
1061 request.chat.tools = enriched;
1062 }
1063 let trigger = request.trigger;
1066 let scope = self.scope_mut(turn);
1067 let token = scope.token();
1068 scope.spawn(async move {
1069 use futures::FutureExt;
1070 let fallback_tx = tx.clone();
1071 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
1072 tx, providers, turn, request, token,
1073 ))
1074 .catch_unwind()
1075 .await
1076 .is_err()
1077 {
1078 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
1084 let _ = fallback_tx
1085 .send(Msg::CompactionFailed {
1086 turn,
1087 trigger,
1088 message: "the compaction task panicked unexpectedly".to_string(),
1089 kind: mermaid_domain::StatusKind::Error,
1090 })
1091 .await;
1092 }
1093 });
1094 },
1095 Cmd::ExecuteTool {
1096 turn,
1097 call_id,
1098 source,
1099 dispatch,
1100 } => {
1101 let tx = self.msg_tx.clone();
1102 let tools = self.tools.clone();
1103 let workdir = self.workdir.clone();
1104 let config = self
1109 .providers
1110 .as_ref()
1111 .map(|p| Arc::new(p.config().clone()))
1112 .unwrap_or_else(|| Arc::new(mermaid_domain::Config::default()));
1113 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
1121 if dispatch.safety_mode == mermaid_runtime::SafetyMode::Auto
1122 || dispatch.plan_file.is_some()
1123 {
1124 self.providers.as_ref().map(|p| {
1125 let model = config
1126 .safety
1127 .auto_classifier_model
1128 .clone()
1129 .unwrap_or_else(|| dispatch.model_id.clone());
1130 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
1131 as Arc<dyn crate::providers::AutoClassifier>
1132 })
1133 } else {
1134 None
1135 };
1136 let services = crate::providers::ctx::ToolServices {
1137 workdir,
1138 config,
1139 task_id: self.task_id.clone(),
1140 notify: Some(self.msg_tx.clone()),
1144 classifier,
1145 approval: self.approval.clone(),
1146 questions: self.questions.clone(),
1147 tasks: Some(self.tasks.clone()),
1148 };
1149 let scope = self.scope_mut(turn);
1150 let signals = crate::providers::ctx::TurnSignals {
1151 token: scope.token(),
1152 background: scope.background_token(),
1153 web_bytes: scope.web_bytes(),
1154 };
1155 scope.spawn(async move {
1156 use futures::FutureExt;
1157 let fallback_tx = tx.clone();
1158 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
1159 tx, tools, turn, call_id, source, signals, dispatch, services,
1160 ))
1161 .catch_unwind()
1162 .await
1163 .is_err()
1164 {
1165 tracing::error!(
1170 turn = %turn,
1171 call_id = call_id.0,
1172 "dispatch_execute_tool panicked"
1173 );
1174 let _ = fallback_tx
1175 .send(Msg::ToolFinished {
1176 turn,
1177 call_id,
1178 outcome: mermaid_domain::ToolOutcome::error(
1179 "internal error: the tool execution task panicked".to_string(),
1180 0.0,
1181 ),
1182 })
1183 .await;
1184 }
1185 });
1186 },
1187 Cmd::ResolveApproval { call_id, decision } => {
1188 if let Some(broker) = &self.approval {
1191 broker.resolve(call_id, decision.into());
1192 }
1193 },
1194 Cmd::ResolveQuestion {
1195 call_id,
1196 resolution,
1197 } => {
1198 if let Some(broker) = &self.questions {
1201 broker.resolve(call_id, resolution);
1202 }
1203 },
1204 Cmd::SyncTaskStore(store) => {
1205 self.tasks.seed(store);
1209 },
1210 Cmd::EnsureScratchpad { session_id } => {
1211 let tx = self.msg_tx.clone();
1212 let workdir = self.workdir.clone();
1213 self.detached.spawn(async move {
1214 match crate::session::scratchpad::ensure(&workdir, &session_id) {
1215 Ok(path) => {
1216 let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
1217 },
1218 Err(err) => {
1219 tracing::warn!(error = %err, "failed to create session scratchpad");
1222 },
1223 }
1224 if let Err(err) = crate::session::scratchpad::sweep_stale(
1227 crate::session::scratchpad::RETENTION_DAYS,
1228 ) {
1229 tracing::warn!(error = %err, "scratchpad sweep failed");
1230 }
1231 });
1232 },
1233 Cmd::ListScratchpad { path } => {
1234 let tx = self.msg_tx.clone();
1237 self.detached.spawn(async move {
1238 let text = tokio::task::spawn_blocking(move || {
1239 crate::session::scratchpad::list_text(&path)
1240 })
1241 .await
1242 .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
1243 let _ = tx.send(Msg::RuntimeText(text)).await;
1244 });
1245 },
1246 Cmd::UserTaskEdit(edit) => {
1247 let broker = self.tasks.clone();
1252 let tx = self.msg_tx.clone();
1253 self.detached.spawn(async move {
1254 let (line, _snapshot) = broker.user_edit(edit).await;
1255 let _ = tx
1260 .send(Msg::TaskNotice {
1261 text: format!(
1262 "The user edited the task checklist: {line}. Acknowledge and \
1263 incorporate this into your plan."
1264 ),
1265 })
1266 .await;
1267 let _ = tx.send(Msg::TransientStatus { text: line }).await;
1268 });
1269 },
1270 Cmd::NotifyTaskCompleted {
1271 task,
1272 completed,
1273 total,
1274 } => {
1275 let payload = serde_json::json!({
1283 "task_id": task.id,
1284 "subject": task.subject,
1285 "description": task.description,
1286 "evidence": task.evidence,
1287 "completed": completed,
1288 "total": total,
1289 });
1290 let broker = self.tasks.clone();
1291 let tx = self.msg_tx.clone();
1292 self.detached.spawn(async move {
1293 let gate = run_plugin_hooks_gated("task_completed", payload).await;
1294 let Some((plugin, reason)) = gate.deny else {
1295 return;
1296 };
1297 let reason = mermaid_model::utils::redact_secrets(&reason);
1298 let _ = broker
1299 .update(vec![mermaid_domain::ChecklistEdit {
1300 id: task.id,
1301 status: Some(mermaid_domain::ChecklistStatus::InProgress),
1302 ..mermaid_domain::ChecklistEdit::default()
1303 }])
1304 .await;
1305 let _ = tx
1306 .send(Msg::TaskNotice {
1307 text: format!(
1308 "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
1309 {reason}. The task is back in_progress; address the reason \
1310 before completing it again.",
1311 task.id, task.subject
1312 ),
1313 })
1314 .await;
1315 let _ = tx
1316 .send(Msg::TransientStatus {
1317 text: format!(
1318 "task #{} completion vetoed by {plugin}: {reason}",
1319 task.id
1320 ),
1321 })
1322 .await;
1323 });
1324 },
1325 Cmd::CancelScope(turn) => {
1326 self.drop_scope(turn);
1327 },
1328 Cmd::BackgroundScope(turn) => {
1329 self.scope_mut(turn).background();
1333 },
1334 Cmd::SaveConversation { snapshot, events } => {
1335 self.queue_persistence(PersistenceJob::Conversation {
1336 snapshot: Box::new(snapshot),
1337 events,
1338 });
1339 },
1340 Cmd::SaveCompaction {
1341 record,
1342 conversation,
1343 events,
1344 } => {
1345 self.queue_persistence(PersistenceJob::Compaction(Box::new(
1346 PendingCompactionSave {
1347 record,
1348 conversation,
1349 events,
1350 events_appended: false,
1351 task_id: self.task_id.clone(),
1352 },
1353 )));
1354 },
1355 Cmd::SaveProcess(process) => {
1356 let task_id = self.task_id.clone();
1357 self.detached.spawn(async move {
1358 let status = process.status;
1359 let _ = mermaid_runtime::with_shared_store(|store| {
1360 store.processes().upsert(mermaid_runtime::NewProcess {
1361 id: Some(process.id),
1362 task_id,
1363 pid: process.pid,
1364 command: process.command,
1365 cwd: process.cwd,
1366 log_path: Some(process.log_path),
1367 detected_url: process.detected_url,
1368 status,
1369 health: None,
1370 })
1371 });
1372 });
1373 },
1374 Cmd::PersistPlanConfig(plan) => {
1375 self.detached.spawn(async move {
1376 if let Err(err) = crate::app::persist_plan_config(&plan) {
1377 tracing::warn!(error = %err, "failed to persist [plan] config");
1378 }
1379 });
1380 },
1381 Cmd::PersistLastModel(model) => {
1382 self.detached.spawn(async move {
1383 if let Err(err) = crate::app::persist_last_model(&model) {
1384 tracing::warn!(error = %err, "failed to persist last-used model");
1385 }
1386 });
1387 },
1388 Cmd::PersistReasoningFor { model_id, level } => {
1389 self.detached.spawn(async move {
1390 if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1391 tracing::warn!(error = %err, "failed to persist reasoning level for model");
1392 }
1393 });
1394 },
1395 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1396 self.detached.spawn(async move {
1397 if let Err(err) =
1398 crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1399 {
1400 tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1401 }
1402 });
1403 },
1404 Cmd::PersistOllamaOffload(enabled) => {
1405 self.detached.spawn(async move {
1406 if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1407 tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1408 }
1409 });
1410 },
1411 Cmd::PersistUiTheme(theme) => {
1412 self.detached.spawn(async move {
1413 if let Err(err) = crate::app::persist_ui_theme(theme) {
1414 tracing::warn!(error = %err, "failed to persist theme");
1415 }
1416 });
1417 },
1418 Cmd::ComposeInEditor { .. } => {
1419 tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1423 },
1424 Cmd::ListMemory => {
1425 let tx = self.msg_tx.clone();
1426 let workdir = self.workdir.clone();
1427 self.detached.spawn(async move {
1428 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1429 let text = match crate::app::memory::load(&workdir, &cfg) {
1430 Some(mem) => mem.index,
1431 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(),
1432 };
1433 let _ = tx.send(Msg::RuntimeText(text)).await;
1434 });
1435 },
1436 Cmd::RememberMemory { text } => {
1437 let tx = self.msg_tx.clone();
1438 let workdir = self.workdir.clone();
1439 self.detached.spawn(async move {
1440 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1441 let name = memory_title_from_text(&text);
1442 let status = match crate::app::memory::write_memory(
1443 &workdir,
1444 mermaid_domain::MemoryScope::ProjectPrivate,
1445 &name,
1446 &text,
1447 &[],
1448 &text,
1449 ) {
1450 Ok(_) => format!("Remembered: {name}"),
1451 Err(e) => format!("Couldn't save memory: {e}"),
1452 };
1453 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1454 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1455 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1456 });
1457 },
1458 Cmd::ForgetMemory { id } => {
1459 let tx = self.msg_tx.clone();
1460 let workdir = self.workdir.clone();
1461 self.detached.spawn(async move {
1462 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1463 let status = match crate::app::memory::delete_memory(&workdir, &id) {
1464 Ok(Some(_)) => format!("Forgot: {id}"),
1465 Ok(None) => format!("No memory named '{id}'"),
1466 Err(e) => format!("Couldn't forget memory: {e}"),
1467 };
1468 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1469 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1470 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1471 });
1472 },
1473 Cmd::ConsolidateMemory { model_id } => {
1474 let tx = self.msg_tx.clone();
1475 let workdir = self.workdir.clone();
1476 let providers = self.providers.clone();
1477 self.detached.spawn(async move {
1478 consolidate_memory(tx, providers, workdir, model_id).await;
1479 });
1480 },
1481 Cmd::Query(query) => self.dispatch_query(query),
1482 Cmd::ShowRuntimeProcessLogs { id } => {
1483 let tx = self.msg_tx.clone();
1484 self.detached.spawn_blocking(move || {
1485 let text = crate::runtime_client::RuntimeClient::auto()
1486 .process_log(&id, None)
1487 .map(|log| format!("Process log {}\n\n{}", id, log.content))
1488 .unwrap_or_else(|err| format!("Process log error: {err}"));
1489 let _ = tx.blocking_send(Msg::RuntimeText(text));
1490 });
1491 },
1492 Cmd::StopRuntimeProcess { id } => {
1493 let tx = self.msg_tx.clone();
1494 self.detached.spawn_blocking(move || {
1495 let msg = match crate::runtime_client::RuntimeClient::auto().stop_process(&id) {
1496 Ok(response) => Msg::TransientStatus {
1497 text: format!("Stopped process {} (pid {})", id, response.item.pid),
1498 },
1499 Err(err) => Msg::TransientStatus {
1500 text: format!("Process stop failed: {err}"),
1501 },
1502 };
1503 let _ = tx.blocking_send(msg);
1504 });
1505 },
1506 Cmd::KillBackgroundAgent { agent_id } => {
1507 let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1511 if let Some(spawner) = spawner {
1512 match agent_id {
1513 Some(id) => {
1514 if let crate::providers::tool::subagent::KillResult::Evicted(
1519 workspace,
1520 ) = spawner.kill_detached(&id)
1521 {
1522 tokio::spawn(workspace.discard());
1523 }
1524 },
1525 None => {
1526 spawner.kill_all_detached();
1527 },
1528 }
1529 }
1530 },
1531 Cmd::RestartRuntimeProcess { id } => {
1532 let tx = self.msg_tx.clone();
1533 self.detached.spawn_blocking(move || {
1534 let msg = match crate::runtime_client::RuntimeClient::auto()
1535 .restart_process(&id)
1536 {
1537 Ok(response) => Msg::TransientStatus {
1538 text: format!("Restarted process {} (pid {})", id, response.item.pid),
1539 },
1540 Err(err) => Msg::TransientStatus {
1541 text: format!("Process restart failed: {err}"),
1542 },
1543 };
1544 let _ = tx.blocking_send(msg);
1545 });
1546 },
1547 Cmd::OpenRuntimeTarget { target } => {
1548 self.detached.spawn_blocking(move || {
1549 let resolved = crate::runtime_client::RuntimeService::open_default()
1550 .and_then(|service| service.resolve_open_target(&target))
1551 .unwrap_or(target);
1552 if let Err(err) = crate::runtime_client::validate_open_target(&resolved) {
1556 tracing::warn!(error = %err, "refusing to open runtime target");
1557 return;
1558 }
1559 mermaid_model::utils::open_file(resolved);
1560 });
1561 },
1562 Cmd::ShowRuntimePorts => {
1563 let tx = self.msg_tx.clone();
1564 self.detached.spawn_blocking(move || {
1565 let text = crate::runtime_client::RuntimeClient::auto()
1566 .ports()
1567 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1568 .unwrap_or_else(|err| format!("Port inspection failed: {err}"));
1569 let _ = tx.blocking_send(Msg::RuntimeText(text));
1570 });
1571 },
1572 Cmd::DecideRuntimeApproval { id, decision } => {
1573 let tx = self.msg_tx.clone();
1574 self.detached.spawn_blocking(move || {
1575 let result = if decision == "approved" {
1576 crate::runtime_client::RuntimeClient::auto().approve(&id)
1577 } else {
1578 crate::runtime_client::RuntimeClient::auto().deny(&id)
1579 };
1580 let msg = match result {
1581 Ok(result) => Msg::TransientStatus {
1582 text: if result.replayed {
1583 format!("Approval {} {}: {}", id, decision, result.summary)
1584 } else {
1585 format!("Approval {id} {decision}")
1586 },
1587 },
1588 Err(err) => Msg::TransientStatus {
1589 text: format!("Approval update failed: {err}"),
1590 },
1591 };
1592 let _ = tx.blocking_send(msg);
1593 });
1594 },
1595 Cmd::UpdateRuntimeTaskStatus {
1596 id,
1597 status,
1598 final_report,
1599 } => {
1600 let tx = self.msg_tx.clone();
1601 self.detached.spawn_blocking(move || {
1602 let msg = match mermaid_runtime::with_shared_store(|store| {
1603 store
1604 .tasks()
1605 .update_status(&id, status, final_report.as_deref())
1606 }) {
1607 Ok(()) => Msg::TransientStatus {
1608 text: format!("Task {id} -> {status}"),
1609 },
1610 Err(err) => Msg::TransientStatus {
1611 text: format!("Task update failed: {err}"),
1612 },
1613 };
1614 let _ = tx.blocking_send(msg);
1615 });
1616 },
1617 Cmd::CreateRuntimeCheckpoint { paths } => {
1618 let tx = self.msg_tx.clone();
1619 let workdir = self.workdir.clone();
1620 self.detached.spawn_blocking(move || {
1621 let pending_action = Some(serde_json::json!({
1622 "source": "tui",
1623 "command": "checkpoint",
1624 }));
1625 let msg = match mermaid_runtime::create_checkpoint(
1626 &workdir,
1627 &paths,
1628 pending_action,
1629 ) {
1630 Ok(manifest) => Msg::TransientStatus {
1631 text: format!(
1632 "Checkpoint {} created for {} path(s)",
1633 manifest.id,
1634 manifest.files.len()
1635 ),
1636 },
1637 Err(err) => Msg::TransientStatus {
1638 text: format!("Checkpoint failed: {err}"),
1639 },
1640 };
1641 let _ = tx.blocking_send(msg);
1642 });
1643 },
1644 Cmd::RestoreRuntimeCheckpoint { id } => {
1645 let tx = self.msg_tx.clone();
1646 self.detached.spawn_blocking(move || {
1647 let msg = match crate::runtime_client::RuntimeClient::auto()
1648 .restore_checkpoint(&id)
1649 {
1650 Ok(result) => Msg::TransientStatus {
1651 text: format!(
1652 "Restored checkpoint {} ({} file(s)){}",
1653 result.checkpoint.id,
1654 result.checkpoint.files.len(),
1655 if result.checkpoint.pending_action.is_some() {
1656 "; pending action available in checkpoint manifest"
1657 } else {
1658 ""
1659 }
1660 ),
1661 },
1662 Err(err) => Msg::TransientStatus {
1663 text: format!("Restore failed: {err}"),
1664 },
1665 };
1666 let _ = tx.blocking_send(msg);
1667 });
1668 },
1669 Cmd::ShowRuntimeModelInfo { model } => {
1670 let tx = self.msg_tx.clone();
1671 self.detached.spawn_blocking(move || {
1672 let text = runtime_model_info_text(&model);
1673 let _ = tx.blocking_send(Msg::RuntimeText(text));
1674 });
1675 },
1676 Cmd::InitMcpServers(configs) => {
1677 let tx = self.msg_tx.clone();
1678 self.detached
1679 .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1680 },
1681 Cmd::StopMcpServer { name } => {
1682 let tx = self.msg_tx.clone();
1683 self.detached.spawn(async move {
1684 if let Some(mgr) = crate::mcp::manager_ref::get() {
1687 mgr.stop_server(&name).await;
1688 }
1689 let _ = tx.send(Msg::McpServerStopped { name }).await;
1690 });
1691 },
1692 Cmd::PullOllamaModel { model } => {
1693 let tx = self.msg_tx.clone();
1694 self.detached.spawn(async move {
1695 dispatch_pull_ollama_model(tx, model).await;
1696 });
1697 },
1698 Cmd::OpenInSystem(path) => {
1699 self.detached.spawn(async move {
1700 let _ = tokio::task::spawn_blocking(move || {
1701 mermaid_model::utils::open_file(&path);
1702 })
1703 .await;
1704 });
1705 },
1706 Cmd::WriteImageToTemp {
1707 path,
1708 bytes,
1709 format: _,
1710 } => {
1711 self.detached.spawn(async move {
1712 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1713 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1714 }
1715 });
1716 },
1717 Cmd::ReadClipboard => {
1718 let tx = self.msg_tx.clone();
1719 self.detached.spawn(async move {
1720 dispatch_read_clipboard(tx).await;
1721 });
1722 },
1723 Cmd::ProbeVision { model_id, warn } => {
1724 let tx = self.msg_tx.clone();
1725 let providers = self.providers.clone();
1726 self.detached.spawn(async move {
1727 dispatch_probe_vision(model_id, warn, providers, tx).await;
1728 });
1729 },
1730 Cmd::CopyToClipboard(text) => {
1731 let tx = self.msg_tx.clone();
1732 self.detached.spawn(async move {
1733 dispatch_copy_to_clipboard(text, tx).await;
1734 });
1735 },
1736 Cmd::Exit => {
1737 },
1742 Cmd::SetTerminalTitle(title) => {
1743 if !self.terminal_title_enabled {
1744 return;
1745 }
1746 self.detached.spawn_blocking(move || {
1752 use std::io::Write;
1753 let seq = format!("\x1b]2;{title}\x07");
1754 let mut stdout = std::io::stdout();
1755 let _ = stdout.write_all(seq.as_bytes());
1756 let _ = stdout.flush();
1757 });
1758 },
1759 Cmd::AlertUser => {
1760 if !self.terminal_title_enabled {
1761 return;
1762 }
1763 self.detached.spawn_blocking(|| {
1766 use std::io::Write;
1767 let mut stdout = std::io::stdout();
1768 let _ = stdout.write_all(b"\x07");
1769 let _ = stdout.flush();
1770 });
1771 },
1772 }
1773 }
1774
1775 fn queue_persistence(&mut self, job: PersistenceJob) {
1776 let previous = self.persistence_tail.take();
1777 let state = Arc::clone(&self.persistence_state);
1778 let tx = self.msg_tx.clone();
1779 self.persistence_tail = Some(tokio::spawn(async move {
1780 if let Some(previous) = previous
1781 && let Err(error) = previous.await
1782 {
1783 tracing::warn!(error = %error, "previous persistence job panicked");
1784 }
1785
1786 let result = tokio::task::spawn_blocking(move || {
1787 state
1788 .lock()
1789 .unwrap_or_else(|error| error.into_inner())
1790 .process(job)
1791 })
1792 .await;
1793
1794 match result {
1795 Ok((events, outcome)) => {
1796 if outcome.is_ok() || !events.is_empty() {
1800 let _ = tx.send(Msg::SessionSaved).await;
1801 }
1802 for event in events {
1803 fire_compaction_hook(&event).await;
1804 }
1805 if let Err(error) = outcome {
1806 tracing::warn!(
1807 error = %error,
1808 "persistence job failed; compaction barriers remain queued"
1809 );
1810 }
1811 },
1812 Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1813 }
1814 }));
1815 }
1816
1817 pub async fn shutdown(mut self) {
1821 for (id, scope) in self.scopes.iter() {
1822 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1823 scope.cancel();
1824 }
1825
1826 if let Some(handle) = self.config_watch.take() {
1829 handle.abort();
1830 }
1831
1832 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1834
1835 let owns_global_mcp = self.owns_global_mcp;
1836 let persistence_tail = self.persistence_tail.take();
1837 let persistence_state = Arc::clone(&self.persistence_state);
1838 let drain = async {
1839 if let Some(tail) = persistence_tail
1840 && let Err(error) = tail.await
1841 {
1842 tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1843 }
1844 match tokio::task::spawn_blocking(move || {
1845 let mut state = persistence_state
1846 .lock()
1847 .unwrap_or_else(|error| error.into_inner());
1848 let drained = state.retry_all_blocked();
1849 if let Err(error) = state.flush_checkpoints() {
1853 tracing::warn!(
1854 error = %error,
1855 "shutdown: could not flush a session checkpoint; the log still has everything, so the next resume just folds further"
1856 );
1857 }
1858 drop(state);
1859 drained
1860 })
1861 .await
1862 {
1863 Ok((events, outcome)) => {
1864 for event in events {
1867 fire_compaction_hook(&event).await;
1868 }
1869 if let Err(error) = outcome {
1870 tracing::warn!(
1871 error = %error,
1872 "shutdown: compaction persistence barrier retry failed"
1873 );
1874 }
1875 },
1876 Err(error) => tracing::warn!(
1877 error = %error,
1878 "shutdown: compaction persistence barrier panicked"
1879 ),
1880 }
1881 if owns_global_mcp {
1885 let _ = tokio::time::timeout(
1891 std::time::Duration::from_secs(2),
1892 crate::mcp::manager_ref::wait_ready(),
1893 )
1894 .await;
1895 if let Some(mgr) = crate::mcp::manager_ref::get() {
1902 mgr.shutdown().await;
1903 }
1904 crate::searxng::shutdown().await;
1908 }
1909 for (id, mut scope) in self.scopes.drain() {
1915 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1916 .await
1917 .is_err()
1918 {
1919 tracing::warn!(
1920 turn = %id,
1921 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1922 "shutdown: scope drain timed out; aborting its remaining tasks"
1923 );
1924 }
1925 }
1926 while let Some(result) = self.detached.join_next().await {
1927 if let Err(e) = result
1928 && !e.is_cancelled()
1929 {
1930 tracing::warn!(error = %e, "shutdown: detached task panic");
1931 }
1932 }
1933 };
1934
1935 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1936 }
1937}
1938
1939fn note_stream_usage(
1947 tasks: &crate::providers::TaskBroker,
1948 usage: &Option<mermaid_model::models::TokenUsage>,
1949) {
1950 if let Some(usage) = usage {
1951 tasks.add_tokens(usage.completion_tokens as u64);
1952 }
1953}
1954
1955fn filter_suppressed(
1959 tools: Vec<mermaid_domain::ToolDefinition>,
1960 suppressed: &[&'static str],
1961) -> Vec<mermaid_domain::ToolDefinition> {
1962 if suppressed.is_empty() {
1963 return tools;
1964 }
1965 tools
1966 .into_iter()
1967 .filter(|t| !suppressed.contains(&t.name.as_str()))
1968 .collect()
1969}
1970
1971mod compaction;
1972mod memory;
1973mod model_call;
1974mod tool_call;
1975
1976use compaction::*;
1977use memory::*;
1978use model_call::*;
1979use tool_call::*;
1980
1981#[cfg(test)]
1982mod tests {
1983 #[test]
1987 fn compaction_row_maps_every_field_it_claims_to() {
1988 use mermaid_domain::{CompactionEvent, CompactionReviewStatus, CompactionTrigger};
1989 let record = CompactionEvent {
1990 id: "cmp-1".to_string(),
1991 trigger: CompactionTrigger::Manual,
1992 created_at: chrono::Local::now(),
1993 before_tokens: 9_000,
1994 after_tokens: 1_200,
1995 archived_message_count: 40,
1996 preserved_message_count: 6,
1997 preserved_turn_count: 3,
1998 summary_tokens: 450,
1999 duration_secs: 1.5,
2000 review_status: CompactionReviewStatus::Reviewed,
2001 review_error: None,
2002 focus: None,
2003 archive_path: None,
2004 };
2005 let row = compaction_row(
2006 &record,
2007 std::path::Path::new("/tmp/archive.json"),
2008 Some("task-7".to_string()),
2009 "sess-3".to_string(),
2010 );
2011 assert_eq!(row.id.as_deref(), Some("cmp-1"));
2012 assert_eq!(row.task_id.as_deref(), Some("task-7"));
2013 assert_eq!(row.session_id.as_deref(), Some("sess-3"));
2014 assert_eq!(row.source_token_estimate, Some(9_000));
2015 assert_eq!(row.summary_token_count, Some(450));
2016 assert_eq!(row.preserved_turns, Some(3));
2017 assert!(row.archive_path.is_some_and(|p| p.contains("archive.json")));
2018 assert_eq!(
2019 row.verification_status.as_deref(),
2020 Some(CompactionReviewStatus::Reviewed.as_str())
2021 );
2022 }
2023
2024 use super::*;
2025 use mermaid_domain::ToolCallId;
2026 use std::time::Duration;
2027
2028 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2029 EffectRunner::pair(PathBuf::from("/tmp"))
2030 }
2031
2032 #[test]
2035 fn filter_suppressed_drops_only_the_named_tools() {
2036 let def = |name: &str| mermaid_domain::ToolDefinition {
2037 name: name.to_string(),
2038 description: String::new(),
2039 input_schema: serde_json::json!({}),
2040 };
2041 let tools = vec![def("task_create"), def("task_list"), def("task_update")];
2042 let kept = filter_suppressed(tools.clone(), &["task_create", "task_update"]);
2043 assert_eq!(
2044 kept.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
2045 vec!["task_list"]
2046 );
2047 let kept = filter_suppressed(tools, &[]);
2048 assert_eq!(kept.len(), 3, "empty suppression list is a no-op");
2049 }
2050
2051 #[test]
2052 fn runtime_tool_payloads_are_redacted_before_serialization() {
2053 let payload = serde_json::json!({
2054 "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
2055 "authorization": "opaque-secret-value",
2056 "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
2057 });
2058 let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
2059 assert!(
2060 !serialized.contains("hunter2"),
2061 "URL password leaked: {serialized}"
2062 );
2063 assert!(
2064 !serialized.contains("opaque-signature"),
2065 "signed URL leaked: {serialized}"
2066 );
2067 assert!(
2068 !serialized.contains("private"),
2069 "URL fragment leaked: {serialized}"
2070 );
2071 assert!(
2072 !serialized.contains("opaque-secret-value"),
2073 "credential-named field leaked: {serialized}"
2074 );
2075 assert!(
2076 !serialized.contains("abcdef123456ghijkl"),
2077 "bearer token leaked: {serialized}"
2078 );
2079 assert!(
2080 !serialized.contains("sk-abcdefghijklmnop1234"),
2081 "secret-shaped fetched content leaked: {serialized}"
2082 );
2083 assert!(serialized.contains("[REDACTED]"));
2084 }
2085
2086 #[test]
2087 fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
2088 let root = std::env::temp_dir().join(format!(
2089 "mermaid-walk-{}-{:?}",
2090 std::process::id(),
2091 std::thread::current().id()
2092 ));
2093 let _ = std::fs::remove_dir_all(&root);
2094 std::fs::create_dir_all(root.join("src")).unwrap();
2095 std::fs::create_dir_all(root.join("target")).unwrap();
2096 std::fs::create_dir_all(root.join(".git")).unwrap();
2097 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
2098 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2099 std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
2100 std::fs::write(root.join("README.md"), "readme").unwrap();
2101 std::fs::write(root.join(".hidden"), "hidden").unwrap();
2102
2103 let files = walk_project_files(&root);
2104 assert_eq!(
2105 files,
2106 vec![
2107 "README.md".to_string(),
2108 "src/".to_string(),
2109 "src/main.rs".to_string(),
2110 ],
2111 "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
2112 );
2113 let _ = std::fs::remove_dir_all(&root);
2114 }
2115
2116 #[test]
2117 fn new_child_suppresses_terminal_title() {
2118 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2122 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2123 let tools = Arc::new(ToolRegistry::new());
2124 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2125 assert!(
2126 !child.terminal_title_enabled,
2127 "subagent child runner must suppress terminal-title escapes"
2128 );
2129 }
2130
2131 #[test]
2132 fn new_child_does_not_own_global_mcp_shutdown() {
2133 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2138 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2139 let tools = Arc::new(ToolRegistry::new());
2140 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2141 assert!(
2142 !child.owns_global_mcp,
2143 "child runner must not reap the shared global MCP manager"
2144 );
2145 let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
2146 assert!(
2147 top.owns_global_mcp,
2148 "top-level runner still owns the global MCP reap"
2149 );
2150 }
2151
2152 #[test]
2153 fn parse_prune_plan_extracts_json_amid_prose() {
2154 let plan = parse_prune_plan(
2155 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2156 )
2157 .expect("should parse");
2158 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2159 assert_eq!(plan.reason, "dupes");
2160 }
2161
2162 #[test]
2163 fn parse_prune_plan_handles_empty_and_garbage() {
2164 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2165 .expect("empty plan parses");
2166 assert!(empty.prune.is_empty());
2167 assert!(parse_prune_plan("no json here").is_none());
2168 }
2169
2170 #[test]
2171 fn memory_title_from_text_is_short_and_nonempty() {
2172 assert_eq!(
2173 memory_title_from_text("prefer ripgrep over grep"),
2174 "prefer ripgrep over grep"
2175 );
2176 assert_eq!(memory_title_from_text(" "), "memory");
2177 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2178 assert!(long.split_whitespace().count() <= 8);
2179 }
2180
2181 #[tokio::test]
2182 async fn dispatch_exit_is_noop_on_runner_state() {
2183 let (mut r, _rx) = runner();
2184 r.dispatch(Cmd::Exit);
2185 assert_eq!(r.scope_count(), 0);
2186 }
2187
2188 #[tokio::test]
2189 async fn dispatch_save_emits_session_saved() {
2190 let (mut r, mut rx) = runner();
2191 r.dispatch(Cmd::SaveConversation {
2192 snapshot: mermaid_domain::ConversationHistory::new(
2193 "/p".to_string(),
2194 "m".to_string(),
2195 chrono::Local::now(),
2196 ),
2197 events: Vec::new(),
2198 });
2199 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2200 .await
2201 .expect("sender emits")
2202 .expect("channel alive");
2203 assert!(matches!(msg, Msg::SessionSaved));
2204 }
2205
2206 #[cfg(unix)]
2207 #[tokio::test]
2208 async fn init_mcp_servers_emits_incremental_errored_msgs() {
2209 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
2213 let mut configs = std::collections::HashMap::new();
2214 for name in ["one", "two"] {
2215 configs.insert(
2216 name.to_string(),
2217 mermaid_domain::McpServerConfig {
2218 command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
2219 ..Default::default()
2220 },
2221 );
2222 }
2223 dispatch_init_mcp_servers(configs, tx).await;
2224 let mut errored = Vec::new();
2225 while let Ok(msg) = rx.try_recv() {
2226 match msg {
2227 Msg::McpServerErrored { name, .. } => errored.push(name),
2228 other => panic!("unexpected msg: {other:?}"),
2229 }
2230 }
2231 errored.sort();
2232 assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
2233 assert!(crate::mcp::manager_ref::is_ready());
2234 }
2235
2236 #[tokio::test]
2237 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2238 let (mut r, mut rx) = runner();
2239 let turn = TurnId(77);
2240 {
2241 let scope = r.scope_mut(turn);
2242 scope.spawn(async {
2243 std::future::pending::<()>().await;
2244 });
2245 }
2246 assert_eq!(r.scope_count(), 1);
2247
2248 let start = std::time::Instant::now();
2249 r.dispatch(Cmd::CancelScope(turn));
2250 assert_eq!(r.scope_count(), 0);
2251 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2252 .await
2253 .expect("bounded cancel should emit terminal message")
2254 .expect("channel alive");
2255 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2256 assert!(
2257 start.elapsed() < Duration::from_millis(500),
2258 "cancel terminal message took {:?}",
2259 start.elapsed()
2260 );
2261 }
2262
2263 #[tokio::test]
2264 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2265 let (mut r, mut rx) = runner();
2271 let turn = TurnId(88);
2272 {
2273 let scope = r.scope_mut(turn);
2274 scope.spawn(async {}); }
2276 assert_eq!(r.scope_count(), 1);
2277
2278 tokio::time::sleep(Duration::from_millis(20)).await;
2280 r.dispatch(Cmd::Exit);
2281 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2282
2283 r.dispatch(Cmd::CancelScope(turn));
2286 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2287 .await
2288 .expect("cancel on a reaped scope must still emit a terminal message")
2289 .expect("channel alive");
2290 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2291 }
2292
2293 #[tokio::test]
2294 async fn dispatch_call_model_creates_scope() {
2295 let (mut r, _rx) = runner();
2296 let turn = TurnId(7);
2297 let request = mermaid_domain::ChatRequest {
2298 model_id: "test/m".to_string(),
2299 messages: vec![],
2300 system_prompt: String::new(),
2301 instructions: None,
2302 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2303 temperature: 0.7,
2304 max_tokens: 4096,
2305 tools: vec![],
2306
2307 ollama_num_ctx: None,
2308 ollama_allow_ram_offload: None,
2309 resolved_context_window: None,
2310 resolved_max_output: None,
2311 output_schema: None,
2312 suppress_auto_compact: false,
2313 suppressed_builtin_tools: Vec::new(),
2314 };
2315 r.dispatch(Cmd::CallModel { turn, request });
2316 assert_eq!(r.scope_count(), 1);
2317 }
2318
2319 #[tokio::test]
2323 async fn empty_scopes_are_reaped_on_next_dispatch() {
2324 let (mut r, mut rx) = runner();
2325 let turn = TurnId(42);
2326 let request = mermaid_domain::ChatRequest {
2327 model_id: "test/m".to_string(),
2328 messages: vec![],
2329 system_prompt: String::new(),
2330 instructions: None,
2331 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2332 temperature: 0.7,
2333 max_tokens: 4096,
2334 tools: vec![],
2335
2336 ollama_num_ctx: None,
2337 ollama_allow_ram_offload: None,
2338 resolved_context_window: None,
2339 resolved_max_output: None,
2340 output_schema: None,
2341 suppress_auto_compact: false,
2342 suppressed_builtin_tools: Vec::new(),
2343 };
2344 r.dispatch(Cmd::CallModel { turn, request });
2345 assert_eq!(r.scope_count(), 1);
2346
2347 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2352 .await
2353 .expect("upstream error arrived")
2354 .expect("channel alive");
2355 assert!(matches!(msg, Msg::UpstreamError { .. }));
2356
2357 tokio::task::yield_now().await;
2359
2360 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2362 assert_eq!(
2363 r.scope_count(),
2364 0,
2365 "completed scope must be reaped on next dispatch"
2366 );
2367 }
2368
2369 #[tokio::test]
2370 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2371 let (mut r, mut rx) = runner();
2372 let turn = TurnId(7);
2373 let call_id = ToolCallId(1);
2374 let source = mermaid_model::models::tool_call::ToolCall {
2375 id: Some("c1".to_string()),
2376 function: mermaid_model::models::tool_call::FunctionCall {
2377 name: "read_file".to_string(),
2378 arguments: serde_json::json!({"path": "x"}),
2379 },
2380 };
2381 r.dispatch(Cmd::ExecuteTool {
2382 turn,
2383 call_id,
2384 source,
2385 dispatch: mermaid_domain::ToolDispatch {
2386 model_id: "ollama/test".to_string(),
2387 safety_mode: mermaid_runtime::SafetyMode::Ask,
2388 plan_file: None,
2389 plan_permissions: mermaid_domain::PlanPermissions::default(),
2390 context_percent: None,
2391 intent: None,
2392 session_id: "sess-test".to_string(),
2393 message_index: 0,
2394 scratchpad: None,
2395 },
2396 });
2397 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2398 .await
2399 .expect("some msg")
2400 .expect("channel alive");
2401 assert!(matches!(
2402 first,
2403 Msg::ToolStarted {
2404 turn: t,
2405 call_id: c,
2406 } if t == turn && c == call_id
2407 ));
2408 }
2409
2410 #[tokio::test]
2411 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2412 let (mut r, _rx) = runner();
2413 let turn = TurnId(9);
2414 r.dispatch(Cmd::CallModel {
2415 turn,
2416 request: mermaid_domain::ChatRequest {
2417 model_id: "m".to_string(),
2418 messages: vec![],
2419 system_prompt: String::new(),
2420 instructions: None,
2421 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2422 temperature: 0.7,
2423 max_tokens: 4096,
2424 tools: vec![],
2425
2426 ollama_num_ctx: None,
2427 ollama_allow_ram_offload: None,
2428 resolved_context_window: None,
2429 resolved_max_output: None,
2430 output_schema: None,
2431 suppress_auto_compact: false,
2432 suppressed_builtin_tools: Vec::new(),
2433 },
2434 });
2435 assert_eq!(r.scope_count(), 1);
2436
2437 r.dispatch(Cmd::CancelScope(turn));
2438 assert_eq!(r.scope_count(), 0);
2439 }
2440
2441 #[tokio::test]
2442 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2443 let (mut r, _rx) = runner();
2449 let req = || mermaid_domain::ChatRequest {
2450 model_id: "test/m".to_string(),
2451 messages: vec![],
2452 system_prompt: String::new(),
2453 instructions: None,
2454 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2455 temperature: 0.7,
2456 max_tokens: 4096,
2457 tools: vec![],
2458 ollama_num_ctx: None,
2459 ollama_allow_ram_offload: None,
2460 resolved_context_window: None,
2461 resolved_max_output: None,
2462 output_schema: None,
2463 suppress_auto_compact: false,
2464 suppressed_builtin_tools: Vec::new(),
2465 };
2466 let turn = TurnId(123);
2467
2468 r.dispatch(Cmd::CallModel {
2469 turn,
2470 request: req(),
2471 });
2472 assert_eq!(r.scope_count(), 1);
2473
2474 r.dispatch(Cmd::CancelScope(turn));
2476 assert_eq!(r.scope_count(), 0);
2477
2478 r.dispatch(Cmd::CallModel {
2480 turn,
2481 request: req(),
2482 });
2483 assert_eq!(
2484 r.scope_count(),
2485 0,
2486 "a cancelled turn must not be resurrected by a late scoped Cmd"
2487 );
2488
2489 r.dispatch(Cmd::CallModel {
2491 turn: TurnId(124),
2492 request: req(),
2493 });
2494 assert_eq!(
2495 r.scope_count(),
2496 1,
2497 "a fresh turn must still create its scope normally"
2498 );
2499 }
2500
2501 #[tokio::test]
2502 async fn shutdown_drains_pending_saves() {
2503 let (mut r, _rx) = runner();
2504 for _ in 0..5 {
2505 r.dispatch(Cmd::SaveConversation {
2506 snapshot: mermaid_domain::ConversationHistory::new(
2507 "/p".to_string(),
2508 "m".to_string(),
2509 chrono::Local::now(),
2510 ),
2511 events: Vec::new(),
2512 });
2513 }
2514 let start = std::time::Instant::now();
2516 r.shutdown().await;
2517 assert!(start.elapsed() < Duration::from_secs(2));
2518 }
2519
2520 fn persistence_fixture(
2521 root: &std::path::Path,
2522 record_id: &str,
2523 ) -> (mermaid_domain::ConversationHistory, PendingCompactionSave) {
2524 let now = chrono::Local::now();
2525 let mut full = mermaid_domain::ConversationHistory::new(
2526 root.display().to_string(),
2527 "test/model".to_string(),
2528 now,
2529 );
2530 full.add_messages(
2531 &[mermaid_model::models::ChatMessage::user("raw history")],
2532 now,
2533 );
2534 let mut compacted = full.clone();
2535 compacted.replace_messages(
2536 vec![mermaid_model::models::ChatMessage::user(
2537 "compacted checkpoint",
2538 )],
2539 now,
2540 );
2541 let record = mermaid_domain::CompactionEvent {
2542 id: record_id.to_string(),
2543 trigger: mermaid_domain::CompactionTrigger::Manual,
2544 created_at: now,
2545 before_tokens: 100,
2546 after_tokens: 20,
2547 archived_message_count: 1,
2548 preserved_message_count: 1,
2549 preserved_turn_count: 1,
2550 summary_tokens: 10,
2551 duration_secs: 0.1,
2552 review_status: mermaid_domain::CompactionReviewStatus::Reviewed,
2553 review_error: None,
2554 focus: None,
2555 archive_path: None,
2556 };
2557 (
2558 full,
2559 PendingCompactionSave {
2560 record,
2561 conversation: compacted,
2562 events: vec![mermaid_domain::SessionEvent::Input {
2565 text: "compaction boundary".to_string(),
2566 }],
2567 events_appended: false,
2568 task_id: None,
2569 },
2570 )
2571 }
2572
2573 fn block_event_log(root: &std::path::Path, id: &str) {
2578 let dir = root.join(".mermaid").join("conversations");
2579 std::fs::create_dir_all(&dir).expect("conversations dir");
2580 std::fs::create_dir_all(dir.join(format!("{id}.jsonl"))).expect("plant a blocker");
2581 }
2582
2583 fn one_message_save(
2585 conversation: &mut mermaid_domain::ConversationHistory,
2586 text: &str,
2587 ) -> PersistenceJob {
2588 let message = mermaid_model::models::ChatMessage::user(text);
2589 conversation.add_messages(std::slice::from_ref(&message), chrono::Local::now());
2590 PersistenceJob::Conversation {
2591 snapshot: Box::new(conversation.clone()),
2592 events: vec![mermaid_domain::SessionEvent::Message { message }],
2593 }
2594 }
2595
2596 #[test]
2597 fn the_checkpoint_stops_being_written_on_every_save() {
2598 let root = std::env::temp_dir().join(format!(
2602 "mermaid-throttle-{}-{:?}",
2603 std::process::id(),
2604 std::thread::current().id()
2605 ));
2606 let _ = std::fs::remove_dir_all(&root);
2607 let manager = crate::session::ConversationManager::new(&root).unwrap();
2608 let mut conversation = mermaid_domain::ConversationHistory::new(
2609 root.display().to_string(),
2610 "test/model".to_string(),
2611 chrono::Local::now(),
2612 );
2613 let mut state = PersistenceState::new(root.clone());
2614
2615 state
2618 .process(one_message_save(&mut conversation, "first"))
2619 .1
2620 .unwrap();
2621 let checkpoint = manager
2622 .conversations_dir()
2623 .join(format!("{}.json", conversation.id));
2624 assert!(
2625 !checkpoint.exists(),
2626 "a single save must not rewrite the transcript"
2627 );
2628
2629 let resumed = manager.load_conversation(&conversation.id).unwrap();
2631 assert_eq!(resumed.messages().len(), 1);
2632 assert_eq!(resumed.messages()[0].content, "first");
2633
2634 for i in 0..CHECKPOINT_EVERY_EVENTS {
2636 state
2637 .process(one_message_save(&mut conversation, &format!("m{i}")))
2638 .1
2639 .unwrap();
2640 }
2641 assert!(
2642 checkpoint.exists(),
2643 "crossing {CHECKPOINT_EVERY_EVENTS} events must materialize a checkpoint"
2644 );
2645 let resumed = manager.load_conversation(&conversation.id).unwrap();
2646 assert_eq!(resumed.messages().len(), CHECKPOINT_EVERY_EVENTS + 1);
2647 let _ = std::fs::remove_dir_all(root);
2648 }
2649
2650 #[test]
2651 fn shutdown_flushes_the_checkpoint_it_was_holding() {
2652 let root = std::env::temp_dir().join(format!(
2653 "mermaid-flush-{}-{:?}",
2654 std::process::id(),
2655 std::thread::current().id()
2656 ));
2657 let _ = std::fs::remove_dir_all(&root);
2658 let manager = crate::session::ConversationManager::new(&root).unwrap();
2659 let mut conversation = mermaid_domain::ConversationHistory::new(
2660 root.display().to_string(),
2661 "test/model".to_string(),
2662 chrono::Local::now(),
2663 );
2664 let mut state = PersistenceState::new(root.clone());
2665 state
2666 .process(one_message_save(&mut conversation, "only message"))
2667 .1
2668 .unwrap();
2669
2670 let checkpoint = manager
2671 .conversations_dir()
2672 .join(format!("{}.json", conversation.id));
2673 assert!(!checkpoint.exists());
2674 state.flush_checkpoints().unwrap();
2675 assert!(
2676 checkpoint.exists(),
2677 "a clean exit must leave a current checkpoint"
2678 );
2679 let raw = std::fs::read_to_string(&checkpoint).unwrap();
2681 let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2682 assert!(
2683 value.get("checkpoint_seq").is_some(),
2684 "a flushed checkpoint must be placeable in its log: {raw}"
2685 );
2686 let _ = std::fs::remove_dir_all(root);
2687 }
2688
2689 #[test]
2690 fn a_failed_append_keeps_its_events_for_the_next_save() {
2691 let root = std::env::temp_dir().join(format!(
2694 "mermaid-unappended-{}-{:?}",
2695 std::process::id(),
2696 std::thread::current().id()
2697 ));
2698 let _ = std::fs::remove_dir_all(&root);
2699 let manager = crate::session::ConversationManager::new(&root).unwrap();
2700 let mut conversation = mermaid_domain::ConversationHistory::new(
2701 root.display().to_string(),
2702 "test/model".to_string(),
2703 chrono::Local::now(),
2704 );
2705 let mut state = PersistenceState::new(root.clone());
2706
2707 std::fs::create_dir_all(
2709 manager
2710 .conversations_dir()
2711 .join(format!("{}.jsonl", conversation.id)),
2712 )
2713 .expect("plant a blocker");
2714 let job = one_message_save(&mut conversation, "must survive");
2715 assert!(state.process(job).1.is_err(), "the append must fail");
2716 assert_eq!(
2717 state.unappended.get(&conversation.id).map(Vec::len),
2718 Some(1),
2719 "the batch must be held, not dropped"
2720 );
2721
2722 std::fs::remove_dir_all(
2724 manager
2725 .conversations_dir()
2726 .join(format!("{}.jsonl", conversation.id)),
2727 )
2728 .expect("unblock");
2729 state
2730 .process(one_message_save(&mut conversation, "and this one"))
2731 .1
2732 .unwrap();
2733 assert!(state.unappended.is_empty(), "the hold must clear");
2734 state.flush_checkpoints().unwrap();
2735
2736 let resumed = manager.load_conversation(&conversation.id).unwrap();
2737 let texts: Vec<&str> = resumed
2738 .messages()
2739 .iter()
2740 .map(|m| m.content.as_str())
2741 .collect();
2742 assert!(
2743 texts.contains(&"must survive"),
2744 "the event held over a failed append must reach the log: {texts:?}"
2745 );
2746 assert!(texts.contains(&"and this one"), "{texts:?}");
2747 let _ = std::fs::remove_dir_all(root);
2748 }
2749
2750 #[test]
2751 fn persistence_orders_compaction_before_newer_conversation_save() {
2752 let root = std::env::temp_dir().join(format!(
2753 "mermaid-persistence-order-{}-{:?}",
2754 std::process::id(),
2755 std::thread::current().id()
2756 ));
2757 let _ = std::fs::remove_dir_all(&root);
2758 let (full, compaction) = persistence_fixture(&root, "compact_ordered");
2759 let manager = crate::session::ConversationManager::new(&root).unwrap();
2760 manager.save_conversation(&full).unwrap();
2761
2762 let mut state = PersistenceState::new(root.clone());
2763 let (events, outcome) =
2764 state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
2765 outcome.unwrap();
2766 assert_eq!(events.len(), 1);
2767 let mut newer = compaction.conversation;
2768 let reply = mermaid_model::models::ChatMessage::assistant("new assistant reply");
2769 newer.add_messages(std::slice::from_ref(&reply), chrono::Local::now());
2770 let (_, outcome) = state.process(PersistenceJob::Conversation {
2775 snapshot: Box::new(newer),
2776 events: vec![mermaid_domain::SessionEvent::Message { message: reply }],
2777 });
2778 outcome.unwrap();
2779
2780 let loaded = crate::session::ConversationManager::new(&root)
2781 .unwrap()
2782 .load_conversation(&full.id)
2783 .unwrap();
2784 assert!(
2785 loaded
2786 .messages()
2787 .iter()
2788 .any(|message| message.content == "new assistant reply")
2789 );
2790 let _ = std::fs::remove_dir_all(root);
2791 }
2792
2793 #[test]
2794 fn failed_event_append_blocks_later_stripped_conversation_save() {
2795 let root = std::env::temp_dir().join(format!(
2796 "mermaid-persistence-barrier-{}-{:?}",
2797 std::process::id(),
2798 std::thread::current().id()
2799 ));
2800 let _ = std::fs::remove_dir_all(&root);
2801 let (full, compaction) = persistence_fixture(&root, "compact_blocked");
2802 let manager = crate::session::ConversationManager::new(&root).unwrap();
2803 manager.save_conversation(&full).unwrap();
2804 block_event_log(&root, &full.id);
2805
2806 let mut state = PersistenceState::new(root.clone());
2807 assert!(
2808 state
2809 .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
2810 .1
2811 .is_err()
2812 );
2813 assert!(
2814 state
2815 .process(PersistenceJob::Conversation {
2816 snapshot: Box::new(compaction.conversation),
2817 events: Vec::new(),
2818 })
2819 .1
2820 .is_err()
2821 );
2822 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2823
2824 let loaded = crate::session::ConversationManager::new(&root)
2825 .unwrap()
2826 .load_conversation(&full.id)
2827 .unwrap();
2828 assert_eq!(loaded.messages()[0].content, "raw history");
2829 let _ = std::fs::remove_dir_all(root);
2830 }
2831
2832 #[test]
2833 fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
2834 let root = std::env::temp_dir().join(format!(
2835 "mermaid-persistence-queue-{}-{:?}",
2836 std::process::id(),
2837 std::thread::current().id()
2838 ));
2839 let _ = std::fs::remove_dir_all(&root);
2840 let (full, first) = persistence_fixture(&root, "compact_first");
2841 let mut second = first.clone();
2842 second.record.id = "compact_second".to_string();
2843 block_event_log(&root, &full.id);
2844
2845 let mut state = PersistenceState::new(root.clone());
2846 assert!(
2847 state
2848 .process(PersistenceJob::Compaction(Box::new(first)))
2849 .1
2850 .is_err()
2851 );
2852 assert!(
2855 state
2856 .process(PersistenceJob::Compaction(Box::new(second)))
2857 .1
2858 .is_err()
2859 );
2860 let queued = state.blocked.get(&full.id).expect("barrier queue");
2861 assert_eq!(queued.len(), 2);
2862 assert_eq!(queued[0].record.id, "compact_first");
2863 assert_eq!(queued[1].record.id, "compact_second");
2864 let _ = std::fs::remove_dir_all(root);
2865 }
2866
2867 #[test]
2868 fn retry_all_blocked_attempts_every_conversation() {
2869 let root = std::env::temp_dir().join(format!(
2870 "mermaid-persistence-drain-{}-{:?}",
2871 std::process::id(),
2872 std::thread::current().id()
2873 ));
2874 let _ = std::fs::remove_dir_all(&root);
2875 let (bad_full, bad) = persistence_fixture(&root, "compact_bad");
2876 let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
2877 block_event_log(&root, &bad_full.id);
2878 good_full.id = "20990101_000000_001".to_string();
2882 good.conversation.id = good_full.id.clone();
2883
2884 let mut state = PersistenceState::new(root.clone());
2885 state
2886 .blocked
2887 .entry(bad_full.id.clone())
2888 .or_default()
2889 .push_back(bad);
2890 state
2891 .blocked
2892 .entry(good_full.id.clone())
2893 .or_default()
2894 .push_back(good);
2895
2896 let (events, outcome) = state.retry_all_blocked();
2900 assert!(outcome.is_err());
2901 assert_eq!(events.len(), 1);
2902 assert_eq!(events[0].id, "compact_good");
2903 assert!(!state.blocked.contains_key(&good_full.id));
2904 assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
2905 let loaded = crate::session::ConversationManager::new(&root)
2906 .unwrap()
2907 .load_conversation(&good_full.id)
2908 .unwrap();
2909 assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
2910 let _ = std::fs::remove_dir_all(root);
2911 }
2912
2913 #[test]
2914 fn partially_drained_barrier_reports_its_persisted_events() {
2915 let root = std::env::temp_dir().join(format!(
2916 "mermaid-persistence-partial-{}-{:?}",
2917 std::process::id(),
2918 std::thread::current().id()
2919 ));
2920 let _ = std::fs::remove_dir_all(&root);
2921 let (full, good) = persistence_fixture(&root, "compact_good");
2922 let mut bad = good.clone();
2923 bad.record.id = "compact_bad".to_string();
2924 bad.conversation.id = "../invalid".to_string();
2927
2928 let mut state = PersistenceState::new(root.clone());
2929 let queue = state.blocked.entry(full.id.clone()).or_default();
2930 queue.push_back(good);
2931 queue.push_back(bad);
2932
2933 let (events, outcome) = state.retry_blocked(&full.id);
2937 assert!(outcome.is_err());
2938 assert_eq!(events.len(), 1);
2939 assert_eq!(events[0].id, "compact_good");
2940 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2941 let _ = std::fs::remove_dir_all(root);
2942 }
2943}